Merge branch 'feature/crms-integration'

This commit is contained in:
Egor Pavlovich Gorbunov 2025-09-07 00:11:06 +03:00
commit 9f9d58e471
7 changed files with 202 additions and 7 deletions

5
core/crm/__init__.py Normal file
View file

@ -0,0 +1,5 @@
from core.models import CustomerRelationshipManagementProvider
def any_crm_integrations():
return CustomerRelationshipManagementProvider.objects.exists()

0
core/crm/amo/__init__.py Normal file
View file

118
core/crm/amo/gateway.py Normal file
View file

@ -0,0 +1,118 @@
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 AmoCRM:
def __init__(self):
try:
self.instance = CustomerRelationshipManagementProvider.objects.get(name="amo")
except CustomerRelationshipManagementProvider.DoesNotExist as dne:
logger.warning("AMO CRM provider not found")
raise CRMException("AMO CRM provider not found") from dne
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")
self.fns_api_key = self.instance.attributes.get("fns_api_key")
if not all(
[
self.base,
self.client_id,
self.client_secret,
self.redirect_uri,
self.pipeline_id,
self.stage_map,
self.fns_api_key,
]
):
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():
try:
link: Optional[OrderCrmLink] = OrderCrmLink.objects.get(order=order)
except OrderCrmLink.MultipleObjectsReturned:
link = OrderCrmLink.objects.filter(order=order).first()
except OrderCrmLink.DoesNotExist:
link = None
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"])

2
core/crm/exceptions.py Normal file
View file

@ -0,0 +1,2 @@
class CRMException(Exception):
pass

View file

@ -1,6 +1,8 @@
import datetime import datetime
import json import json
import logging import logging
import traceback
from contextlib import suppress
from typing import Any, Optional, Self from typing import Any, Optional, Self
from constance import config from constance import config
@ -30,6 +32,7 @@ from django.db.models import (
PositiveIntegerField, PositiveIntegerField,
QuerySet, QuerySet,
TextField, TextField,
URLField,
) )
from django.db.models.indexes import Index from django.db.models.indexes import Index
from django.http import Http404 from django.http import Http404
@ -56,6 +59,7 @@ from core.utils.db import TweakedAutoSlugField, unicode_slugify_function
from core.utils.lists import FAILED_STATUSES from core.utils.lists import FAILED_STATUSES
from core.validators import validate_category_image_dimensions from core.validators import validate_category_image_dimensions
from evibes.settings import CURRENCY_CODE from evibes.settings import CURRENCY_CODE
from evibes.utils.misc import create_object
from payments.models import Transaction from payments.models import Transaction
logger = logging.getLogger("django") logger = logging.getLogger("django")
@ -1427,11 +1431,15 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
@property @property
def is_business(self) -> bool: def is_business(self) -> bool:
return (self.attributes.get("is_business", False) if self.attributes else False) or ( with suppress(Exception):
self.user.attributes.get("is_business", False) if self.user else False return (self.attributes.get("is_business", False) if self.attributes else False) or (
) (self.user.attributes.get("is_business", False) and self.user.attributes.get("business_identificator"))
if self.user
else False
)
return False
def save(self, **kwargs: dict) -> Self: def save(self, **kwargs) -> Self:
pending_orders = 0 pending_orders = 0
if self.user: if self.user:
pending_orders = self.user.orders.filter(status="PENDING").count() pending_orders = self.user.orders.filter(status="PENDING").count()
@ -1775,6 +1783,29 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
) )
return self return self
def trigger_crm(self):
crm_links = OrderCrmLink.objects.filter(order=self)
if crm_links.exists():
crm_link = crm_links.first()
crm_integration = create_object(crm_link.crm.integration_location, crm_link.crm.name)
try:
crm_integration.upsert_order(self)
return True
except Exception as e:
logger.error(f"failed to trigger crm integration {crm_link.crm.name} for order {self.uuid}: {e}")
logger.error(traceback.format_exc())
return False
else:
crm = CustomerRelationshipManagementProvider.objects.get(default=True)
crm_integration = create_object(crm.integration_location, crm.name)
try:
crm_integration.upsert_order(self)
return True
except Exception as e:
logger.error(f"failed to trigger crm integration {crm.name} for order {self.uuid}: {e}")
logger.error(traceback.format_exc())
return False
class OrderProduct(ExportModelOperationsMixin("order_product"), NiceModel): # type: ignore [misc, django-manager-missing] class OrderProduct(ExportModelOperationsMixin("order_product"), NiceModel): # type: ignore [misc, django-manager-missing]
""" """
@ -1929,6 +1960,40 @@ class OrderProduct(ExportModelOperationsMixin("order_product"), NiceModel): # t
return None 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"))
integration_location = CharField(max_length=128, blank=True, null=True)
default = BooleanField(default=False)
def __str__(self) -> str:
return self.crm_lead_id
def save(self, **kwargs):
if self.objects.filter(default=True).exists():
raise ValueError(_("you can only have one default CRM provider"))
super().save(**kwargs)
class Meta:
verbose_name = _("order CRM link")
verbose_name_plural = _("orders CRM links")
class OrderCrmLink(ExportModelOperationsMixin("order_crm_link"), NiceModel):
order = 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] class DigitalAssetDownload(ExportModelOperationsMixin("attribute_group"), NiceModel): # type: ignore [misc, django-manager-missing]
""" """
Represents the downloading functionality for digital assets associated Represents the downloading functionality for digital assets associated

View file

@ -10,6 +10,7 @@ from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
from core.crm import any_crm_integrations
from core.models import Category, Order, Product, PromoCode, Wishlist, DigitalAssetDownload from core.models import Category, Order, Product, PromoCode, Wishlist, DigitalAssetDownload
from core.utils import ( from core.utils import (
generate_human_readable_id, generate_human_readable_id,
@ -67,6 +68,9 @@ def process_order_changes(instance, created, **_kwargs):
if type(instance.attributes) is not dict: if type(instance.attributes) is not dict:
instance.attributes = {} instance.attributes = {}
if any_crm_integrations():
instance.trigger_crm()
if not created: if not created:
if instance.status != "PENDING" and instance.user: if instance.status != "PENDING" and instance.user:
pending_orders = Order.objects.filter(user=instance.user, status="PENDING") pending_orders = Order.objects.filter(user=instance.user, status="PENDING")

View file

@ -109,9 +109,10 @@ class UserSerializer(ModelSerializer):
raise ValidationError(_("passwords do not match")) raise ValidationError(_("passwords do not match"))
if "phone_number" in attrs: if "phone_number" in attrs:
validate_phone_number(attrs["phone_number"]) validate_phone_number(attrs["phone_number"])
if User.objects.filter(phone_number=attrs["phone_number"]).exclude(uuid=self.instance.uuid).exists(): if self.instance:
phone_number = attrs["phone_number"] if User.objects.filter(phone_number=attrs["phone_number"]).exclude(uuid=self.instance.uuid).exists():
raise ValidationError(_(f"malformed phone number: {phone_number}")) phone_number = attrs["phone_number"]
raise ValidationError(_(f"malformed phone number: {phone_number}"))
if "email" in attrs: if "email" in attrs:
validate_email(attrs["email"]) validate_email(attrs["email"])
if User.objects.filter(email=attrs["email"]).exclude(uuid=self.instance.uuid).exists(): if User.objects.filter(email=attrs["email"]).exclude(uuid=self.instance.uuid).exists():