Merge branch 'main' into storefront-nuxt

This commit is contained in:
Egor Pavlovich Gorbunov 2025-09-13 13:02:00 +03:00
commit 282c0ae541
219 changed files with 11411 additions and 9779 deletions

View file

@ -68,7 +68,7 @@ pip-delete-this-directory.txt
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
storefront/Dockerfile storefront/Dockerfile
docker-compose.yml docker-compose.yml
db_backups/ backups/
services_data/ services_data/
static/ static/
media/ media/

3
.gitignore vendored
View file

@ -35,7 +35,7 @@ db.sqlite3-journal
# Django backups and metadata # Django backups and metadata
instance/ instance/
db_backups/ backups/
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
# Logs and reports # Logs and reports
@ -50,6 +50,7 @@ coverage.*
*.cover *.cover
*.py,cover *.py,cover
nosetests.xml nosetests.xml
tmp
# Coverage / test reports # Coverage / test reports
htmlcov/ htmlcov/

View file

@ -1,17 +1,18 @@
from django_elasticsearch_dsl import Document, fields from django_elasticsearch_dsl import fields
from django_elasticsearch_dsl.registries import registry from django_elasticsearch_dsl.registries import registry
from blog.models import Post from blog.models import Post
from core.elasticsearch import COMMON_ANALYSIS, ActiveOnlyMixin from core.elasticsearch import COMMON_ANALYSIS, ActiveOnlyMixin, add_multilang_fields
from core.elasticsearch.documents import BaseDocument
class PostDocument(ActiveOnlyMixin, Document): class PostDocument(ActiveOnlyMixin, BaseDocument):
title = fields.TextField( title = fields.TextField(
attr="title", attr="title",
analyzer="standard", analyzer="standard",
fields={ fields={
"raw": fields.KeywordField(ignore_above=256), "raw": fields.KeywordField(ignore_above=256),
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="query_lc"), "ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"),
"phonetic": fields.TextField(analyzer="name_phonetic"), "phonetic": fields.TextField(analyzer="name_phonetic"),
}, },
) )
@ -33,4 +34,5 @@ class PostDocument(ActiveOnlyMixin, Document):
return getattr(instance, "title", "") or "" return getattr(instance, "title", "") or ""
add_multilang_fields(PostDocument)
registry.register_document(PostDocument) registry.register_document(PostDocument)

View file

@ -1,9 +1,9 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: EVIBES 2.9.2\n" "Project-Id-Version: EVIBES 2.9.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-03 18:33+0300\n" "POT-Creation-Date: 2025-09-01 20:37+0300\n"
"PO-Revision-Date: 2025-06-16 08:59+0100\n" "PO-Revision-Date: 2025-06-16 08:59+0100\n"
"Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n" "Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n"
"Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n" "Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n"

View file

@ -1,9 +1,9 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: EVIBES 2.9.2\n" "Project-Id-Version: EVIBES 2.9.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-03 18:33+0300\n" "POT-Creation-Date: 2025-09-01 20:37+0300\n"
"PO-Revision-Date: 2025-06-16 08:59+0100\n" "PO-Revision-Date: 2025-06-16 08:59+0100\n"
"Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n" "Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n"
"Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n" "Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n"

View file

@ -1,9 +1,9 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: EVIBES 2.9.2\n" "Project-Id-Version: EVIBES 2.9.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-03 18:33+0300\n" "POT-Creation-Date: 2025-09-01 20:37+0300\n"
"PO-Revision-Date: 2025-06-16 08:59+0100\n" "PO-Revision-Date: 2025-06-16 08:59+0100\n"
"Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n" "Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n"
"Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n" "Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n"

View file

@ -1,9 +1,9 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: EVIBES 2.9.2\n" "Project-Id-Version: EVIBES 2.9.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-03 18:33+0300\n" "POT-Creation-Date: 2025-09-01 20:37+0300\n"
"PO-Revision-Date: 2025-06-16 08:59+0100\n" "PO-Revision-Date: 2025-06-16 08:59+0100\n"
"Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n" "Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n"
"Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n" "Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n"

View file

@ -5,9 +5,9 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: EVIBES 2.9.2\n" "Project-Id-Version: EVIBES 2.9.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-03 18:33+0300\n" "POT-Creation-Date: 2025-09-01 20:37+0300\n"
"PO-Revision-Date: 2025-06-16 08:59+0100\n" "PO-Revision-Date: 2025-06-16 08:59+0100\n"
"Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n" "Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n"
"Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n" "Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n"

View file

@ -1,9 +1,9 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: EVIBES 2.9.2\n" "Project-Id-Version: EVIBES 2.9.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-03 18:33+0300\n" "POT-Creation-Date: 2025-09-01 20:37+0300\n"
"PO-Revision-Date: 2025-06-16 08:59+0100\n" "PO-Revision-Date: 2025-06-16 08:59+0100\n"
"Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n" "Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n"
"Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n" "Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n"

View file

@ -1,9 +1,9 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: EVIBES 2.9.2\n" "Project-Id-Version: EVIBES 2.9.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-03 18:33+0300\n" "POT-Creation-Date: 2025-09-01 20:37+0300\n"
"PO-Revision-Date: 2025-06-16 08:59+0100\n" "PO-Revision-Date: 2025-06-16 08:59+0100\n"
"Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n" "Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n"
"Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n" "Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n"

View file

@ -1,9 +1,9 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: EVIBES 2.9.2\n" "Project-Id-Version: EVIBES 2.9.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-03 18:33+0300\n" "POT-Creation-Date: 2025-09-01 20:37+0300\n"
"PO-Revision-Date: 2025-06-16 08:59+0100\n" "PO-Revision-Date: 2025-06-16 08:59+0100\n"
"Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n" "Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n"
"Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n" "Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n"

View file

@ -5,9 +5,9 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: EVIBES 2.9.2\n" "Project-Id-Version: EVIBES 2.9.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-03 18:33+0300\n" "POT-Creation-Date: 2025-09-01 20:37+0300\n"
"PO-Revision-Date: 2025-06-16 08:59+0100\n" "PO-Revision-Date: 2025-06-16 08:59+0100\n"
"Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n" "Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n"
"Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n" "Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n"

View file

@ -1,9 +1,9 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: EVIBES 2.9.2\n" "Project-Id-Version: EVIBES 2.9.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-03 18:33+0300\n" "POT-Creation-Date: 2025-09-01 20:37+0300\n"
"PO-Revision-Date: 2025-06-16 08:59+0100\n" "PO-Revision-Date: 2025-06-16 08:59+0100\n"
"Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n" "Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n"
"Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n" "Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n"

View file

@ -1,9 +1,9 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: EVIBES 2.9.2\n" "Project-Id-Version: EVIBES 2.9.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-03 18:33+0300\n" "POT-Creation-Date: 2025-09-01 20:37+0300\n"
"PO-Revision-Date: 2025-06-16 08:59+0100\n" "PO-Revision-Date: 2025-06-16 08:59+0100\n"
"Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n" "Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n"
"Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n" "Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n"

View file

@ -5,9 +5,9 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: EVIBES 2.9.2\n" "Project-Id-Version: EVIBES 2.9.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-03 18:33+0300\n" "POT-Creation-Date: 2025-09-01 20:37+0300\n"
"PO-Revision-Date: 2025-06-16 08:59+0100\n" "PO-Revision-Date: 2025-06-16 08:59+0100\n"
"Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n" "Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n"
"Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n" "Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n"

View file

@ -1,9 +1,9 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: EVIBES 2.9.2\n" "Project-Id-Version: EVIBES 2.9.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-03 18:33+0300\n" "POT-Creation-Date: 2025-09-01 20:37+0300\n"
"PO-Revision-Date: 2025-06-16 08:59+0100\n" "PO-Revision-Date: 2025-06-16 08:59+0100\n"
"Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n" "Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n"
"Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n" "Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n"

View file

@ -1,9 +1,9 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: EVIBES 2.9.2\n" "Project-Id-Version: EVIBES 2.9.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-03 18:33+0300\n" "POT-Creation-Date: 2025-09-01 20:37+0300\n"
"PO-Revision-Date: 2025-06-16 08:59+0100\n" "PO-Revision-Date: 2025-06-16 08:59+0100\n"
"Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n" "Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n"
"Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n" "Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n"

View file

@ -1,9 +1,9 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: EVIBES 2.9.2\n" "Project-Id-Version: EVIBES 2.9.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-03 18:33+0300\n" "POT-Creation-Date: 2025-09-01 20:37+0300\n"
"PO-Revision-Date: 2025-06-16 08:59+0100\n" "PO-Revision-Date: 2025-06-16 08:59+0100\n"
"Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n" "Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n"
"Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n" "Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n"

View file

@ -1,9 +1,9 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: EVIBES 2.9.2\n" "Project-Id-Version: EVIBES 2.9.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-03 18:33+0300\n" "POT-Creation-Date: 2025-09-01 20:37+0300\n"
"PO-Revision-Date: 2025-06-16 08:59+0100\n" "PO-Revision-Date: 2025-06-16 08:59+0100\n"
"Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n" "Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n"
"Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n" "Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n"

View file

