Features: 1) Integrated CRM trigger functionality for Order model, allowing CRM updates during state changes; 2) Added handling for multiple or missing OrderCrmLink entries within upsert_order; 3) Introduced fns_api_key handling in CRM configurations.

Fixes: 1) Resolved malformed phone number issue in serializer when `instance` is unavailable; 2) Corrected relation field naming for `OrderCrmLink` (`order_uuid` to `order`); 3) Ensured only one default CRM provider can exist.

Extra: 1) Refactored `is_business` logic with error handling; 2) Improved logging for CRM integration errors; 3) Added `integration_location` and `default` fields to CRM configuration model.
This commit is contained in:
Egor Pavlovich Gorbunov 2025-09-07 00:09:29 +03:00
parent 752f96fcdd
commit d203095c65
4 changed files with 61 additions and 11 deletions

View file

@ -12,13 +12,13 @@ from core.models import CustomerRelationshipManagementProvider, Order, OrderCrmL
logger = logging.getLogger("django") logger = logging.getLogger("django")
class AmoOrderGateway: class AmoCRM:
def __init__(self): def __init__(self):
try: try:
self.instance = CustomerRelationshipManagementProvider.objects.get(name="amo") self.instance = CustomerRelationshipManagementProvider.objects.get(name="amo")
except CustomerRelationshipManagementProvider.DoesNotExist: except CustomerRelationshipManagementProvider.DoesNotExist as dne:
logger.warning("AMO CRM provider not found") logger.warning("AMO CRM provider not found")
raise CRMException("AMO CRM provider not found") raise CRMException("AMO CRM provider not found") from dne
self.base = f"https://{self.instance.integration_url}" self.base = f"https://{self.instance.integration_url}"
@ -31,6 +31,8 @@ class AmoOrderGateway:
self.stage_map = self.instance.attributes.get("stage_map") self.stage_map = self.instance.attributes.get("stage_map")
self.responsible_user_id = self.instance.attributes.get("responsible_user_id") 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( if not all(
[ [
self.base, self.base,
@ -39,6 +41,7 @@ class AmoOrderGateway:
self.redirect_uri, self.redirect_uri,
self.pipeline_id, self.pipeline_id,
self.stage_map, self.stage_map,
self.fns_api_key,
] ]
): ):
raise CRMException("AMO CRM provider not configured") raise CRMException("AMO CRM provider not configured")
@ -79,7 +82,12 @@ class AmoOrderGateway:
def upsert_order(self, order: Order) -> str: def upsert_order(self, order: Order) -> str:
with transaction.atomic(): with transaction.atomic():
link: Optional[OrderCrmLink] = OrderCrmLink.objects.filter(order=order).first() 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: if link:
lead_id = link.crm_lead_id lead_id = link.crm_lead_id
payload = self._build_lead_payload(order) payload = self._build_lead_payload(order)

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
@ -57,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")
@ -1428,9 +1431,13 @@ 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) -> Self: def save(self, **kwargs) -> Self:
pending_orders = 0 pending_orders = 0
@ -1776,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]
""" """
@ -1935,17 +1965,24 @@ class CustomerRelationshipManagementProvider(ExportModelOperationsMixin("crm_pro
integration_url = URLField(blank=True, null=True, help_text=_("URL of the integration")) integration_url = URLField(blank=True, null=True, help_text=_("URL of the integration"))
authentication = JSONField(blank=True, null=True, help_text=_("authentication credentials")) authentication = JSONField(blank=True, null=True, help_text=_("authentication credentials"))
attributes = JSONField(blank=True, null=True, help_name=_("attributes")) 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: def __str__(self) -> str:
return self.crm_lead_id 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: class Meta:
verbose_name = _("order CRM link") verbose_name = _("order CRM link")
verbose_name_plural = _("orders CRM links") verbose_name_plural = _("orders CRM links")
class OrderCrmLink(ExportModelOperationsMixin("order_crm_link"), NiceModel): class OrderCrmLink(ExportModelOperationsMixin("order_crm_link"), NiceModel):
order_uuid = ForeignKey(to=Order, on_delete=PROTECT, related_name="crm_links") order = ForeignKey(to=Order, on_delete=PROTECT, related_name="crm_links")
crm = ForeignKey(to=CustomerRelationshipManagementProvider, on_delete=PROTECT, related_name="order_links") crm = ForeignKey(to=CustomerRelationshipManagementProvider, on_delete=PROTECT, related_name="order_links")
crm_lead_id = CharField(max_length=30, unique=True, db_index=True) crm_lead_id = CharField(max_length=30, unique=True, db_index=True)

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

@ -104,9 +104,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():