Merge branch 'main' into storefront-nuxt
This commit is contained in:
commit
9877633a2c
219 changed files with 11408 additions and 9776 deletions
|
|
@ -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
3
.gitignore
vendored
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
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
179
core/crm/amo/gateway.py
Normal file
179
core/crm/amo/gateway.py
Normal 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
2
core/crm/exceptions.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
class CRMException(Exception):
|
||||||
|
pass
|
||||||
|
|
@ -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=(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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, typo‑tolerant, multi‑index search.
|
|
||||||
|
|
||||||
* Full‑text with fuzziness for spelling mistakes
|
|
||||||
* `bool_prefix` for edge‑ngram 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 best‑guess 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"),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
25
core/migrations/0037_product_sku.py
Normal file
25
core/migrations/0037_product_sku.py
Normal 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
51
core/migrations/0038_backfill_product_sku.py
Normal file
51
core/migrations/0038_backfill_product_sku.py
Normal 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),
|
||||||
|
]
|
||||||
24
core/migrations/0039_alter_product_sku.py
Normal file
24
core/migrations/0039_alter_product_sku.py
Normal 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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"},
|
||||||
|
),
|
||||||
|
]
|
||||||
128
core/models.py
128
core/models.py
|
|
@ -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")
|
|
||||||
|
|
|
||||||
|
|
@ -326,6 +326,7 @@ class ProductDetailSerializer(ModelSerializer):
|
||||||
model = Product
|
model = Product
|
||||||
fields = [
|
fields = [
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"sku",
|
||||||
"name",
|
"name",
|
||||||
"description",
|
"description",
|
||||||
"partnumber",
|
"partnumber",
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,7 @@ class ProductSimpleSerializer(ModelSerializer):
|
||||||
fields = [
|
fields = [
|
||||||
"uuid",
|
"uuid",
|
||||||
"name",
|
"name",
|
||||||
|
"sku",
|
||||||
"is_digital",
|
"is_digital",
|
||||||
"slug",
|
"slug",
|
||||||
"description",
|
"description",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
122
core/templates/promocode_granted_email.html
Normal file
122
core/templates/promocode_granted_email.html
Normal 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">
|
||||||
|
© {% 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
Loading…
Reference in a new issue