@ -1,9 +1,9 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: EVIBES 2.9.2\n" "Project-Id-Version: EVIBES 2.9.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-03 18:33+0300\n" "POT-Creation-Date: 2025-09-01 20:37+0300\n"
"PO-Revision-Date: 2025-06-16 08:59+0100\n" "PO-Revision-Date: 2025-06-16 08:59+0100\n"
"Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n" "Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n"
"Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n" "Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n"

View file

@ -1,9 +1,9 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: EVIBES 2.9.2\n" "Project-Id-Version: EVIBES 2.9.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-03 18:33+0300\n" "POT-Creation-Date: 2025-09-01 20:37+0300\n"
"PO-Revision-Date: 2025-06-16 08:59+0100\n" "PO-Revision-Date: 2025-06-16 08:59+0100\n"
"Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n" "Last-Translator: EGOR GORBUNOV <CONTACT@FUREUNOIR.COM>\n"
"Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n" "Language-Team: LANGUAGE <CONTACT@FUREUNOIR.COM>\n"

View file

@ -33,6 +33,8 @@ from core.models import (
Stock, Stock,
Vendor, Vendor,
Wishlist, Wishlist,
CustomerRelationshipManagementProvider,
OrderCrmLink,
) )
from evibes.settings import CONSTANCE_CONFIG from evibes.settings import CONSTANCE_CONFIG
@ -67,11 +69,22 @@ class FieldsetsMixin:
fieldsets.append((_("relations"), {"fields": self.relation_fields})) fieldsets.append((_("relations"), {"fields": self.relation_fields}))
opts = self.model._meta opts = self.model._meta
meta_fields = []
if any(f.name == "uuid" for f in opts.fields): if any(f.name == "uuid" for f in opts.fields):
if any(f.name == "slug" for f in opts.fields): meta_fields.append("uuid")
fieldsets.append((_("metadata"), {"fields": ["uuid", "slug"]}))
else: if any(f.name == "slug" for f in opts.fields):
fieldsets.append((_("metadata"), {"fields": ["uuid"]})) meta_fields.append("slug")
if any(f.name == "sku" for f in opts.fields):
meta_fields.append("sku")
if any(f.name == "human_readable_id" for f in opts.fields):
meta_fields.append("sku")
if meta_fields:
fieldsets.append((_("metadata"), {"fields": meta_fields}))
ts = [] ts = []
for name in ("created", "modified"): for name in ("created", "modified"):
@ -243,8 +256,8 @@ class ProductAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type:
# noinspection PyClassVar # noinspection PyClassVar
model = Product # type: ignore [misc] model = Product # type: ignore [misc]
list_display = ( list_display = (
"sku",
"name", "name",
"partnumber",
"is_active", "is_active",
"category", "category",
"brand", "brand",
@ -269,6 +282,7 @@ class ProductAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type:
"category__slug", "category__slug",
"uuid", "uuid",
"slug", "slug",
"sku",
) )
readonly_fields = ("slug", "uuid", "modified", "created") readonly_fields = ("slug", "uuid", "modified", "created")
autocomplete_fields = ("category", "brand", "tags") autocomplete_fields = ("category", "brand", "tags")
@ -457,7 +471,12 @@ class ProductImageAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin): #
readonly_fields = ("uuid", "modified", "created") readonly_fields = ("uuid", "modified", "created")
autocomplete_fields = ("product",) autocomplete_fields = ("product",)
general_fields = ["is_active", "alt", "priority", "image"] general_fields = [
"is_active",
"alt",
"priority",
"image",
]
relation_fields = ["product"] relation_fields = ["product"]
@ -488,7 +507,53 @@ class AddressAdmin(FieldsetsMixin, GISModelAdmin):
"country", "country",
"raw_data", "raw_data",
] ]
relation_fields = ["user", "api_response"] relation_fields = [
"user",
"api_response",
]
@register(CustomerRelationshipManagementProvider)
class CustomerRelationshipManagementProviderAdmin(FieldsetsMixin, ModelAdmin):
# noinspection PyClassVar
model = CustomerRelationshipManagementProvider # type: ignore [misc]
list_display = ("name", "default")
search_fields = ("name",)
readonly_fields = ("uuid", "modified", "created")
general_fields = [
"is_active",
"name",
"default",
"integration_url",
"integration_location",
"attributes",
"authentication",
]
relation_fields = []
@register(OrderCrmLink)
class OrderCrmLinkAdmin(FieldsetsMixin, ModelAdmin):
# noinspection PyClassVar
model = OrderCrmLink # type: ignore [misc]
list_display = ("crm_lead_id",)
search_fields = ("crm_lead_id",)
readonly_fields = (
"uuid",
"modified",
"created",
"crm_lead_id",
)
general_fields = [
"is_active",
"crm_lead_id",
]
relation_fields = [
"order",
"crm",
]
# Constance configuration # Constance configuration

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

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

@ -0,0 +1,179 @@
import logging
import traceback
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:
STATUS_MAP: dict[str, str] = {}
def __init__(self):
try:
self.instance = CustomerRelationshipManagementProvider.objects.get(name="AmoCRM")
except CustomerRelationshipManagementProvider.DoesNotExist as dne:
logger.warning("AMO CRM provider not found")
raise CRMException("AMO CRM provider not found") from dne
except CustomerRelationshipManagementProvider.MultipleObjectsReturned as mre:
logger.warning("Multiple AMO CRM providers found")
raise CRMException("Multiple AMO CRM providers found") from mre
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.authorization_code = self.instance.authentication.get("authorization_code")
self.refresh_token = cache.get("amo_refresh_token")
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.authorization_code,
self.fns_api_key,
]
):
raise CRMException("AMO CRM provider not configured")
def _token(self) -> str:
payload = {
"client_id": self.client_id,
"client_secret": self.client_secret,
}
if self.refresh_token:
payload["grant_type"] = "refresh_token"
payload["refresh_token"] = self.refresh_token
else:
payload["grant_type"] = "authorization_code"
payload["code"] = self.authorization_code
r = requests.post(f"{self.base}/oauth2/access_token", json=payload, timeout=15)
r.raise_for_status()
data = r.json()
self.access_token = data["access_token"]
cache.set("amo_refresh_token", data["refresh_token"], 604800)
self.refresh_token = data["refresh_token"]
return self.access_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.human_readable_id}"
price = int(round(order.total_price))
payload: dict = {"name": name, "price": price}
contact_id = self._create_contact(order)
if contact_id:
payload["_embedded"] = {"contacts": [{"id": contact_id}]}
if self.responsible_user_id:
payload["responsible_user_id"] = self.responsible_user_id
return payload
def _get_customer_name(self, order: Order) -> str:
if not order.attributes.get("business_identificator"):
return order.user.get_full_name() or (
f"{order.attributes.get('customer_name')} | "
f"{order.attributes.get('customer_phone_number') or order.attributes.get('customer_email')}"
)
try:
business_identificator = order.attributes.get("business_identificator")
r = requests.get(
f"https://api-fns.ru/api/egr?req={business_identificator}&key={self.fns_api_key}", timeout=15
)
body = r.json()
except requests.exceptions.RequestException as rex:
logger.error(f"Unable to get company info with FNS: {rex}")
logger.error(traceback.format_exc())
return ""
ul = body.get("ЮЛ")
ip = body.get("ИП")
if ul and not ip:
return f"{ul.get('НаимСокрЮЛ')} | {business_identificator}"
if ip and not ul:
return f"ИП {ip.get('ФИОПолн')} | {business_identificator}"
return ""
def _create_contact(self, order: Order) -> int | None:
try:
customer_name = self._get_customer_name(order)
if customer_name:
r = requests.get(
f"{self.base}/api/v4/contacts",
headers=self._headers().update({"filter[name]": customer_name, "limit": 1}),
timeout=15,
)
if r.status_code == 200:
body = r.json()
return body.get("_embedded", {}).get("contacts", [{}])[0].get("id", None)
create_contact_payload = {"name": customer_name}
if self.responsible_user_id:
create_contact_payload["responsible_user_id"] = self.responsible_user_id
if order.user:
create_contact_payload["first_name"] = order.user.first_name or ""
create_contact_payload["last_name"] = order.user.last_name or ""
r = requests.post(
f"{self.base}/api/v4/contacts", json={"name": customer_name}, headers=self._headers(), timeout=15
)
if r.status_code == 200:
body = r.json()
return body.get("_embedded", {}).get("contacts", [{}])[0].get("id", None)
else:
return None
except requests.exceptions.RequestException as rex:
logger.error(f"Unable to create a company in AmoCRM: {rex}")
logger.error(traceback.format_exc())
raise CRMException("Unable to create a company in AmoCRM") from rex
def process_order_changes(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=order, crm_lead_id=lead_id, crm=self.instance)
return lead_id
def update_order_status(self, crm_lead_id: str, new_status: str) -> None:
link = OrderCrmLink.objects.get(crm_lead_id=crm_lead_id)
if link.order.status == new_status:
return
link.order.status = self.STATUS_MAP.get(new_status)
link.order.save(update_fields=["status"])

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

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

View file

