Merge branch 'feature/crms-integration'
This commit is contained in:
commit
9f9d58e471
7 changed files with 202 additions and 7 deletions
5
core/crm/__init__.py
Normal file
5
core/crm/__init__.py
Normal 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
0
core/crm/amo/__init__.py
Normal file
118
core/crm/amo/gateway.py
Normal file
118
core/crm/amo/gateway.py
Normal 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
2
core/crm/exceptions.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
class CRMException(Exception):
|
||||
pass
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
from contextlib import suppress
|
||||
from typing import Any, Optional, Self
|
||||
|
||||
from constance import config
|
||||
|
|
@ -30,6 +32,7 @@ from django.db.models import (
|
|||
PositiveIntegerField,
|
||||
QuerySet,
|
||||
TextField,
|
||||
URLField,
|
||||
)
|
||||
from django.db.models.indexes import Index
|
||||
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.validators import validate_category_image_dimensions
|
||||
from evibes.settings import CURRENCY_CODE
|
||||
from evibes.utils.misc import create_object
|
||||
from payments.models import Transaction
|
||||
|
||||
logger = logging.getLogger("django")
|
||||
|
|
@ -1427,11 +1431,15 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
|
|||
|
||||
@property
|
||||
def is_business(self) -> bool:
|
||||
return (self.attributes.get("is_business", False) if self.attributes else False) or (
|
||||
self.user.attributes.get("is_business", False) if self.user else False
|
||||
)
|
||||
with suppress(Exception):
|
||||
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
|
||||
if self.user:
|
||||
pending_orders = self.user.orders.filter(status="PENDING").count()
|
||||
|
|
@ -1775,6 +1783,29 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
|
|||
)
|
||||
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]
|
||||
"""
|
||||
|
|
@ -1929,6 +1960,40 @@ 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"))
|
||||
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]
|
||||
"""
|
||||
Represents the downloading functionality for digital assets associated
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from django.utils.timezone import now
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
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.utils import (
|
||||
generate_human_readable_id,
|
||||
|
|
@ -67,6 +68,9 @@ def process_order_changes(instance, created, **_kwargs):
|
|||
if type(instance.attributes) is not dict:
|
||||
instance.attributes = {}
|
||||
|
||||
if any_crm_integrations():
|
||||
instance.trigger_crm()
|
||||
|
||||
if not created:
|
||||
if instance.status != "PENDING" and instance.user:
|
||||
pending_orders = Order.objects.filter(user=instance.user, status="PENDING")
|
||||
|
|
|
|||
|
|
@ -109,9 +109,10 @@ class UserSerializer(ModelSerializer):
|
|||
raise ValidationError(_("passwords do not match"))
|
||||
if "phone_number" in attrs:
|
||||
validate_phone_number(attrs["phone_number"])
|
||||
if User.objects.filter(phone_number=attrs["phone_number"]).exclude(uuid=self.instance.uuid).exists():
|
||||
phone_number = attrs["phone_number"]
|
||||
raise ValidationError(_(f"malformed phone number: {phone_number}"))
|
||||
if self.instance:
|
||||
if User.objects.filter(phone_number=attrs["phone_number"]).exclude(uuid=self.instance.uuid).exists():
|
||||
phone_number = attrs["phone_number"]
|
||||
raise ValidationError(_(f"malformed phone number: {phone_number}"))
|
||||
if "email" in attrs:
|
||||
validate_email(attrs["email"])
|
||||
if User.objects.filter(email=attrs["email"]).exclude(uuid=self.instance.uuid).exists():
|
||||
|
|
|
|||
Loading…
Reference in a new issue