diff --git a/core/crm/__init__.py b/core/crm/__init__.py new file mode 100644 index 00000000..a628855a --- /dev/null +++ b/core/crm/__init__.py @@ -0,0 +1,5 @@ +from core.models import CustomerRelationshipManagementProvider + + +def any_crm_integrations(): + return CustomerRelationshipManagementProvider.objects.exists() diff --git a/core/crm/amo/__init__.py b/core/crm/amo/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/crm/amo/gateway.py b/core/crm/amo/gateway.py new file mode 100644 index 00000000..e699bd8c --- /dev/null +++ b/core/crm/amo/gateway.py @@ -0,0 +1,110 @@ +import logging +import time +from typing import Optional + +import requests +from django.core.cache import cache +from django.db import transaction + +from core.crm.exceptions import CRMException +from core.models import CustomerRelationshipManagementProvider, Order, OrderCrmLink + +logger = logging.getLogger("django") + + +class AmoOrderGateway: + def __init__(self): + try: + self.instance = CustomerRelationshipManagementProvider.objects.get(name="amo") + except CustomerRelationshipManagementProvider.DoesNotExist: + logger.warning("AMO CRM provider not found") + raise CRMException("AMO CRM provider not found") + + self.base = f"https://{self.instance.integration_url}" + + self.client_id = self.instance.authentication.get("client_id") + self.client_secret = self.instance.authentication.get("client_secret") + self.refresh_token = cache.get("amo_refresh_token") + + self.redirect_uri = self.instance.attributes.get("redirect_uri") + self.pipeline_id = self.instance.attributes.get("pipeline_id") + self.stage_map = self.instance.attributes.get("stage_map") + self.responsible_user_id = self.instance.attributes.get("responsible_user_id") + + if not all( + [ + self.base, + self.client_id, + self.client_secret, + self.redirect_uri, + self.pipeline_id, + self.stage_map, + ] + ): + raise CRMException("AMO CRM provider not configured") + + def _token(self) -> str: + cached = getattr(self, "_cached_token", None) + expiry = getattr(self, "_cached_expiry", 0) + if cached and time.time() < expiry - 15: + return cached + payload = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "grant_type": "refresh_token", + "refresh_token": self.refresh_token, + "redirect_uri": self.redirect_uri, + } + r = requests.post(f"{self.base}/oauth2/access_token", json=payload, timeout=15) + r.raise_for_status() + data = r.json() + self._cached_token = data["access_token"] + self._cached_expiry = time.time() + int(data.get("expires_in", 900)) + self.refresh_token = data.get("refresh_token", self.refresh_token) + return self._cached_token + + def _headers(self) -> dict: + return {"Authorization": f"Bearer {self._token()}", "Content-Type": "application/json"} + + def _build_lead_payload(self, order: Order) -> dict: + name = f"Order #{order.human_readable_id}" + price = int(round(order.total_price)) + stage_id = self.stage_map.get(order.status) + payload = {"name": name, "price": price, "pipeline_id": self.pipeline_id} + if stage_id: + payload["status_id"] = stage_id + if self.responsible_user_id: + payload["responsible_user_id"] = self.responsible_user_id + return payload + + def upsert_order(self, order: Order) -> str: + with transaction.atomic(): + link: Optional[OrderCrmLink] = OrderCrmLink.objects.filter(order=order).first() + if link: + lead_id = link.crm_lead_id + payload = self._build_lead_payload(order) + r = requests.patch( + f"{self.base}/api/v4/leads/{lead_id}", json=payload, headers=self._headers(), timeout=15 + ) + if r.status_code not in (200, 204): + r.raise_for_status() + return lead_id + payload = self._build_lead_payload(order) + r = requests.post(f"{self.base}/api/v4/leads", json=[payload], headers=self._headers(), timeout=15) + r.raise_for_status() + body = r.json() + lead_id = str(body["_embedded"]["leads"][0]["id"]) + OrderCrmLink.objects.create(order_uuid=order.uuid, crm_lead_id=lead_id) + return lead_id + + def update_order_status(self, crm_lead_id: str, new_status_code: str) -> None: + link = OrderCrmLink.objects.filter(crm_lead_id=crm_lead_id).first() + if not link: + return + from core.models import Order + + order = Order.objects.get(uuid=link.order_uuid) + if order.status == new_status_code: + return + order.status = new_status_code + order.save(update_fields=["status"]) diff --git a/core/crm/exceptions.py b/core/crm/exceptions.py new file mode 100644 index 00000000..8ade09c6 --- /dev/null +++ b/core/crm/exceptions.py @@ -0,0 +1,2 @@ +class CRMException(Exception): + pass diff --git a/core/models.py b/core/models.py index ac6012cc..9235865d 100644 --- a/core/models.py +++ b/core/models.py @@ -30,6 +30,7 @@ from django.db.models import ( PositiveIntegerField, QuerySet, TextField, + URLField, ) from django.db.models.indexes import Index from django.http import Http404 @@ -1431,7 +1432,7 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi self.user.attributes.get("is_business", False) if self.user else False ) - def save(self, **kwargs: dict) -> Self: + def save(self, **kwargs) -> Self: pending_orders = 0 if self.user: pending_orders = self.user.orders.filter(status="PENDING").count() @@ -1929,6 +1930,33 @@ class OrderProduct(ExportModelOperationsMixin("order_product"), NiceModel): # t return None +class CustomerRelationshipManagementProvider(ExportModelOperationsMixin("crm_provider"), NiceModel): + name = CharField(max_length=128, unique=True, verbose_name=_("name")) + integration_url = URLField(blank=True, null=True, help_text=_("URL of the integration")) + authentication = JSONField(blank=True, null=True, help_text=_("authentication credentials")) + attributes = JSONField(blank=True, null=True, help_name=_("attributes")) + + def __str__(self) -> str: + return self.crm_lead_id + + class Meta: + verbose_name = _("order CRM link") + verbose_name_plural = _("orders CRM links") + + +class OrderCrmLink(ExportModelOperationsMixin("order_crm_link"), NiceModel): + order_uuid = ForeignKey(to=Order, on_delete=PROTECT, related_name="crm_links") + crm = ForeignKey(to=CustomerRelationshipManagementProvider, on_delete=PROTECT, related_name="order_links") + crm_lead_id = CharField(max_length=30, unique=True, db_index=True) + + def __str__(self) -> str: + return self.crm_lead_id + + class Meta: + verbose_name = _("order CRM link") + verbose_name_plural = _("orders CRM links") + + class DigitalAssetDownload(ExportModelOperationsMixin("attribute_group"), NiceModel): # type: ignore [misc, django-manager-missing] """ Represents the downloading functionality for digital assets associated