@ -91,7 +91,7 @@ BUY_AS_BUSINESS_SCHEMA = {
summary=_("purchase an order as a business"), summary=_("purchase an order as a business"),
request=BuyAsBusinessOrderSerializer, request=BuyAsBusinessOrderSerializer,
responses={ responses={
200: TransactionProcessSerializer, 201: TransactionProcessSerializer,
400: error, 400: error,
}, },
description=( description=(

View file

@ -152,7 +152,7 @@ CATEGORY_SCHEMA = {
responses={status.HTTP_200_OK: CategoryDetailSerializer(), **BASE_ERRORS}, responses={status.HTTP_200_OK: CategoryDetailSerializer(), **BASE_ERRORS},
), ),
"seo": extend_schema( "seo": extend_schema(
summary=_("SEO Meta Snapshot"), summary=_("SEO Meta snapshot"),
description=_("returns a snapshot of the category's SEO meta data"), description=_("returns a snapshot of the category's SEO meta data"),
parameters=[ parameters=[
OpenApiParameter( OpenApiParameter(
@ -550,7 +550,7 @@ PRODUCT_SCHEMA = {
}, },
), ),
"seo": extend_schema( "seo": extend_schema(
summary=_("SEO Meta Snapshot"), summary=_("SEO Meta snapshot"),
description=_("returns a snapshot of the product's SEO meta data"), description=_("returns a snapshot of the product's SEO meta data"),
parameters=[ parameters=[
OpenApiParameter( OpenApiParameter(

View file

@ -1,6 +1,7 @@
import re
from django.conf import settings from django.conf import settings
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_elasticsearch_dsl import fields from django_elasticsearch_dsl import fields
@ -12,154 +13,237 @@ from rest_framework.request import Request
from core.models import Brand, Category, Product from core.models import Brand, Category, Product
SMART_FIELDS = [ SMART_FIELDS = [
"name^6", "name^8",
"name.ngram^5", "name.ngram^8",
"name.phonetic", "name.phonetic^6",
"title^4", "title^5",
"title.ngram^3", "title.ngram^4",
"title.phonetic", "title.phonetic^2",
"description^2", "description^2",
"description.ngram", "description.ngram",
"description.phonetic", "description.phonetic",
"brand__name^3", "brand_name^5",
"brand__name.ngram", "brand_name.ngram^3",
"brand__name.auto", "brand_name.auto^4",
"category__name^2", "category_name^3",
"category__name.ngram", "category_name.ngram^2",
"category__name.auto", "category_name.auto^2",
"sku^9",
"sku.ngram^6",
"sku.auto^8",
"partnumber^10",
"partnumber.ngram^7",
"partnumber.auto^9",
] ]
functions = [ functions = [
# product-level boosts when searching for products
{ {
"filter": Q("term", **{"_index": "products"}), "filter": Q("term", **{"_index": "products"}),
"field_value_factor": { "field_value_factor": {
"field": "brand_priority", "field": "brand_priority",
"modifier": "log1p", "modifier": "log1p",
"factor": 1.5, "factor": 0.2,
"missing": 0, "missing": 0,
}, },
"weight": 0.6,
}, },
{ {
"filter": Q("term", **{"_index": "products"}), "filter": Q("term", **{"_index": "products"}),
"field_value_factor": { "field_value_factor": {
"field": "rating", "field": "rating",
"modifier": "log1p", "modifier": "log1p",
"factor": 2.0, "factor": 0.15,
"missing": 0, "missing": 0,
}, },
"weight": 0.5,
}, },
{ {
"filter": Q("term", **{"_index": "products"}), "filter": Q("term", **{"_index": "products"}),
"field_value_factor": { "field_value_factor": {
"field": "total_orders", "field": "total_orders",
"modifier": "log1p", "modifier": "log1p",
"factor": 3.0, "factor": 0.25,
"missing": 0, "missing": 0,
}, },
"weight": 0.7,
}, },
{ {
"filter": Q("term", **{"_index": "products"}), "filter": Q("term", **{"_index": "products"}),
"field_value_factor": { "field_value_factor": {
"field": "category_priority", "field": "category_priority",
"modifier": "log1p", "modifier": "log1p",
"factor": 1.2, "factor": 0.2,
"missing": 0, "missing": 0,
}, },
"weight": 0.6,
}, },
# category-level boost when searching for categories
{ {
"filter": Q("term", **{"_index": "categories"}), "filter": Q("term", **{"_index": "categories"}),
"field_value_factor": { "field_value_factor": {
"field": "priority", "field": "priority",
"modifier": "log1p", "modifier": "log1p",
"factor": 2.0, "factor": 0.25,
"missing": 0, "missing": 0,
}, },
"weight": 0.8,
}, },
# brand-level boost when searching for brands
{ {
"filter": Q("term", **{"_index": "brands"}), "filter": Q("term", **{"_index": "brands"}),
"field_value_factor": { "field_value_factor": {
"field": "priority", "field": "priority",
"modifier": "log1p", "modifier": "log1p",
"factor": 2.0, "factor": 0.25,
"missing": 0, "missing": 0,
}, },
"weight": 0.8,
}, },
] ]
def process_query(query: str = "", request: Request | None = None) -> dict[str, list[dict]] | None: def process_query(query: str = "", request: Request | None = None) -> dict[str, list[dict]] | None:
"""
Perform a lenient, typotolerant, multiindex search.
* Fulltext with fuzziness for spelling mistakes
* `bool_prefix` for edgengram autocomplete / icontains
"""
if not query: if not query:
raise ValueError(_("no search term provided.")) raise ValueError(_("no search term provided."))
query = query.strip() query = query.strip()
try: try:
exact_shoulds = [
Q("term", **{"name.raw": {"value": query, "boost": 3.0}}),
Q("term", **{"slug": {"value": slugify(query), "boost": 2.0}}),
Q("term", **{"sku.raw": {"value": query.lower(), "boost": 8.0}}),
Q("term", **{"partnumber.raw": {"value": query.lower(), "boost": 9.0}}),
]
lang = ""
if request and hasattr(request, "LANGUAGE_CODE") and request.LANGUAGE_CODE:
lang = request.LANGUAGE_CODE.lower()
base = lang.split("-")[0] if lang else ""
is_cjk = base in {"ja", "zh"}
is_rtl_or_indic = base in {"ar", "hi"}
fields_all = SMART_FIELDS[:]
if is_cjk or is_rtl_or_indic:
fields_all = [f for f in fields_all if ".phonetic" not in f]
fields_all = [
f.replace("name.ngram^8", "name.ngram^10").replace("title.ngram^4", "title.ngram^6") for f in fields_all
]
fuzzy = None if (is_cjk or is_rtl_or_indic) else "AUTO:5,8"
is_code_like = bool(re.search(r"[0-9]", query)) and " " not in query
text_shoulds = [
Q(
"multi_match",
query=query,
fields=fields_all,
operator="and",
**({"fuzziness": fuzzy} if fuzzy else {}),
),
Q(
"multi_match",
query=query,
fields=[f for f in fields_all if f.endswith(".auto")],
type="bool_prefix",
),
]
if is_code_like:
text_shoulds.extend(
[
Q("term", **{"sku.raw": {"value": query.lower(), "boost": 12.0}}),
Q("term", **{"partnumber.raw": {"value": query.lower(), "boost": 14.0}}),
Q("prefix", **{"sku.raw": {"value": query.lower(), "boost": 6.0}}),
Q("prefix", **{"partnumber.raw": {"value": query.lower(), "boost": 7.0}}),
]
)
query_base = Q( query_base = Q(
"bool", "bool",
should=[ should=exact_shoulds + text_shoulds,
Q(
"multi_match",
query=query,
fields=SMART_FIELDS,
fuzziness="AUTO",
operator="and",
),
Q(
"multi_match",
query=query,
fields=[f for f in SMART_FIELDS if f.endswith(".auto")],
type="bool_prefix",
),
],
minimum_should_match=1, minimum_should_match=1,
) )
function_score_query = Q( def build_search(indexes, size):
"function_score", return (
query=query_base, Search(index=indexes)
functions=functions, .query(query_base)
boost_mode="multiply", .extra(
score_mode="first", rescore={
) "window_size": 200,
"query": {
"rescore_query": Q(
"function_score",
query=Q("match_all"),
functions=functions,
boost_mode="sum",
score_mode="sum",
max_boost=2.0,
).to_dict(),
"query_weight": 1.0,
"rescore_query_weight": 1.0,
},
}
)
.extra(size=size, track_total_hits=True)
)
search = Search(index=["products", "categories", "brands", "posts"]).query(function_score_query).extra(size=100) search_cats = build_search(["categories"], size=22)
response = search.execute() search_brands = build_search(["brands"], size=22)
search_products = build_search(["products"], size=44)
resp_cats = search_cats.execute()
resp_brands = search_brands.execute()
resp_products = search_products.execute()
results: dict = {"products": [], "categories": [], "brands": [], "posts": []} results: dict = {"products": [], "categories": [], "brands": [], "posts": []}
for hit in response.hits: uuids_by_index: dict[str, list] = {"products": [], "categories": [], "brands": []}
hit_cache: list = []
for h in list(resp_cats.hits[:12]) + list(resp_brands.hits[:12]) + list(resp_products.hits[:26]):
hit_cache.append(h)
if getattr(h, "uuid", None):
uuids_by_index.setdefault(h.meta.index, []).append(str(h.uuid))
products_by_uuid = {}
brands_by_uuid = {}
cats_by_uuid = {}
if request:
if uuids_by_index.get("products"):
products_by_uuid = {
str(p.uuid): p
for p in Product.objects.filter(uuid__in=uuids_by_index["products"])
.select_related("brand", "category")
.prefetch_related("images")
}
if uuids_by_index.get("brands"):
brands_by_uuid = {str(b.uuid): b for b in Brand.objects.filter(uuid__in=uuids_by_index["brands"])}
if uuids_by_index.get("categories"):
cats_by_uuid = {str(c.uuid): c for c in Category.objects.filter(uuid__in=uuids_by_index["categories"])}
for hit in hit_cache:
obj_uuid = getattr(hit, "uuid", None) or hit.meta.id obj_uuid = getattr(hit, "uuid", None) or hit.meta.id
obj_name = getattr(hit, "name", None) or getattr(hit, "title", None) or "N/A" obj_name = getattr(hit, "name", None) or getattr(hit, "title", None) or "N/A"
obj_slug = "" obj_slug = getattr(hit, "slug", "") or (
raw_slug = getattr(hit, "slug", None) slugify(obj_name) if hit.meta.index in {"brands", "categories"} else ""
if raw_slug: )
obj_slug = raw_slug
elif hit.meta.index == "brands":
obj_slug = slugify(obj_name)
elif hit.meta.index == "categories":
obj_slug = slugify(f"{obj_name}")
image_url = None image_url = None
idx = hit.meta.index idx = hit.meta.index
if idx == "products" and request: if idx == "products" and request:
prod = get_object_or_404(Product, uuid=obj_uuid) prod = products_by_uuid.get(str(obj_uuid))
first = prod.images.order_by("priority").first() if prod:
if first and first.image: first = prod.images.order_by("priority").first()
image_url = request.build_absolute_uri(first.image.url) if first and first.image:
image_url = request.build_absolute_uri(first.image.url)
elif idx == "brands" and request: elif idx == "brands" and request:
brand = get_object_or_404(Brand, uuid=obj_uuid) brand = brands_by_uuid.get(str(obj_uuid))
if brand.small_logo: if brand and brand.small_logo:
image_url = request.build_absolute_uri(brand.small_logo.url) image_url = request.build_absolute_uri(brand.small_logo.url)
elif idx == "categories" and request: elif idx == "categories" and request:
cat = get_object_or_404(Category, uuid=obj_uuid) cat = cats_by_uuid.get(str(obj_uuid))
if cat.image: if cat and cat.image:
image_url = request.build_absolute_uri(cat.image.url) image_url = request.build_absolute_uri(cat.image.url)
hit_result = { hit_result = {
@ -175,12 +259,8 @@ def process_query(query: str = "", request: Request | None = None) -> dict[str,
hit_result["total_orders_debug"] = getattr(hit, "total_orders", 0) hit_result["total_orders_debug"] = getattr(hit, "total_orders", 0)
hit_result["brand_priority_debug"] = getattr(hit, "brand_priority", 0) hit_result["brand_priority_debug"] = getattr(hit, "brand_priority", 0)
hit_result["category_priority_debug"] = getattr(hit, "category_priority", 0) hit_result["category_priority_debug"] = getattr(hit, "category_priority", 0)
if idx == "brands": if idx in ("brands", "categories"):
hit_result["priority_debug"] = getattr(hit, "priority", 0) hit_result["priority_debug"] = getattr(hit, "priority", 0)
if idx == "categories":
hit_result["priority_debug"] = getattr(hit, "priority", 0)
if idx == "posts":
pass
results[idx].append(hit_result) results[idx].append(hit_result)
@ -190,30 +270,29 @@ def process_query(query: str = "", request: Request | None = None) -> dict[str,
LANGUAGE_ANALYZER_MAP = { LANGUAGE_ANALYZER_MAP = {
"ar": "arabic",
"cs": "czech", "cs": "czech",
"da": "danish", "da": "danish",
"de": "german", "de": "german",
"en": "english", "en": "english",
"es": "spanish", "es": "spanish",
"fr": "french", "fr": "french",
"hi": "hindi",
"it": "italian", "it": "italian",
"ja": "standard",
"kk": "standard",
"nl": "dutch", "nl": "dutch",
"pl": "standard",
"pt": "portuguese", "pt": "portuguese",
"ro": "romanian", "ro": "romanian",
"ja": "cjk_search",
"zh": "cjk_search",
"ar": "arabic_search",
"hi": "indic_search",
"ru": "russian", "ru": "russian",
"zh": "standard", "pl": "standard",
"kk": "standard",
} }
def _lang_analyzer(lang_code: str) -> str: def _lang_analyzer(lang_code: str) -> str:
"""Return the bestguess ES analyzer for an ISO language code."""
base = lang_code.split("-")[0].lower() base = lang_code.split("-")[0].lower()
return LANGUAGE_ANALYZER_MAP.get(base, "standard") return LANGUAGE_ANALYZER_MAP.get(base, "icu_query")
class ActiveOnlyMixin: class ActiveOnlyMixin:
@ -227,38 +306,78 @@ class ActiveOnlyMixin:
COMMON_ANALYSIS = { COMMON_ANALYSIS = {
"char_filter": {
"icu_nfkc_cf": {"type": "icu_normalizer", "name": "nfkc_cf"},
},
"filter": { "filter": {
"edge_ngram_filter": {"type": "edge_ngram", "min_gram": 1, "max_gram": 20}, "edge_ngram_filter": {"type": "edge_ngram", "min_gram": 1, "max_gram": 20},
"ngram_filter": {"type": "ngram", "min_gram": 2, "max_gram": 20}, "ngram_filter": {"type": "ngram", "min_gram": 2, "max_gram": 20},
"double_metaphone": { "cjk_bigram": {"type": "cjk_bigram"},
"type": "phonetic", "icu_folding": {"type": "icu_folding"},
"encoder": "double_metaphone", "double_metaphone": {"type": "phonetic", "encoder": "double_metaphone", "replace": False},
"replace": False, "arabic_norm": {"type": "arabic_normalization"},
}, "indic_norm": {"type": "indic_normalization"},
}, },
"analyzer": { "analyzer": {
"icu_query": {
"type": "custom",
"char_filter": ["icu_nfkc_cf"],
"tokenizer": "icu_tokenizer",
"filter": ["lowercase", "icu_folding"],
},
"autocomplete": { "autocomplete": {
"tokenizer": "standard", "type": "custom",
"filter": ["lowercase", "asciifolding", "edge_ngram_filter"], "char_filter": ["icu_nfkc_cf"],
"tokenizer": "icu_tokenizer",
"filter": ["lowercase", "icu_folding", "edge_ngram_filter"],
}, },
"autocomplete_search": { "autocomplete_search": {
"tokenizer": "standard", "type": "custom",
"filter": ["lowercase", "asciifolding"], "char_filter": ["icu_nfkc_cf"],
"tokenizer": "icu_tokenizer",
"filter": ["lowercase", "icu_folding"],
}, },
"name_ngram": { "name_ngram": {
"tokenizer": "standard", "type": "custom",
"filter": ["lowercase", "asciifolding", "ngram_filter"], "char_filter": ["icu_nfkc_cf"],
"tokenizer": "icu_tokenizer",
"filter": ["lowercase", "icu_folding", "ngram_filter"],
}, },
"name_phonetic": { "name_phonetic": {
"tokenizer": "standard", "type": "custom",
"filter": ["lowercase", "asciifolding", "double_metaphone"], "char_filter": ["icu_nfkc_cf"],
"tokenizer": "icu_tokenizer",
"filter": ["lowercase", "icu_folding", "double_metaphone"],
}, },
"query_lc": {"tokenizer": "standard", "filter": ["lowercase", "asciifolding"]}, "cjk_search": {
"type": "custom",
"char_filter": ["icu_nfkc_cf"],
"tokenizer": "icu_tokenizer",
"filter": ["lowercase", "icu_folding", "cjk_bigram"],
},
"arabic_search": {
"type": "custom",
"char_filter": ["icu_nfkc_cf"],
"tokenizer": "icu_tokenizer",
"filter": ["lowercase", "icu_folding", "arabic_norm"],
},
"indic_search": {
"type": "custom",
"char_filter": ["icu_nfkc_cf"],
"tokenizer": "icu_tokenizer",
"filter": ["lowercase", "icu_folding", "indic_norm"],
},
},
"normalizer": {
"lc_norm": {
"type": "custom",
"filter": ["lowercase", "icu_folding"],
}
}, },
} }
def _add_multilang_fields(cls): def add_multilang_fields(cls):
""" """
Dynamically add multilingual name/description fields and prepare methods to guard against None. Dynamically add multilingual name/description fields and prepare methods to guard against None.
""" """
@ -275,7 +394,7 @@ def _add_multilang_fields(cls):
copy_to="name", copy_to="name",
fields={ fields={
"raw": fields.KeywordField(ignore_above=256), "raw": fields.KeywordField(ignore_above=256),
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="query_lc"), "ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"),
"phonetic": fields.TextField(analyzer="name_phonetic"), "phonetic": fields.TextField(analyzer="name_phonetic"),
}, },
), ),
@ -298,7 +417,7 @@ def _add_multilang_fields(cls):
copy_to="description", copy_to="description",
fields={ fields={
"raw": fields.KeywordField(ignore_above=256), "raw": fields.KeywordField(ignore_above=256),
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="query_lc"), "ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"),
"phonetic": fields.TextField(analyzer="name_phonetic"), "phonetic": fields.TextField(analyzer="name_phonetic"),
}, },
), ),

View file

@ -2,17 +2,17 @@ from django_elasticsearch_dsl import Document, fields
from django_elasticsearch_dsl.registries import registry from django_elasticsearch_dsl.registries import registry
from health_check.db.models import TestModel from health_check.db.models import TestModel
from core.elasticsearch import COMMON_ANALYSIS, ActiveOnlyMixin, _add_multilang_fields from core.elasticsearch import COMMON_ANALYSIS, ActiveOnlyMixin, add_multilang_fields
from core.models import Brand, Category, Product from core.models import Brand, Category, Product
class _BaseDoc(ActiveOnlyMixin, Document): class BaseDocument(Document):
name = fields.TextField( name = fields.TextField(
attr="name", attr="name",
analyzer="standard", analyzer="standard",
fields={ fields={
"raw": fields.KeywordField(ignore_above=256), "raw": fields.KeywordField(ignore_above=256),
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="query_lc"), "ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"),
"phonetic": fields.TextField(analyzer="name_phonetic"), "phonetic": fields.TextField(analyzer="name_phonetic"),
"auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"), "auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"),
}, },
@ -22,7 +22,7 @@ class _BaseDoc(ActiveOnlyMixin, Document):
analyzer="standard", analyzer="standard",
fields={ fields={
"raw": fields.KeywordField(ignore_above=256), "raw": fields.KeywordField(ignore_above=256),
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="query_lc"), "ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"),
"phonetic": fields.TextField(analyzer="name_phonetic"), "phonetic": fields.TextField(analyzer="name_phonetic"),
"auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"), "auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"),
}, },
@ -44,9 +44,9 @@ class _BaseDoc(ActiveOnlyMixin, Document):
return getattr(instance, "description", "") or "" return getattr(instance, "description", "") or ""
class ProductDocument(_BaseDoc): class ProductDocument(ActiveOnlyMixin, BaseDocument):
rating = fields.FloatField(attr="rating") rating = fields.FloatField(attr="rating")
total_order = fields.IntegerField(attr="total_orders") total_orders = fields.IntegerField(attr="total_orders")
brand_priority = fields.IntegerField( brand_priority = fields.IntegerField(
attr="brand.priority", attr="brand.priority",
index=True, index=True,
@ -58,7 +58,59 @@ class ProductDocument(_BaseDoc):
fields={"raw": fields.KeywordField()}, fields={"raw": fields.KeywordField()},
) )
class Index(_BaseDoc.Index): brand_name = fields.TextField(
attr="brand.name",
analyzer="standard",
fields={
"raw": fields.KeywordField(ignore_above=256),
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"),
"phonetic": fields.TextField(analyzer="name_phonetic"),
"auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"),
},
)
category_name = fields.TextField(
attr="category.name",
analyzer="standard",
fields={
"raw": fields.KeywordField(ignore_above=256),
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"),
"phonetic": fields.TextField(analyzer="name_phonetic"),
"auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"),
},
)
sku = fields.KeywordField(
attr="sku",
normalizer="lc_norm",
fields={
"raw": fields.KeywordField(normalizer="lc_norm"),
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"),
"auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"),
},
)
partnumber = fields.KeywordField(
attr="partnumber",
normalizer="lc_norm",
fields={
"raw": fields.KeywordField(normalizer="lc_norm"),
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"),
"auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"),
},
)
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
brand__is_active=True,
category__is_active=True,
stocks__vendor__is_active=True,
)
)
class Index(BaseDocument.Index):
name = "products" name = "products"
class Django: class Django:
@ -66,14 +118,14 @@ class ProductDocument(_BaseDoc):
fields = ["uuid"] fields = ["uuid"]
_add_multilang_fields(ProductDocument) add_multilang_fields(ProductDocument)
registry.register_document(ProductDocument) registry.register_document(ProductDocument)
class CategoryDocument(_BaseDoc): class CategoryDocument(ActiveOnlyMixin, BaseDocument):
priority = fields.IntegerField(attr="priority") priority = fields.IntegerField(attr="priority")
class Index(_BaseDoc.Index): class Index(BaseDocument.Index):
name = "categories" name = "categories"
class Django: class Django:
@ -81,30 +133,22 @@ class CategoryDocument(_BaseDoc):
fields = ["uuid"] fields = ["uuid"]
_add_multilang_fields(CategoryDocument) add_multilang_fields(CategoryDocument)
registry.register_document(CategoryDocument) registry.register_document(CategoryDocument)
class BrandDocument(ActiveOnlyMixin, Document): class BrandDocument(ActiveOnlyMixin, BaseDocument):
priority = fields.IntegerField(attr="priority") priority = fields.IntegerField(attr="priority")
class Index: class Index(BaseDocument.Index):
name = "brands" name = "brands"
settings = {
"number_of_shards": 1,
"number_of_replicas": 0,
"analysis": COMMON_ANALYSIS,
"index": {"max_ngram_diff": 18},
}
class Django: class Django:
model = Brand model = Brand
fields = ["uuid"] fields = ["uuid"]
def prepare_name(self, instance):
return getattr(instance, "name", "") or ""
add_multilang_fields(BrandDocument)
registry.register_document(BrandDocument) registry.register_document(BrandDocument)
@ -114,9 +158,7 @@ class TestModelDocument(Document):
class Django: class Django:
model = TestModel model = TestModel
fields = [ fields = ["title"]
"title",
]
ignore_signals = True ignore_signals = True
related_models: list = [] related_models: list = []
auto_refresh = False auto_refresh = False

View file

@ -14,6 +14,8 @@ from django.db.models import (
Subquery, Subquery,
Value, Value,
When, When,
Max,
Prefetch,
) )
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils.http import urlsafe_base64_decode from django.utils.http import urlsafe_base64_decode
@ -73,6 +75,11 @@ class ProductFilter(FilterSet):
slug = CharFilter(field_name="slug", lookup_expr="exact", label=_("Slug")) slug = CharFilter(field_name="slug", lookup_expr="exact", label=_("Slug"))
is_digital = BooleanFilter(field_name="is_digital", label=_("Is Digital")) is_digital = BooleanFilter(field_name="is_digital", label=_("Is Digital"))
include_subcategories = BooleanFilter(method="filter_include_flag", label=_("Include sub-categories")) include_subcategories = BooleanFilter(method="filter_include_flag", label=_("Include sub-categories"))
include_personal_ordered = BooleanFilter(
method="filter_include_personal_ordered",
label=_("Include personal ordered"),
)
sku = CharFilter(field_name="sku", lookup_expr="iexact", label=_("SKU"))
order_by = OrderingFilter( order_by = OrderingFilter(
fields=( fields=(
@ -82,7 +89,8 @@ class ProductFilter(FilterSet):
("slug", "slug"), ("slug", "slug"),
("created", "created"), ("created", "created"),
("modified", "modified"), ("modified", "modified"),
("stocks__price", "price"), ("price_order", "price"),
("sku", "sku"),
("?", "random"), ("?", "random"),
), ),
initial="uuid", initial="uuid",
@ -102,6 +110,7 @@ class ProductFilter(FilterSet):
"is_active", "is_active",
"tags", "tags",
"slug", "slug",
"sku",
"min_price", "min_price",
"max_price", "max_price",
"brand", "brand",
@ -127,6 +136,14 @@ class ProductFilter(FilterSet):
Value(0, output_field=FloatField()), Value(0, output_field=FloatField()),
) )
) )
if "price" in order_fields:
self.queryset = self.queryset.annotate(
price_order=Coalesce(
Max("stocks__price"),
Value(0.0),
output_field=FloatField(),
)
)
def filter_name(self, queryset, _name, value): def filter_name(self, queryset, _name, value):
search_results = process_query(query=value, request=self.request)["products"] search_results = process_query(query=value, request=self.request)["products"]
@ -147,6 +164,11 @@ class ProductFilter(FilterSet):
raise BadRequest(_("there must be a category_uuid to use include_subcategories flag")) raise BadRequest(_("there must be a category_uuid to use include_subcategories flag"))
return queryset return queryset
def filter_include_personal_ordered(self, queryset, **_kwargs):
if self.data.get("include_personal_ordered", False):
queryset = queryset.filter(stocks__isnull=False, stocks__quantity__gt=0, stocks__price__gt=0)
return queryset
def filter_attributes(self, queryset, _name, value): def filter_attributes(self, queryset, _name, value):
if not value: if not value:
return queryset return queryset
@ -361,14 +383,7 @@ class CategoryFilter(FilterSet):
tags = CaseInsensitiveListFilter(field_name="tags__tag_name", label=_("Tags")) tags = CaseInsensitiveListFilter(field_name="tags__tag_name", label=_("Tags"))
level = NumberFilter(field_name="level", lookup_expr="exact", label=_("Level")) level = NumberFilter(field_name="level", lookup_expr="exact", label=_("Level"))
order_by = OrderingFilter( order_by = CharFilter(method="filter_order_by")
fields=(
("priority", "priority"),
("uuid", "uuid"),
("name", "name"),
("?", "random"),
)
)
class Meta: class Meta:
model = Category model = Category
@ -383,6 +398,37 @@ class CategoryFilter(FilterSet):
"whole", "whole",
] ]
def filter_order_by(self, queryset, _name, value):
if not value:
return queryset
desc = value.startswith("-")
key = value.lstrip("-")
mapping = {
"priority": "priority",
"uuid": "uuid",
"name": "name",
"?": "random",
}
field = mapping.get(key)
if field is None:
return queryset
if field == "?":
parent_order = "?"
child_order = "?"
else:
parent_order = f"-{field}" if desc else field
child_order = parent_order
qs = queryset.order_by(parent_order).prefetch_related(None)
children_qs = (
Category.objects.all().order_by(child_order) if child_order != "?" else Category.objects.all().order_by("?")
)
return qs.prefetch_related(Prefetch("children", queryset=children_qs))
def filter_name(self, queryset, _name, value): def filter_name(self, queryset, _name, value):
search_results = process_query(query=value, request=self.request)["categories"] search_results = process_query(query=value, request=self.request)["categories"]
uuids = [res["uuid"] for res in search_results if res.get("uuid")] uuids = [res["uuid"] for res in search_results if res.get("uuid")]
@ -427,12 +473,14 @@ class CategoryFilter(FilterSet):
class BrandFilter(FilterSet): class BrandFilter(FilterSet):
uuid = UUIDFilter(field_name="uuid", lookup_expr="exact") uuid = UUIDFilter(field_name="uuid", lookup_expr="exact")
name = CharFilter(method="filter_name", label=_("Name")) name = CharFilter(method="filter_name", label=_("Name"))
slug = CharFilter(field_name="slug", lookup_expr="exact", label=_("Slug"))
categories = CaseInsensitiveListFilter(field_name="categories__uuid", lookup_expr="exact", label=_("Categories")) categories = CaseInsensitiveListFilter(field_name="categories__uuid", lookup_expr="exact", label=_("Categories"))
order_by = OrderingFilter( order_by = OrderingFilter(
fields=( fields=(
("priority", "priority"), ("priority", "priority"),
("uuid", "uuid"), ("uuid", "uuid"),
("slug", "slug"),
("name", "name"), ("name", "name"),
("?", "random"), ("?", "random"),
) )
@ -440,7 +488,7 @@ class BrandFilter(FilterSet):
class Meta: class Meta:
model = Brand model = Brand
fields = ["uuid", "name"] fields = ["uuid", "name", "slug", "priority"]
def filter_name(self, queryset, _name, value): def filter_name(self, queryset, _name, value):
search_results = process_query(query=value, request=self.request)["brands"] search_results = process_query(query=value, request=self.request)["brands"]

View file

@ -68,7 +68,7 @@ class SEOMetaType(ObjectType):
open_graph = GenericScalar() open_graph = GenericScalar()
twitter = GenericScalar() twitter = GenericScalar()
json_ld = List(GenericScalar) json_ld = List(GenericScalar)
hreflang = List(GenericScalar) hreflang = String()
class AttributeType(DjangoObjectType): class AttributeType(DjangoObjectType):
@ -114,12 +114,12 @@ class AttributeGroupType(DjangoObjectType):
class BrandType(DjangoObjectType): class BrandType(DjangoObjectType):
categories = List(lambda: CategoryType, description=_("categories")) categories = List(lambda: CategoryType, description=_("categories"))
seo_meta = Field(SEOMetaType, description=_("SEO meta snapshot")) seo_meta = Field(SEOMetaType, description=_("SEO Meta snapshot"))
class Meta: class Meta:
model = Brand model = Brand
interfaces = (relay.Node,) interfaces = (relay.Node,)
fields = ("uuid", "categories", "name", "description", "big_logo", "small_logo") fields = ("uuid", "categories", "name", "description", "big_logo", "small_logo", "slug")
filter_fields = ["uuid", "name"] filter_fields = ["uuid", "name"]
description = _("brands") description = _("brands")
@ -173,7 +173,7 @@ class BrandType(DjangoObjectType):
"open_graph": og, "open_graph": og,
"twitter": tw, "twitter": tw,
"json_ld": json_ld, "json_ld": json_ld,
"hreflang": [], "hreflang": info.context.LANGUAGE_CODE,
} }
@ -204,7 +204,7 @@ class CategoryType(DjangoObjectType):
) )
tags = DjangoFilterConnectionField(lambda: CategoryTagType, description=_("tags for this category")) tags = DjangoFilterConnectionField(lambda: CategoryTagType, description=_("tags for this category"))
products = DjangoFilterConnectionField(lambda: ProductType, description=_("products in this category")) products = DjangoFilterConnectionField(lambda: ProductType, description=_("products in this category"))
seo_meta = Field(SEOMetaType, description=_("SEO meta snapshot")) seo_meta = Field(SEOMetaType, description=_("SEO Meta snapshot"))
class Meta: class Meta:
model = Category model = Category
@ -336,7 +336,7 @@ class CategoryType(DjangoObjectType):
"open_graph": og, "open_graph": og,
"twitter": tw, "twitter": tw,
"json_ld": json_ld, "json_ld": json_ld,
"hreflang": [], "hreflang": info.context.LANGUAGE_CODE,
} }
@ -500,13 +500,14 @@ class ProductType(DjangoObjectType):
quantity = Float(description=_("quantity")) quantity = Float(description=_("quantity"))
feedbacks_count = Int(description=_("number of feedbacks")) feedbacks_count = Int(description=_("number of feedbacks"))
personal_orders_only = Boolean(description=_("only available for personal orders")) personal_orders_only = Boolean(description=_("only available for personal orders"))
seo_meta = Field(SEOMetaType, description=_("SEO meta snapshot")) seo_meta = Field(SEOMetaType, description=_("SEO Meta snapshot"))
class Meta: class Meta:
model = Product model = Product
interfaces = (relay.Node,) interfaces = (relay.Node,)
fields = ( fields = (
"uuid", "uuid",
"sku",
"category", "category",
"brand", "brand",
"tags", "tags",
@ -589,7 +590,7 @@ class ProductType(DjangoObjectType):
"open_graph": og, "open_graph": og,
"twitter": tw, "twitter": tw,
"json_ld": json_ld, "json_ld": json_ld,
"hreflang": [], "hreflang": info.context.LANGUAGE_CODE,
} }

View file

@ -155,6 +155,7 @@ class Query(ObjectType):
is_active=True, is_active=True,
brand__is_active=True, brand__is_active=True,
category__is_active=True, category__is_active=True,
stocks__isnull=False,
stocks__vendor__is_active=True, stocks__vendor__is_active=True,
) )
.select_related("brand", "category") .select_related("brand", "category")

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@ import requests
from constance import config from constance import config
from django.contrib.gis.geos import Point from django.contrib.gis.geos import Point
from django.db import models from django.db import models
from modeltranslation.manager import MultilingualManager
logger = logging.getLogger("django") logger = logging.getLogger("django")
@ -62,3 +63,24 @@ class AddressManager(models.Manager):
user=kwargs.pop("user"), user=kwargs.pop("user"),
defaults={"api_response": data, "location": location}, defaults={"api_response": data, "location": location},
)[0] )[0]
class ProductManager(MultilingualManager):
def available(self):
return self.filter(
is_active=True,
brand__is_active=True,
category__is_active=True,
stocks__isnull=False,
stocks__vendor__is_active=True,
)
def available_in_stock(self):
return self.filter(
is_active=True,
brand__is_active=True,
category__is_active=True,
stocks__isnull=False,
stocks__vendor__is_active=True,
stocks__quantity__gt=0,
)

View file

@ -0,0 +1,25 @@
# Generated by Django 5.2 on 2025-09-01 17:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0036_vendor_b2b_auth_token_vendor_users"),
]
operations = [
migrations.AddField(
model_name="product",
name="sku",
field=models.CharField(
blank=True,
db_index=True,
default=None,
help_text="stock keeping unit for this product",
max_length=8,
null=True,
verbose_name="SKU",
),
),
]

View file

@ -0,0 +1,51 @@
from django.db import migrations, transaction
def generate_unique_sku(make_candidate, taken):
while True:
c = make_candidate()
if c not in taken:
taken.add(c)
return c
def backfill_sku(apps, schema_editor):
Product = apps.get_model("core", "Product")
from core.utils import generate_human_readable_id as make_candidate
taken = set(Product.objects.exclude(sku__isnull=True).values_list("sku", flat=True))
BATCH = 10000
last_pk = 0
while True:
ids = list(
Product.objects.filter(sku__isnull=True, pk__gt=last_pk).order_by("pk").values_list("pk", flat=True)[:BATCH]
)
if not ids:
break
updates = []
for pk in ids:
updates.append(Product(pk=pk, sku=generate_unique_sku(make_candidate, taken)))
with transaction.atomic():
Product.objects.bulk_update(updates, ["sku"], batch_size=BATCH)
last_pk = ids[-1]
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
atomic = False
dependencies = [
("core", "0037_product_sku"),
]
operations = [
migrations.RunPython(backfill_sku, reverse_code=noop),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 5.2 on 2025-09-01 17:36
import core.utils
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0038_backfill_product_sku"),
]
operations = [
migrations.AlterField(
model_name="product",
name="sku",
field=models.CharField(
default=core.utils.generate_human_readable_id,
help_text="stock keeping unit for this product",
max_length=8,
unique=True,
verbose_name="SKU",
),
),
]

View file

@ -0,0 +1,153 @@
# Generated by Django 5.2 on 2025-09-06 22:05
import django.db.models.deletion
import django_extensions.db.fields
import django_prometheus.models
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0039_alter_product_sku"),
]
operations = [
migrations.CreateModel(
name="CustomerRelationshipManagementProvider",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="unique id is used to surely identify any database object",
primary_key=True,
serialize=False,
verbose_name="unique id",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="if set to false, this object can't be seen by users without needed permission",
verbose_name="is active",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True,
help_text="when the object first appeared on the database",
verbose_name="created",
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
),
),
(
"name",
models.CharField(max_length=128, unique=True, verbose_name="name"),
),
(
"integration_url",
models.URLField(blank=True, help_text="URL of the integration", null=True),
),
(
"authentication",
models.JSONField(blank=True, help_text="authentication credentials", null=True),
),
(
"attributes",
models.JSONField(blank=True, null=True, verbose_name="attributes"),
),
(
"integration_location",
models.CharField(blank=True, max_length=128, null=True),
),
("default", models.BooleanField(default=False)),
],
options={
"verbose_name": "order CRM link",
"verbose_name_plural": "orders CRM links",
},
bases=(
django_prometheus.models.ExportModelOperationsMixin("crm_provider"),
models.Model,
),
),
migrations.CreateModel(
name="OrderCrmLink",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="unique id is used to surely identify any database object",
primary_key=True,
serialize=False,
verbose_name="unique id",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="if set to false, this object can't be seen by users without needed permission",
verbose_name="is active",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True,
help_text="when the object first appeared on the database",
verbose_name="created",
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
),
),
(
"crm_lead_id",
models.CharField(db_index=True, max_length=30, unique=True),
),
(
"crm",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="order_links",
to="core.customerrelationshipmanagementprovider",
),
),
(
"order",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="crm_links",
to="core.order",
),
),
],
options={
"verbose_name": "order CRM link",
"verbose_name_plural": "orders CRM links",
},
bases=(
django_prometheus.models.ExportModelOperationsMixin("order_crm_link"),
models.Model,
),
),
]

View file

@ -0,0 +1,16 @@
# Generated by Django 5.2 on 2025-09-06 22:16
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0040_customerrelationshipmanagementprovider_ordercrmlink"),
]
operations = [
migrations.AlterModelOptions(
name="customerrelationshipmanagementprovider",
options={"verbose_name": "CRM", "verbose_name_plural": "CRMs"},
),
]

View file

@ -1,11 +1,11 @@
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
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.contrib.gis.db.models import PointField from django.contrib.gis.db.models import PointField
from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.indexes import GinIndex
from django.core.cache import cache from django.core.cache import cache
@ -32,7 +32,6 @@ from django.db.models import (
PositiveIntegerField, PositiveIntegerField,
QuerySet, QuerySet,
TextField, TextField,
UUIDField,
URLField, URLField,
) )
from django.db.models.indexes import Index from django.db.models.indexes import Index
@ -49,7 +48,7 @@ from mptt.models import MPTTModel
from core.abstract import NiceModel from core.abstract import NiceModel
from core.choices import ORDER_PRODUCT_STATUS_CHOICES, ORDER_STATUS_CHOICES from core.choices import ORDER_PRODUCT_STATUS_CHOICES, ORDER_STATUS_CHOICES
from core.errors import DisabledCommerceError, NotEnoughMoneyError from core.errors import DisabledCommerceError, NotEnoughMoneyError
from core.managers import AddressManager from core.managers import AddressManager, ProductManager
from core.utils import ( from core.utils import (
generate_human_readable_id, generate_human_readable_id,
get_product_uuid_as_path, get_product_uuid_as_path,
@ -60,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")
@ -609,7 +609,7 @@ class Product(ExportModelOperationsMixin("product"), NiceModel): # type: ignore
Properties: Properties:
rating (float): The average rating of the product, rounded to 2 decimal places. rating (float): The average rating of the product, rounded to 2 decimal places.
feedbacks_count (int): The total number of feedback entries associated with the product. feedbacks_count (int): The total number of feedback entries associated with the product.
price (float): The lowest price of the product based on its stock, rounded to 2 decimal price (float): The highest price of the product based on its stock, rounded to 2 decimal
places. places.
quantity (int): The total available quantity of the product across all its stocks. quantity (int): The total available quantity of the product across all its stocks.
total_orders (int): Counts the total orders made for the product in relevant statuses. total_orders (int): Counts the total orders made for the product in relevant statuses.
@ -680,6 +680,15 @@ class Product(ExportModelOperationsMixin("product"), NiceModel): # type: ignore
null=True, null=True,
verbose_name=_("Slug"), verbose_name=_("Slug"),
) )
sku = CharField(
help_text=_("stock keeping unit for this product"),
verbose_name=_("SKU"),
max_length=8,
unique=True,
default=generate_human_readable_id,
)
objects: ProductManager = ProductManager()
class Meta: class Meta:
verbose_name = _("product") verbose_name = _("product")
@ -1422,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()
@ -1600,7 +1613,7 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
) -> Self | Transaction | None: ) -> Self | Transaction | None:
order = self order = self
if not self.attributes: if not self.attributes or type(self.attributes) is not dict:
self.attributes = {} self.attributes = {}
if chosen_products: if chosen_products:
@ -1638,6 +1651,17 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
if not order.user: if not order.user:
raise ValueError(_("you cannot buy an order without a user")) raise ValueError(_("you cannot buy an order without a user"))
if type(order.user.attributes) is dict:
if order.user.attributes.get("is_business", False) or order.user.attributes.get(
"business_identificator", ""
):
if type(order.attributes) is not dict:
order.attributes = {}
order.attributes.update({"is_business": True})
else:
order.user.attributes = {}
order.user.save()
if not order.user.payments_balance: if not order.user.payments_balance:
raise ValueError(_("a user without a balance cannot buy with balance")) raise ValueError(_("a user without a balance cannot buy with balance"))
@ -1661,8 +1685,8 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
currency=CURRENCY_CODE, currency=CURRENCY_CODE,
order=order, order=order,
) )
case _:
return order raise ValueError(_("invalid force value"))
def buy_without_registration(self, products: list, promocode_uuid, **kwargs) -> Transaction | None: def buy_without_registration(self, products: list, promocode_uuid, **kwargs) -> Transaction | None:
if config.DISABLED_COMMERCE: if config.DISABLED_COMMERCE:
@ -1671,7 +1695,7 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
if len(products) < 1: if len(products) < 1:
raise ValueError(_("you cannot purchase an empty order!")) raise ValueError(_("you cannot purchase an empty order!"))
customer_name = kwargs.get("customer_name") customer_name = kwargs.get("customer_name") or kwargs.get("business_identificator")
customer_email = kwargs.get("customer_email") customer_email = kwargs.get("customer_email")
customer_phone_number = kwargs.get("customer_phone_number") customer_phone_number = kwargs.get("customer_phone_number")
@ -1759,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.process_order_changes(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.process_order_changes(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]
""" """
@ -1913,6 +1960,44 @@ 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, verbose_name=_("attributes"))
integration_location = CharField(max_length=128, blank=True, null=True)
default = BooleanField(default=False)
def __str__(self) -> str:
return self.name
def save(self, **kwargs):
if self.default:
qs = type(self).objects.all()
if self.pk:
qs = qs.exclude(pk=self.pk)
if qs.filter(default=True).exists():
raise ValueError(_("you can only have one default CRM provider"))
super().save(**kwargs)
class Meta:
verbose_name = _("CRM")
verbose_name_plural = _("CRMs")
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
@ -2010,20 +2095,3 @@ class Feedback(ExportModelOperationsMixin("feedback"), NiceModel): # type: igno
class Meta: class Meta:
verbose_name = _("feedback") verbose_name = _("feedback")
verbose_name_plural = _("feedbacks") verbose_name_plural = _("feedbacks")
class SeoMeta(NiceModel):
uuid = None
content_type = ForeignKey(ContentType, on_delete=CASCADE)
object_id = UUIDField()
content_object = GenericForeignKey("content_type", "object_id")
meta_title = CharField(max_length=70, blank=True)
meta_description = CharField(max_length=180, blank=True)
canonical_override = URLField(blank=True)
robots = CharField(max_length=40, blank=True, default="index,follow")
social_image = ImageField(upload_to="seo/", blank=True, null=True)
extras = JSONField(blank=True, null=True)
class Meta:
unique_together = ("content_type", "object_id")

View file

@ -326,6 +326,7 @@ class ProductDetailSerializer(ModelSerializer):
model = Product model = Product
fields = [ fields = [
"uuid", "uuid",
"sku",
"name", "name",
"description", "description",
"partnumber", "partnumber",

View file

@ -7,7 +7,7 @@ class SeoSnapshotSerializer(Serializer):
description = CharField() description = CharField()
canonical = CharField() canonical = CharField()
robots = CharField() robots = CharField()
hreflang = ListField(child=DictField(), required=False) hreflang = CharField()
open_graph = DictField() open_graph = DictField()
twitter = DictField() twitter = DictField()
json_ld = ListField(child=DictField()) json_ld = ListField(child=DictField())

View file

@ -163,6 +163,7 @@ class ProductSimpleSerializer(ModelSerializer):
fields = [ fields = [
"uuid", "uuid",
"name", "name",
"sku",
"is_digital", "is_digital",
"slug", "slug",
"description", "description",

View file

@ -160,11 +160,11 @@ class BuyOrderSerializer(Serializer):
promocode_uuid = CharField(required=False) promocode_uuid = CharField(required=False)
shipping_address_uuid = CharField(required=False) shipping_address_uuid = CharField(required=False)
billing_address_uuid = CharField(required=False) billing_address_uuid = CharField(required=False)
chosen_products = ListField(child=AddOrderProductSerializer(), required=False) chosen_products = AddOrderProductSerializer(many=True, required=False)
class BuyUnregisteredOrderSerializer(Serializer): class BuyUnregisteredOrderSerializer(Serializer):
products = ListField(child=AddOrderProductSerializer(), required=True) products = AddOrderProductSerializer(many=True, required=True)
promocode_uuid = UUIDField(required=False) promocode_uuid = UUIDField(required=False)
customer_name = CharField(required=True) customer_name = CharField(required=True)
customer_email = CharField(required=True) customer_email = CharField(required=True)
@ -175,8 +175,8 @@ class BuyUnregisteredOrderSerializer(Serializer):
class BuyAsBusinessOrderSerializer(Serializer): class BuyAsBusinessOrderSerializer(Serializer):
products = ListField(child=AddOrderProductSerializer(), required=True) products = AddOrderProductSerializer(many=True, required=True)
business_inn = CharField(required=True) business_identificator = CharField(required=True)
business_email = CharField(required=True) business_email = CharField(required=True)
business_phone_number = CharField(required=True) business_phone_number = CharField(required=True)
billing_business_address_uuid = CharField(required=False) billing_business_address_uuid = CharField(required=False)

View file

@ -1,4 +1,5 @@
import logging import logging
from contextlib import suppress
from datetime import timedelta from datetime import timedelta
from django.db import IntegrityError from django.db import IntegrityError
@ -10,13 +11,14 @@ 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.crm.exceptions import CRMException
from core.models import Category, Order, Product, PromoCode, Wishlist, DigitalAssetDownload from core.models import Category, Order, Product, PromoCode, Wishlist, DigitalAssetDownload
from core.serializers import OrderProductSimpleSerializer
from core.utils import ( from core.utils import (
generate_human_readable_id, generate_human_readable_id,
resolve_translations_for_elasticsearch, resolve_translations_for_elasticsearch,
) )
from core.utils.emailing import send_order_created_email, send_order_finished_email from core.utils.emailing import send_order_created_email, send_order_finished_email, send_promocode_created_email
from evibes.utils.misc import create_object from evibes.utils.misc import create_object
from vibes_auth.models import User from vibes_auth.models import User
@ -68,6 +70,10 @@ 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() and instance.status != "PENDING":
with suppress(CRMException):
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")
@ -89,16 +95,20 @@ def process_order_changes(instance, created, **_kwargs):
break break
if instance.status in ["CREATED", "PAYMENT"]: if instance.status in ["CREATED", "PAYMENT"]:
logger.debug(
"Processing order changes: %s\nWith orderproducts: %s",
str(instance.__dict__),
str(OrderProductSimpleSerializer(instance.order_products.all(), many=True).data),
)
if not instance.is_whole_digital: if not instance.is_whole_digital:
send_order_created_email.delay(instance.uuid) send_order_created_email.delay(instance.uuid)
for order_product in instance.order_products.filter(status="DELIVERING", product__is_digital=True): for order_product in instance.order_products.filter(status="DELIVERING", product__is_digital=True):
if order_product.product.stocks.filter(digital_asset__isnull=False).exists(): stocks_qs = order_product.product.stocks.filter(digital_asset__isnull=False).exclude(digital_asset="")
stock = stocks_qs.first()
has_file = False
if stock:
f = stock.digital_asset
has_file = bool(getattr(f, "name", "")) and f.storage.exists(f.name)
if has_file:
order_product.status = "FINISHED" order_product.status = "FINISHED"
download = DigitalAssetDownload.objects.create(order_product=order_product) download = DigitalAssetDownload.objects.create(order_product=order_product)
order_product.download = download order_product.download = download
@ -107,7 +117,6 @@ def process_order_changes(instance, created, **_kwargs):
order_product.order.user.payments_balance.save() order_product.order.user.payments_balance.save()
continue continue
try: try:
logger.debug("Trying to buy: %s", str(order_product.uuid))
vendor_name = ( vendor_name = (
order_product.product.stocks.filter(price=order_product.buy_price).first().vendor.name.lower() order_product.product.stocks.filter(price=order_product.buy_price).first().vendor.name.lower()
) )
@ -146,3 +155,9 @@ def update_category_name_lang(instance, created, **_kwargs):
pass pass
resolve_translations_for_elasticsearch(instance, "name") resolve_translations_for_elasticsearch(instance, "name")
resolve_translations_for_elasticsearch(instance, "description") resolve_translations_for_elasticsearch(instance, "description")
@receiver(post_save, sender=PromoCode)
def send_promocode_creation_email(instance, created, **_kwargs):
if created:
send_promocode_created_email.delay(instance.uuid)

View file

@ -0,0 +1,122 @@
{% load tz static i18n filters conditions %}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% blocktrans %}promocode granted{% endblocktrans %}</title>
<style>
* {
box-sizing: border-box;
}
body, table, td, a {
text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
table {
}
body {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
}
img {
max-width: 100%;
height: auto;
}
.email-container {
max-width: 600px;
margin: 0 auto;
}
.content-cell {
border: 3px solid #000000;
padding: 20px;
font-family: Arial, sans-serif;
}
.header {
background-color: #000000;
padding: 20px;
text-align: center;
}
.header img {
width: 120px;
height: auto;
}
.footer {
background-color: #000000;
padding: 10px;
text-align: center;
font-size: 12px;
color: #888;
}
.order-table {
width: 100%;
margin-top: 20px;
border-collapse: collapse;
}
.order-table th, .order-table td {
border: 1px solid #ddd;
padding: 8px;
}
.order-table th {
background-color: #f4f4f4;
}
@media only screen and (max-width: 600px) {
.email-container {
width: 100% !important;
}
}
</style>
<link rel="icon" href="{% static 'favicon.png' %}" sizes="192x192">
</head>
<body>
<table>
<tr>
<td>
<table class="email-container">
<tr>
<td class="header">
<img src="{% static 'logo.png' %}"
alt="{% blocktrans %}logo{% endblocktrans %}" width="120">
</td>
</tr>
<tr>
<td class="content-cell">
<h2>{% blocktrans %}promocode granted{% endblocktrans %}</h2>
<p>{% blocktrans %}hello {{ user_first_name }},{% endblocktrans %}</p>
<p>{% blocktrans %}Thank you for staying with us! We have granted you with a promocode
for {% endblocktrans %}{% if promocode.discount_type == "amount" %}
{{ promocode.discount_amount }}{{ currency }}{% else %}{{ promocode.discount_percent }}
%{% endif %}</p>
<p>{% blocktrans %}if you have any questions, feel free to contact our support at
{{ contact_email }}.{% endblocktrans %}</p>
<p>{% blocktrans %}best regards,<br>the {{ project_name }} team{% endblocktrans %}</p>
</td>
</tr>
<tr>
<td class="footer">
&copy; {% now "Y" %} {{ project_name }}.
{% blocktrans %}all rights reserved{% endblocktrans %}.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show more