Extra: RUFF

This commit is contained in:
Egor Pavlovich Gorbunov 2025-12-15 20:29:02 +03:00
parent 3b60f82770
commit 890957197c
128 changed files with 3266 additions and 917 deletions

View file

@ -4,7 +4,7 @@ repos:
hooks: hooks:
- id: ruff - id: ruff
name: Ruff (lint & fix) name: Ruff (lint & fix)
args: ["--fix", "--exit-non-zero-on-fix", "--force-exclude"] args: ["--fix", "--exit-non-zero-on-fix"]
files: "\\.(py|pyi)$" files: "\\.(py|pyi)$"
exclude: "^storefront/" exclude: "^storefront/"
- id: ruff-format - id: ruff-format

View file

@ -9,7 +9,9 @@ from engine.core.admin import ActivationActionsMixin, FieldsetsMixin
@register(Post) @register(Post)
class PostAdmin(SummernoteModelAdminMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type: ignore [misc, type-arg] class PostAdmin(
SummernoteModelAdminMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin
): # type: ignore [misc, type-arg]
list_display = ("title", "author", "slug", "created", "modified") list_display = ("title", "author", "slug", "created", "modified")
list_filter = ("author", "tags", "created", "modified") list_filter = ("author", "tags", "created", "modified")
search_fields = ("title", "content", "slug") search_fields = ("title", "content", "slug")

View file

@ -2,8 +2,8 @@ from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from rest_framework import status from rest_framework import status
from engine.core.docs.drf import BASE_ERRORS
from engine.blog.serializers import PostSerializer from engine.blog.serializers import PostSerializer
from engine.core.docs.drf import BASE_ERRORS
POST_SCHEMA = { POST_SCHEMA = {
"list": extend_schema( "list": extend_schema(

View file

@ -2,7 +2,11 @@ from django_elasticsearch_dsl import fields
from django_elasticsearch_dsl.registries import registry from django_elasticsearch_dsl.registries import registry
from engine.blog.models import Post from engine.blog.models import Post
from engine.core.elasticsearch import COMMON_ANALYSIS, ActiveOnlyMixin, add_multilang_fields from engine.core.elasticsearch import (
COMMON_ANALYSIS,
ActiveOnlyMixin,
add_multilang_fields,
)
from engine.core.elasticsearch.documents import BaseDocument from engine.core.elasticsearch.documents import BaseDocument
@ -12,7 +16,9 @@ class PostDocument(ActiveOnlyMixin, BaseDocument): # type: ignore [misc]
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="icu_query"), "ngram": fields.TextField(
analyzer="name_ngram", search_analyzer="icu_query"
),
"phonetic": fields.TextField(analyzer="name_phonetic"), "phonetic": fields.TextField(analyzer="name_phonetic"),
}, },
) )

View file

@ -48,13 +48,17 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
( (
"tag_name", "tag_name",
models.CharField( models.CharField(
help_text="internal tag identifier for the post tag", max_length=255, verbose_name="tag name" help_text="internal tag identifier for the post tag",
max_length=255,
verbose_name="tag name",
), ),
), ),
( (
@ -105,17 +109,26 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
("title", models.CharField()), ("title", models.CharField()),
("content", markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content")), (
"content",
markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
),
("file", models.FileField(blank=True, null=True, upload_to="posts/")), ("file", models.FileField(blank=True, null=True, upload_to="posts/")),
("slug", models.SlugField(allow_unicode=True)), ("slug", models.SlugField(allow_unicode=True)),
( (
"author", "author",
models.ForeignKey( models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="posts", to=settings.AUTH_USER_MODEL on_delete=django.db.models.deletion.CASCADE,
related_name="posts",
to=settings.AUTH_USER_MODEL,
), ),
), ),
("tags", models.ManyToManyField(to="blog.posttag")), ("tags", models.ManyToManyField(to="blog.posttag")),

View file

@ -12,12 +12,21 @@ class Migration(migrations.Migration):
model_name="post", model_name="post",
name="slug", name="slug",
field=django_extensions.db.fields.AutoSlugField( field=django_extensions.db.fields.AutoSlugField(
allow_unicode=True, blank=True, editable=False, populate_from="title", unique=True allow_unicode=True,
blank=True,
editable=False,
populate_from="title",
unique=True,
), ),
), ),
migrations.AlterField( migrations.AlterField(
model_name="post", model_name="post",
name="title", name="title",
field=models.CharField(help_text="post title", max_length=128, unique=True, verbose_name="title"), field=models.CharField(
help_text="post title",
max_length=128,
unique=True,
verbose_name="title",
),
), ),
] ]

View file

@ -10,6 +10,8 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name="post", model_name="post",
name="tags", name="tags",
field=models.ManyToManyField(blank=True, related_name="posts", to="blog.posttag"), field=models.ManyToManyField(
blank=True, related_name="posts", to="blog.posttag"
),
), ),
] ]

View file

@ -11,92 +11,128 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_ar_ar", name="content_ar_ar",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_cs_cz", name="content_cs_cz",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_da_dk", name="content_da_dk",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_de_de", name="content_de_de",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_en_gb", name="content_en_gb",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_en_us", name="content_en_us",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_es_es", name="content_es_es",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_fr_fr", name="content_fr_fr",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_hi_in", name="content_hi_in",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_it_it", name="content_it_it",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_ja_jp", name="content_ja_jp",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_kk_kz", name="content_kk_kz",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_nl_nl", name="content_nl_nl",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_pl_pl", name="content_pl_pl",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_pt_br", name="content_pt_br",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_ro_ro", name="content_ro_ro",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_ru_ru", name="content_ru_ru",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_zh_hans", name="content_zh_hans",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",

View file

@ -11,52 +11,72 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_fa_ir", name="content_fa_ir",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_he_il", name="content_he_il",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_hr_hr", name="content_hr_hr",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_id_id", name="content_id_id",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_ko_kr", name="content_ko_kr",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_no_no", name="content_no_no",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_sv_se", name="content_sv_se",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_th_th", name="content_th_th",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_tr_tr", name="content_tr_tr",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",
name="content_vi_vn", name="content_vi_vn",
field=markdown_field.fields.MarkdownField(blank=True, null=True, verbose_name="content"), field=markdown_field.fields.MarkdownField(
blank=True, null=True, verbose_name="content"
),
), ),
migrations.AddField( migrations.AddField(
model_name="post", model_name="post",

View file

@ -1,5 +1,12 @@
from django.conf import settings from django.conf import settings
from django.db.models import CASCADE, CharField, FileField, ForeignKey, ManyToManyField, BooleanField from django.db.models import (
CASCADE,
BooleanField,
CharField,
FileField,
ForeignKey,
ManyToManyField,
)
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_extensions.db.fields import AutoSlugField from django_extensions.db.fields import AutoSlugField
from markdown.extensions.toc import TocExtension from markdown.extensions.toc import TocExtension
@ -19,9 +26,20 @@ class Post(NiceModel): # type: ignore [django-manager-missing]
is_publicly_visible = True is_publicly_visible = True
author = ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=CASCADE, blank=False, null=False, related_name="posts") author = ForeignKey(
to=settings.AUTH_USER_MODEL,
on_delete=CASCADE,
blank=False,
null=False,
related_name="posts",
)
title = CharField( title = CharField(
unique=True, max_length=128, blank=False, null=False, help_text=_("post title"), verbose_name=_("title") unique=True,
max_length=128,
blank=False,
null=False,
help_text=_("post title"),
verbose_name=_("title"),
) )
content: MarkdownField = MarkdownField( content: MarkdownField = MarkdownField(
"content", "content",
@ -61,13 +79,17 @@ class Post(NiceModel): # type: ignore [django-manager-missing]
null=True, null=True,
) )
file = FileField(upload_to="posts/", blank=True, null=True) file = FileField(upload_to="posts/", blank=True, null=True)
slug = AutoSlugField(populate_from="title", allow_unicode=True, unique=True, editable=False) slug = AutoSlugField(
populate_from="title", allow_unicode=True, unique=True, editable=False
)
tags = ManyToManyField(to="blog.PostTag", blank=True, related_name="posts") tags = ManyToManyField(to="blog.PostTag", blank=True, related_name="posts")
meta_description = CharField(max_length=150, blank=True, null=True) meta_description = CharField(max_length=150, blank=True, null=True)
is_static_page = BooleanField( is_static_page = BooleanField(
default=False, default=False,
verbose_name=_("is static page"), verbose_name=_("is static page"),
help_text=_("is this a post for a page with static URL (e.g. `/help/delivery`)?"), help_text=_(
"is this a post for a page with static URL (e.g. `/help/delivery`)?"
),
) )
def __str__(self): def __str__(self):
@ -79,9 +101,15 @@ class Post(NiceModel): # type: ignore [django-manager-missing]
def save(self, **kwargs): def save(self, **kwargs):
if self.file: if self.file:
raise ValueError(_("markdown files are not supported yet - use markdown content instead")) raise ValueError(
_("markdown files are not supported yet - use markdown content instead")
)
if not any([self.file, self.content]) or all([self.file, self.content]): if not any([self.file, self.content]) or all([self.file, self.content]):
raise ValueError(_("a markdown file or markdown content must be provided - mutually exclusive")) raise ValueError(
_(
"a markdown file or markdown content must be provided - mutually exclusive"
)
)
super().save(**kwargs) super().save(**kwargs)

View file

@ -3,10 +3,10 @@ from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema_view from drf_spectacular.utils import extend_schema_view
from rest_framework.viewsets import ReadOnlyModelViewSet from rest_framework.viewsets import ReadOnlyModelViewSet
from engine.blog.docs.drf.viewsets import POST_SCHEMA
from engine.blog.filters import PostFilter from engine.blog.filters import PostFilter
from engine.blog.models import Post from engine.blog.models import Post
from engine.blog.serializers import PostSerializer from engine.blog.serializers import PostSerializer
from engine.blog.docs.drf.viewsets import POST_SCHEMA
from engine.core.permissions import EvibesPermission from engine.core.permissions import EvibesPermission

View file

@ -7,10 +7,20 @@ from django.utils.safestring import mark_safe
class MarkdownEditorWidget(forms.Textarea): class MarkdownEditorWidget(forms.Textarea):
class Media: class Media:
css = {"all": ("https://cdnjs.cloudflare.com/ajax/libs/easymde/2.14.0/easymde.min.css",)} css = {
"all": (
"https://cdnjs.cloudflare.com/ajax/libs/easymde/2.14.0/easymde.min.css",
)
}
js = ("https://cdnjs.cloudflare.com/ajax/libs/easymde/2.14.0/easymde.min.js",) js = ("https://cdnjs.cloudflare.com/ajax/libs/easymde/2.14.0/easymde.min.js",)
def render(self, name: str, value: str, attrs: dict[Any, Any] | None = None, renderer: BaseRenderer | None = None): def render(
self,
name: str,
value: str,
attrs: dict[Any, Any] | None = None,
renderer: BaseRenderer | None = None,
):
if not attrs: if not attrs:
attrs = {} attrs = {}
attrs["class"] = "markdown-editor" attrs["class"] = "markdown-editor"

View file

@ -18,10 +18,16 @@ class NiceModel(Model):
is_active = BooleanField( is_active = BooleanField(
default=True, default=True,
verbose_name=_("is active"), verbose_name=_("is active"),
help_text=_("if set to false, this object can't be seen by users without needed permission"), help_text=_(
"if set to false, this object can't be seen by users without needed permission"
),
) )
created = CreationDateTimeField(_("created"), help_text=_("when the object first appeared on the database")) # type: ignore [no-untyped-call] created = CreationDateTimeField(
modified = ModificationDateTimeField(_("modified"), help_text=_("when the object was last modified")) # type: ignore [no-untyped-call] _("created"), help_text=_("when the object first appeared on the database")
) # type: ignore [no-untyped-call]
modified = ModificationDateTimeField(
_("modified"), help_text=_("when the object was last modified")
) # type: ignore [no-untyped-call]
def save( # type: ignore [override] def save( # type: ignore [override]
self, self,
@ -34,7 +40,10 @@ class NiceModel(Model):
) -> None: ) -> None:
self.update_modified = update_modified self.update_modified = update_modified
return super().save( return super().save(
force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields force_insert=force_insert,
force_update=force_update,
using=using,
update_fields=update_fields,
) )
class Meta: class Meta:

View file

@ -33,7 +33,13 @@ from unfold.contrib.import_export.forms import ExportForm, ImportForm
from unfold.decorators import action from unfold.decorators import action
from unfold.widgets import UnfoldAdminSelectWidget, UnfoldAdminTextInputWidget from unfold.widgets import UnfoldAdminSelectWidget, UnfoldAdminTextInputWidget
from engine.core.forms import CRMForm, OrderForm, OrderProductForm, StockForm, VendorForm from engine.core.forms import (
CRMForm,
OrderForm,
OrderProductForm,
StockForm,
VendorForm,
)
from engine.core.models import ( from engine.core.models import (
Address, Address,
Attribute, Attribute,
@ -64,7 +70,9 @@ class FieldsetsMixin:
additional_fields: list[str] | None = [] additional_fields: list[str] | None = []
model: ClassVar[Type[Model]] model: ClassVar[Type[Model]]
def get_fieldsets(self, request: HttpRequest, obj: Any = None) -> list[tuple[str, dict[str, list[str]]]]: def get_fieldsets(
self, request: HttpRequest, obj: Any = None
) -> list[tuple[str, dict[str, list[str]]]]:
if request: if request:
pass pass
@ -82,15 +90,29 @@ class FieldsetsMixin:
for orig in transoptions.local_fields: for orig in transoptions.local_fields:
translation_fields += get_translation_fields(orig) translation_fields += get_translation_fields(orig)
if translation_fields: if translation_fields:
fss = list(fss) + [(_("translations"), {"classes": ["tab"], "fields": translation_fields})] # type: ignore [list-item] fss = list(fss) + [
(
_("translations"),
{"classes": ["tab"], "fields": translation_fields},
)
] # type: ignore [list-item]
return fss return fss
if self.general_fields: if self.general_fields:
fieldsets.append((_("general"), {"classes": ["tab"], "fields": self.general_fields})) fieldsets.append(
(_("general"), {"classes": ["tab"], "fields": self.general_fields})
)
if self.relation_fields: if self.relation_fields:
fieldsets.append((_("relations"), {"classes": ["tab"], "fields": self.relation_fields})) fieldsets.append(
(_("relations"), {"classes": ["tab"], "fields": self.relation_fields})
)
if self.additional_fields: if self.additional_fields:
fieldsets.append((_("additional info"), {"classes": ["tab"], "fields": self.additional_fields})) fieldsets.append(
(
_("additional info"),
{"classes": ["tab"], "fields": self.additional_fields},
)
)
opts = self.model._meta opts = self.model._meta
meta_fields = [] meta_fields = []
@ -108,7 +130,9 @@ class FieldsetsMixin:
meta_fields.append("human_readable_id") meta_fields.append("human_readable_id")
if meta_fields: if meta_fields:
fieldsets.append((_("metadata"), {"classes": ["tab"], "fields": meta_fields})) fieldsets.append(
(_("metadata"), {"classes": ["tab"], "fields": meta_fields})
)
ts = [] ts = []
for name in ("created", "modified"): for name in ("created", "modified"):
@ -130,23 +154,35 @@ class ActivationActionsMixin:
"deactivate_selected", "deactivate_selected",
] ]
@action(description=_("activate selected %(verbose_name_plural)s").lower(), permissions=["change"]) @action(
description=_("activate selected %(verbose_name_plural)s").lower(),
permissions=["change"],
)
def activate_selected(self, request: HttpRequest, queryset: QuerySet[Any]) -> None: def activate_selected(self, request: HttpRequest, queryset: QuerySet[Any]) -> None:
try: try:
queryset.update(is_active=True) queryset.update(is_active=True)
self.message_user( # type: ignore [attr-defined] self.message_user( # type: ignore [attr-defined]
request=request, message=_("selected items have been activated.").lower(), level=messages.SUCCESS request=request,
message=_("selected items have been activated.").lower(),
level=messages.SUCCESS,
) )
except Exception as e: except Exception as e:
self.message_user(request=request, message=str(e), level=messages.ERROR) # type: ignore [attr-defined] self.message_user(request=request, message=str(e), level=messages.ERROR) # type: ignore [attr-defined]
@action(description=_("deactivate selected %(verbose_name_plural)s").lower(), permissions=["change"]) @action(
def deactivate_selected(self, request: HttpRequest, queryset: QuerySet[Any]) -> None: description=_("deactivate selected %(verbose_name_plural)s").lower(),
permissions=["change"],
)
def deactivate_selected(
self, request: HttpRequest, queryset: QuerySet[Any]
) -> None:
try: try:
queryset.update(is_active=False) queryset.update(is_active=False)
self.message_user( # type: ignore [attr-defined] self.message_user( # type: ignore [attr-defined]
request=request, message=_("selected items have been deactivated.").lower(), level=messages.SUCCESS request=request,
message=_("selected items have been deactivated.").lower(),
level=messages.SUCCESS,
) )
except Exception as e: except Exception as e:
@ -198,7 +234,12 @@ class OrderProductInline(TabularInline): # type: ignore [type-arg]
tab = True tab = True
def get_queryset(self, request): def get_queryset(self, request):
return super().get_queryset(request).select_related("product").only("product__name") return (
super()
.get_queryset(request)
.select_related("product")
.only("product__name")
)
class CategoryChildrenInline(TabularInline): # type: ignore [type-arg] class CategoryChildrenInline(TabularInline): # type: ignore [type-arg]
@ -212,7 +253,9 @@ class CategoryChildrenInline(TabularInline): # type: ignore [type-arg]
@register(AttributeGroup) @register(AttributeGroup)
class AttributeGroupAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type: ignore [misc, type-arg] class AttributeGroupAdmin(
DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin
): # type: ignore [misc, type-arg]
# noinspection PyClassVar # noinspection PyClassVar
model = AttributeGroup # type: ignore [misc] model = AttributeGroup # type: ignore [misc]
list_display = ( list_display = (
@ -236,7 +279,9 @@ class AttributeGroupAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActions
@register(Attribute) @register(Attribute)
class AttributeAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type: ignore [misc, type-arg] class AttributeAdmin(
DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin
): # type: ignore [misc, type-arg]
# noinspection PyClassVar # noinspection PyClassVar
model = Attribute # type: ignore [misc] model = Attribute # type: ignore [misc]
list_display = ( list_display = (
@ -311,7 +356,13 @@ class AttributeValueAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin):
@register(Category) @register(Category)
class CategoryAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, DraggableMPTTAdmin, ModelAdmin): class CategoryAdmin(
DjangoQLSearchMixin,
FieldsetsMixin,
ActivationActionsMixin,
DraggableMPTTAdmin,
ModelAdmin,
):
# noinspection PyClassVar # noinspection PyClassVar
model = Category model = Category
list_display = ( list_display = (
@ -360,7 +411,9 @@ class CategoryAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin,
@register(Brand) @register(Brand)
class BrandAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type: ignore [misc, type-arg] class BrandAdmin(
DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin
): # type: ignore [misc, type-arg]
# noinspection PyClassVar # noinspection PyClassVar
model = Brand # type: ignore [misc] model = Brand # type: ignore [misc]
list_display = ( list_display = (
@ -392,7 +445,13 @@ class BrandAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, Mo
@register(Product) @register(Product)
class ProductAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin, ImportExportModelAdmin): # type: ignore [misc, type-arg] class ProductAdmin(
DjangoQLSearchMixin,
FieldsetsMixin,
ActivationActionsMixin,
ModelAdmin,
ImportExportModelAdmin,
): # type: ignore [misc, type-arg]
# noinspection PyClassVar # noinspection PyClassVar
model = Product # type: ignore [misc] model = Product # type: ignore [misc]
list_display = ( list_display = (
@ -471,7 +530,9 @@ class ProductAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin,
@register(ProductTag) @register(ProductTag)
class ProductTagAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type: ignore [misc, type-arg] class ProductTagAdmin(
DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin
): # type: ignore [misc, type-arg]
# noinspection PyClassVar # noinspection PyClassVar
model = ProductTag # type: ignore [misc] model = ProductTag # type: ignore [misc]
list_display = ("tag_name",) list_display = ("tag_name",)
@ -489,7 +550,9 @@ class ProductTagAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixi
@register(CategoryTag) @register(CategoryTag)
class CategoryTagAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type: ignore [misc, type-arg] class CategoryTagAdmin(
DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin
): # type: ignore [misc, type-arg]
# noinspection PyClassVar # noinspection PyClassVar
model = CategoryTag # type: ignore [misc] model = CategoryTag # type: ignore [misc]
list_display = ( list_display = (
@ -515,7 +578,9 @@ class CategoryTagAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMix
@register(Vendor) @register(Vendor)
class VendorAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type: ignore [misc, type-arg] class VendorAdmin(
DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin
): # type: ignore [misc, type-arg]
# noinspection PyClassVar # noinspection PyClassVar
model = Vendor # type: ignore [misc] model = Vendor # type: ignore [misc]
list_display = ( list_display = (
@ -555,7 +620,9 @@ class VendorAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, M
@register(Feedback) @register(Feedback)
class FeedbackAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type: ignore [misc, type-arg] class FeedbackAdmin(
DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin
): # type: ignore [misc, type-arg]
# noinspection PyClassVar # noinspection PyClassVar
model = Feedback # type: ignore [misc] model = Feedback # type: ignore [misc]
list_display = ( list_display = (
@ -588,7 +655,9 @@ class FeedbackAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin,
@register(Order) @register(Order)
class OrderAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type: ignore [misc, type-arg] class OrderAdmin(
DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin
): # type: ignore [misc, type-arg]
# noinspection PyClassVar # noinspection PyClassVar
model = Order # type: ignore [misc] model = Order # type: ignore [misc]
list_display = ( list_display = (
@ -639,7 +708,9 @@ class OrderAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, Mo
@register(OrderProduct) @register(OrderProduct)
class OrderProductAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type: ignore [misc, type-arg] class OrderProductAdmin(
DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin
): # type: ignore [misc, type-arg]
# noinspection PyClassVar # noinspection PyClassVar
model = OrderProduct # type: ignore [misc] model = OrderProduct # type: ignore [misc]
list_display = ( list_display = (
@ -677,7 +748,9 @@ class OrderProductAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMi
@register(PromoCode) @register(PromoCode)
class PromoCodeAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type: ignore [misc, type-arg] class PromoCodeAdmin(
DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin
): # type: ignore [misc, type-arg]
# noinspection PyClassVar # noinspection PyClassVar
model = PromoCode # type: ignore [misc] model = PromoCode # type: ignore [misc]
list_display = ( list_display = (
@ -721,7 +794,9 @@ class PromoCodeAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin
@register(Promotion) @register(Promotion)
class PromotionAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type: ignore [misc, type-arg] class PromotionAdmin(
DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin
): # type: ignore [misc, type-arg]
# noinspection PyClassVar # noinspection PyClassVar
model = Promotion # type: ignore [misc] model = Promotion # type: ignore [misc]
list_display = ( list_display = (
@ -748,7 +823,9 @@ class PromotionAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin
@register(Stock) @register(Stock)
class StockAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type: ignore [misc, type-arg] class StockAdmin(
DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin
): # type: ignore [misc, type-arg]
# noinspection PyClassVar # noinspection PyClassVar
model = Stock # type: ignore [misc] model = Stock # type: ignore [misc]
form = StockForm form = StockForm
@ -796,7 +873,9 @@ class StockAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, Mo
@register(Wishlist) @register(Wishlist)
class WishlistAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type: ignore [misc, type-arg] class WishlistAdmin(
DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin
): # type: ignore [misc, type-arg]
# noinspection PyClassVar # noinspection PyClassVar
model = Wishlist # type: ignore [misc] model = Wishlist # type: ignore [misc]
list_display = ( list_display = (
@ -822,7 +901,9 @@ class WishlistAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin,
@register(ProductImage) @register(ProductImage)
class ProductImageAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type: ignore [misc, type-arg] class ProductImageAdmin(
DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin
): # type: ignore [misc, type-arg]
# noinspection PyClassVar # noinspection PyClassVar
model = ProductImage # type: ignore [misc] model = ProductImage # type: ignore [misc]
list_display = ( list_display = (
@ -908,7 +989,9 @@ class AddressAdmin(DjangoQLSearchMixin, FieldsetsMixin, GISModelAdmin): # type:
@register(CustomerRelationshipManagementProvider) @register(CustomerRelationshipManagementProvider)
class CustomerRelationshipManagementProviderAdmin(DjangoQLSearchMixin, FieldsetsMixin, ModelAdmin): # type: ignore [misc, type-arg] class CustomerRelationshipManagementProviderAdmin(
DjangoQLSearchMixin, FieldsetsMixin, ModelAdmin
): # type: ignore [misc, type-arg]
# noinspection PyClassVar # noinspection PyClassVar
model = CustomerRelationshipManagementProvider # type: ignore [misc] model = CustomerRelationshipManagementProvider # type: ignore [misc]
list_display = ( list_display = (

View file

@ -9,5 +9,9 @@ app_name = "core"
urlpatterns = [ urlpatterns = [
path("search/", GlobalSearchView.as_view(), name="global_search"), path("search/", GlobalSearchView.as_view(), name="global_search"),
path("orders/buy_as_business/", BuyAsBusinessView.as_view(), name="request_cursed_url"), path(
"orders/buy_as_business/",
BuyAsBusinessView.as_view(),
name="request_cursed_url",
),
] ]

View file

@ -6,7 +6,11 @@ from django.core.cache import cache
from django.db import transaction from django.db import transaction
from engine.core.crm.exceptions import CRMException from engine.core.crm.exceptions import CRMException
from engine.core.models import CustomerRelationshipManagementProvider, Order, OrderCrmLink from engine.core.models import (
CustomerRelationshipManagementProvider,
Order,
OrderCrmLink,
)
from engine.core.utils import is_status_code_success from engine.core.utils import is_status_code_success
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -17,7 +21,9 @@ class AmoCRM:
def __init__(self): def __init__(self):
try: try:
self.instance = CustomerRelationshipManagementProvider.objects.get(name="AmoCRM") self.instance = CustomerRelationshipManagementProvider.objects.get(
name="AmoCRM"
)
except CustomerRelationshipManagementProvider.DoesNotExist as dne: except CustomerRelationshipManagementProvider.DoesNotExist as dne:
logger.warning("AMO CRM provider not found") logger.warning("AMO CRM provider not found")
raise CRMException("AMO CRM provider not found") from dne raise CRMException("AMO CRM provider not found") from dne
@ -70,7 +76,10 @@ class AmoCRM:
return self.access_token return self.access_token
def _headers(self) -> dict: def _headers(self) -> dict:
return {"Authorization": f"Bearer {self._token()}", "Content-Type": "application/json"} return {
"Authorization": f"Bearer {self._token()}",
"Content-Type": "application/json",
}
def _build_lead_payload(self, order: Order) -> dict: def _build_lead_payload(self, order: Order) -> dict:
name = f"Заказ #{order.human_readable_id}" name = f"Заказ #{order.human_readable_id}"
@ -104,7 +113,8 @@ class AmoCRM:
) )
try: try:
r = requests.get( r = requests.get(
f"https://api-fns.ru/api/egr?req={order.business_identificator}&key={self.fns_api_key}", timeout=15 f"https://api-fns.ru/api/egr?req={order.business_identificator}&key={self.fns_api_key}",
timeout=15,
) )
r.raise_for_status() r.raise_for_status()
body = r.json() body = r.json()
@ -129,28 +139,43 @@ class AmoCRM:
if customer_name: if customer_name:
r = requests.get( r = requests.get(
f"{self.base}/api/v4/contacts", f"{self.base}/api/v4/contacts",
headers=self._headers().update({"filter[name]": customer_name, "limit": 1}), headers=self._headers().update(
{"filter[name]": customer_name, "limit": 1}
),
timeout=15, timeout=15,
) )
if r.status_code == 200: if r.status_code == 200:
body = r.json() body = r.json()
return body.get("_embedded", {}).get("contacts", [{}])[0].get("id", None) return (
body.get("_embedded", {})
.get("contacts", [{}])[0]
.get("id", None)
)
create_contact_payload = {"name": customer_name} create_contact_payload = {"name": customer_name}
if self.responsible_user_id: if self.responsible_user_id:
create_contact_payload["responsible_user_id"] = self.responsible_user_id create_contact_payload["responsible_user_id"] = (
self.responsible_user_id
)
if order.user: if order.user:
create_contact_payload["first_name"] = order.user.first_name or "" create_contact_payload["first_name"] = order.user.first_name or ""
create_contact_payload["last_name"] = order.user.last_name or "" create_contact_payload["last_name"] = order.user.last_name or ""
r = requests.post( r = requests.post(
f"{self.base}/api/v4/contacts", json={"name": customer_name}, headers=self._headers(), timeout=15 f"{self.base}/api/v4/contacts",
json={"name": customer_name},
headers=self._headers(),
timeout=15,
) )
if r.status_code == 200: if r.status_code == 200:
body = r.json() body = r.json()
return body.get("_embedded", {}).get("contacts", [{}])[0].get("id", None) return (
body.get("_embedded", {})
.get("contacts", [{}])[0]
.get("id", None)
)
return None return None
@ -173,17 +198,27 @@ class AmoCRM:
lead_id = link.crm_lead_id lead_id = link.crm_lead_id
payload = self._build_lead_payload(order) payload = self._build_lead_payload(order)
r = requests.patch( r = requests.patch(
f"{self.base}/api/v4/leads/{lead_id}", json=payload, headers=self._headers(), timeout=15 f"{self.base}/api/v4/leads/{lead_id}",
json=payload,
headers=self._headers(),
timeout=15,
) )
if r.status_code not in (200, 204): if r.status_code not in (200, 204):
r.raise_for_status() r.raise_for_status()
return lead_id return lead_id
payload = self._build_lead_payload(order) payload = self._build_lead_payload(order)
r = requests.post(f"{self.base}/api/v4/leads", json=[payload], headers=self._headers(), timeout=15) r = requests.post(
f"{self.base}/api/v4/leads",
json=[payload],
headers=self._headers(),
timeout=15,
)
r.raise_for_status() r.raise_for_status()
body = r.json() body = r.json()
lead_id = str(body["_embedded"]["leads"][0]["id"]) lead_id = str(body["_embedded"]["leads"][0]["id"])
OrderCrmLink.objects.create(order=order, crm_lead_id=lead_id, crm=self.instance) OrderCrmLink.objects.create(
order=order, crm_lead_id=lead_id, crm=self.instance
)
return lead_id return lead_id
def update_order_status(self, crm_lead_id: str, new_status: str) -> None: def update_order_status(self, crm_lead_id: str, new_status: str) -> None:

View file

@ -14,7 +14,6 @@ from engine.core.serializers import (
) )
from engine.payments.serializers import TransactionProcessSerializer from engine.payments.serializers import TransactionProcessSerializer
CUSTOM_OPENAPI_SCHEMA = { CUSTOM_OPENAPI_SCHEMA = {
"get": extend_schema( "get": extend_schema(
tags=[ tags=[
@ -48,7 +47,9 @@ CACHE_SCHEMA = {
), ),
request=CacheOperatorSerializer, request=CacheOperatorSerializer,
responses={ responses={
status.HTTP_200_OK: inline_serializer("cache", fields={"data": JSONField()}), status.HTTP_200_OK: inline_serializer(
"cache", fields={"data": JSONField()}
),
status.HTTP_400_BAD_REQUEST: error, status.HTTP_400_BAD_REQUEST: error,
}, },
), ),
@ -72,7 +73,11 @@ PARAMETERS_SCHEMA = {
"misc", "misc",
], ],
summary=_("get application's exposable parameters"), summary=_("get application's exposable parameters"),
responses={status.HTTP_200_OK: inline_serializer("parameters", fields={"key": CharField(default="value")})}, responses={
status.HTTP_200_OK: inline_serializer(
"parameters", fields={"key": CharField(default="value")}
)
},
) )
} }
@ -96,7 +101,9 @@ REQUEST_CURSED_URL_SCHEMA = {
"misc", "misc",
], ],
summary=_("request a CORSed URL"), summary=_("request a CORSed URL"),
request=inline_serializer("url", fields={"url": CharField(default="https://example.org")}), request=inline_serializer(
"url", fields={"url": CharField(default="https://example.org")}
),
responses={ responses={
status.HTTP_200_OK: inline_serializer("data", fields={"data": JSONField()}), status.HTTP_200_OK: inline_serializer("data", fields={"data": JSONField()}),
status.HTTP_400_BAD_REQUEST: error, status.HTTP_400_BAD_REQUEST: error,
@ -121,7 +128,11 @@ SEARCH_SCHEMA = {
responses={ responses={
status.HTTP_200_OK: inline_serializer( status.HTTP_200_OK: inline_serializer(
name="GlobalSearchResponse", name="GlobalSearchResponse",
fields={"results": DictField(child=ListField(child=DictField(child=CharField())))}, fields={
"results": DictField(
child=ListField(child=DictField(child=CharField()))
)
},
), ),
status.HTTP_400_BAD_REQUEST: inline_serializer( status.HTTP_400_BAD_REQUEST: inline_serializer(
name="GlobalSearchErrorResponse", fields={"error": CharField()} name="GlobalSearchErrorResponse", fields={"error": CharField()}
@ -143,7 +154,9 @@ BUY_AS_BUSINESS_SCHEMA = {
status.HTTP_400_BAD_REQUEST: error, status.HTTP_400_BAD_REQUEST: error,
}, },
description=( description=(
_("purchase an order as a business, using the provided `products` with `product_uuid` and `attributes`.") _(
"purchase an order as a business, using the provided `products` with `product_uuid` and `attributes`."
)
), ),
) )
} }

View file

@ -50,7 +50,11 @@ from engine.core.serializers import (
WishlistSimpleSerializer, WishlistSimpleSerializer,
) )
from engine.core.serializers.seo import SeoSnapshotSerializer from engine.core.serializers.seo import SeoSnapshotSerializer
from engine.core.serializers.utility import AddressCreateSerializer, AddressSuggestionSerializer, DoFeedbackSerializer from engine.core.serializers.utility import (
AddressCreateSerializer,
AddressSuggestionSerializer,
DoFeedbackSerializer,
)
from engine.payments.serializers import TransactionProcessSerializer from engine.payments.serializers import TransactionProcessSerializer
ATTRIBUTE_GROUP_SCHEMA = { ATTRIBUTE_GROUP_SCHEMA = {
@ -59,7 +63,10 @@ ATTRIBUTE_GROUP_SCHEMA = {
"attributeGroups", "attributeGroups",
], ],
summary=_("list all attribute groups (simple view)"), summary=_("list all attribute groups (simple view)"),
responses={status.HTTP_200_OK: AttributeGroupSimpleSerializer(many=True), **BASE_ERRORS}, responses={
status.HTTP_200_OK: AttributeGroupSimpleSerializer(many=True),
**BASE_ERRORS,
},
), ),
"retrieve": extend_schema( "retrieve": extend_schema(
tags=[ tags=[
@ -73,7 +80,10 @@ ATTRIBUTE_GROUP_SCHEMA = {
"attributeGroups", "attributeGroups",
], ],
summary=_("create an attribute group"), summary=_("create an attribute group"),
responses={status.HTTP_201_CREATED: AttributeGroupDetailSerializer(), **BASE_ERRORS}, responses={
status.HTTP_201_CREATED: AttributeGroupDetailSerializer(),
**BASE_ERRORS,
},
), ),
"destroy": extend_schema( "destroy": extend_schema(
tags=[ tags=[
@ -93,7 +103,9 @@ ATTRIBUTE_GROUP_SCHEMA = {
tags=[ tags=[
"attributeGroups", "attributeGroups",
], ],
summary=_("rewrite some fields of an existing attribute group saving non-editables"), summary=_(
"rewrite some fields of an existing attribute group saving non-editables"
),
responses={status.HTTP_200_OK: AttributeGroupDetailSerializer(), **BASE_ERRORS}, responses={status.HTTP_200_OK: AttributeGroupDetailSerializer(), **BASE_ERRORS},
), ),
} }
@ -104,7 +116,10 @@ ATTRIBUTE_SCHEMA = {
"attributes", "attributes",
], ],
summary=_("list all attributes (simple view)"), summary=_("list all attributes (simple view)"),
responses={status.HTTP_200_OK: AttributeSimpleSerializer(many=True), **BASE_ERRORS}, responses={
status.HTTP_200_OK: AttributeSimpleSerializer(many=True),
**BASE_ERRORS,
},
), ),
"retrieve": extend_schema( "retrieve": extend_schema(
tags=[ tags=[
@ -149,7 +164,10 @@ ATTRIBUTE_VALUE_SCHEMA = {
"attributeValues", "attributeValues",
], ],
summary=_("list all attribute values (simple view)"), summary=_("list all attribute values (simple view)"),
responses={status.HTTP_200_OK: AttributeValueSimpleSerializer(many=True), **BASE_ERRORS}, responses={
status.HTTP_200_OK: AttributeValueSimpleSerializer(many=True),
**BASE_ERRORS,
},
), ),
"retrieve": extend_schema( "retrieve": extend_schema(
tags=[ tags=[
@ -163,7 +181,10 @@ ATTRIBUTE_VALUE_SCHEMA = {
"attributeValues", "attributeValues",
], ],
summary=_("create an attribute value"), summary=_("create an attribute value"),
responses={status.HTTP_201_CREATED: AttributeValueDetailSerializer(), **BASE_ERRORS}, responses={
status.HTTP_201_CREATED: AttributeValueDetailSerializer(),
**BASE_ERRORS,
},
), ),
"destroy": extend_schema( "destroy": extend_schema(
tags=[ tags=[
@ -183,7 +204,9 @@ ATTRIBUTE_VALUE_SCHEMA = {
tags=[ tags=[
"attributeValues", "attributeValues",
], ],
summary=_("rewrite some fields of an existing attribute value saving non-editables"), summary=_(
"rewrite some fields of an existing attribute value saving non-editables"
),
responses={status.HTTP_200_OK: AttributeValueDetailSerializer(), **BASE_ERRORS}, responses={status.HTTP_200_OK: AttributeValueDetailSerializer(), **BASE_ERRORS},
), ),
} }
@ -195,7 +218,10 @@ CATEGORY_SCHEMA = {
], ],
summary=_("list all categories (simple view)"), summary=_("list all categories (simple view)"),
description=_("list all categories (simple view)"), description=_("list all categories (simple view)"),
responses={status.HTTP_200_OK: CategorySimpleSerializer(many=True), **BASE_ERRORS}, responses={
status.HTTP_200_OK: CategorySimpleSerializer(many=True),
**BASE_ERRORS,
},
), ),
"retrieve": extend_schema( "retrieve": extend_schema(
tags=[ tags=[
@ -242,7 +268,9 @@ CATEGORY_SCHEMA = {
"categories", "categories",
], ],
summary=_("rewrite some fields of an existing category saving non-editables"), summary=_("rewrite some fields of an existing category saving non-editables"),
description=_("rewrite some fields of an existing category saving non-editables"), description=_(
"rewrite some fields of an existing category saving non-editables"
),
responses={status.HTTP_200_OK: CategoryDetailSerializer(), **BASE_ERRORS}, responses={status.HTTP_200_OK: CategoryDetailSerializer(), **BASE_ERRORS},
), ),
"seo_meta": extend_schema( "seo_meta": extend_schema(
@ -315,7 +343,9 @@ ORDER_SCHEMA = {
OpenApiParameter( OpenApiParameter(
name="status", name="status",
type=OpenApiTypes.STR, type=OpenApiTypes.STR,
description=_("Filter by order status (case-insensitive substring match)"), description=_(
"Filter by order status (case-insensitive substring match)"
),
), ),
OpenApiParameter( OpenApiParameter(
name="order_by", name="order_by",
@ -418,7 +448,9 @@ ORDER_SCHEMA = {
"orders", "orders",
], ],
summary=_("add product to order"), summary=_("add product to order"),
description=_("adds a product to an order using the provided `product_uuid` and `attributes`."), description=_(
"adds a product to an order using the provided `product_uuid` and `attributes`."
),
request=AddOrderProductSerializer(), request=AddOrderProductSerializer(),
responses={status.HTTP_200_OK: OrderDetailSerializer(), **BASE_ERRORS}, responses={status.HTTP_200_OK: OrderDetailSerializer(), **BASE_ERRORS},
), ),
@ -427,7 +459,9 @@ ORDER_SCHEMA = {
"orders", "orders",
], ],
summary=_("add a list of products to order, quantities will not count"), summary=_("add a list of products to order, quantities will not count"),
description=_("adds a list of products to an order using the provided `product_uuid` and `attributes`."), description=_(
"adds a list of products to an order using the provided `product_uuid` and `attributes`."
),
request=BulkAddOrderProductsSerializer(), request=BulkAddOrderProductsSerializer(),
responses={status.HTTP_200_OK: OrderDetailSerializer(), **BASE_ERRORS}, responses={status.HTTP_200_OK: OrderDetailSerializer(), **BASE_ERRORS},
), ),
@ -436,7 +470,9 @@ ORDER_SCHEMA = {
"orders", "orders",
], ],
summary=_("remove product from order"), summary=_("remove product from order"),
description=_("removes a product from an order using the provided `product_uuid` and `attributes`."), description=_(
"removes a product from an order using the provided `product_uuid` and `attributes`."
),
request=RemoveOrderProductSerializer(), request=RemoveOrderProductSerializer(),
responses={status.HTTP_200_OK: OrderDetailSerializer(), **BASE_ERRORS}, responses={status.HTTP_200_OK: OrderDetailSerializer(), **BASE_ERRORS},
), ),
@ -445,7 +481,9 @@ ORDER_SCHEMA = {
"orders", "orders",
], ],
summary=_("remove product from order, quantities will not count"), summary=_("remove product from order, quantities will not count"),
description=_("removes a list of products from an order using the provided `product_uuid` and `attributes`"), description=_(
"removes a list of products from an order using the provided `product_uuid` and `attributes`"
),
request=BulkRemoveOrderProductsSerializer(), request=BulkRemoveOrderProductsSerializer(),
responses={status.HTTP_200_OK: OrderDetailSerializer(), **BASE_ERRORS}, responses={status.HTTP_200_OK: OrderDetailSerializer(), **BASE_ERRORS},
), ),
@ -458,7 +496,10 @@ WISHLIST_SCHEMA = {
], ],
summary=_("list all wishlists (simple view)"), summary=_("list all wishlists (simple view)"),
description=_("for non-staff users, only their own wishlists are returned."), description=_("for non-staff users, only their own wishlists are returned."),
responses={status.HTTP_200_OK: WishlistSimpleSerializer(many=True), **BASE_ERRORS}, responses={
status.HTTP_200_OK: WishlistSimpleSerializer(many=True),
**BASE_ERRORS,
},
), ),
"retrieve": extend_schema( "retrieve": extend_schema(
tags=[ tags=[
@ -512,7 +553,9 @@ WISHLIST_SCHEMA = {
"wishlists", "wishlists",
], ],
summary=_("add product to wishlist"), summary=_("add product to wishlist"),
description=_("adds a product to an wishlist using the provided `product_uuid`"), description=_(
"adds a product to an wishlist using the provided `product_uuid`"
),
request=AddWishlistProductSerializer(), request=AddWishlistProductSerializer(),
responses={status.HTTP_200_OK: WishlistDetailSerializer(), **BASE_ERRORS}, responses={status.HTTP_200_OK: WishlistDetailSerializer(), **BASE_ERRORS},
), ),
@ -521,7 +564,9 @@ WISHLIST_SCHEMA = {
"wishlists", "wishlists",
], ],
summary=_("remove product from wishlist"), summary=_("remove product from wishlist"),
description=_("removes a product from an wishlist using the provided `product_uuid`"), description=_(
"removes a product from an wishlist using the provided `product_uuid`"
),
request=RemoveWishlistProductSerializer(), request=RemoveWishlistProductSerializer(),
responses={status.HTTP_200_OK: WishlistDetailSerializer(), **BASE_ERRORS}, responses={status.HTTP_200_OK: WishlistDetailSerializer(), **BASE_ERRORS},
), ),
@ -530,7 +575,9 @@ WISHLIST_SCHEMA = {
"wishlists", "wishlists",
], ],
summary=_("add many products to wishlist"), summary=_("add many products to wishlist"),
description=_("adds many products to an wishlist using the provided `product_uuids`"), description=_(
"adds many products to an wishlist using the provided `product_uuids`"
),
request=BulkAddWishlistProductSerializer(), request=BulkAddWishlistProductSerializer(),
responses={status.HTTP_200_OK: WishlistDetailSerializer(), **BASE_ERRORS}, responses={status.HTTP_200_OK: WishlistDetailSerializer(), **BASE_ERRORS},
), ),
@ -539,7 +586,9 @@ WISHLIST_SCHEMA = {
"wishlists", "wishlists",
], ],
summary=_("remove many products from wishlist"), summary=_("remove many products from wishlist"),
description=_("removes many products from an wishlist using the provided `product_uuids`"), description=_(
"removes many products from an wishlist using the provided `product_uuids`"
),
request=BulkRemoveWishlistProductSerializer(), request=BulkRemoveWishlistProductSerializer(),
responses={status.HTTP_200_OK: WishlistDetailSerializer(), **BASE_ERRORS}, responses={status.HTTP_200_OK: WishlistDetailSerializer(), **BASE_ERRORS},
), ),
@ -644,8 +693,12 @@ PRODUCT_SCHEMA = {
tags=[ tags=[
"products", "products",
], ],
summary=_("update some fields of an existing product, preserving non-editable fields"), summary=_(
description=_("update some fields of an existing product, preserving non-editable fields"), "update some fields of an existing product, preserving non-editable fields"
),
description=_(
"update some fields of an existing product, preserving non-editable fields"
),
parameters=[ parameters=[
OpenApiParameter( OpenApiParameter(
name="lookup_value", name="lookup_value",
@ -791,7 +844,9 @@ ADDRESS_SCHEMA = {
OpenApiParameter( OpenApiParameter(
name="q", name="q",
location="query", location="query",
description=_("raw data query string, please append with data from geo-IP endpoint"), description=_(
"raw data query string, please append with data from geo-IP endpoint"
),
type=str, type=str,
), ),
OpenApiParameter( OpenApiParameter(
@ -814,7 +869,10 @@ FEEDBACK_SCHEMA = {
"feedbacks", "feedbacks",
], ],
summary=_("list all feedbacks (simple view)"), summary=_("list all feedbacks (simple view)"),
responses={status.HTTP_200_OK: FeedbackSimpleSerializer(many=True), **BASE_ERRORS}, responses={
status.HTTP_200_OK: FeedbackSimpleSerializer(many=True),
**BASE_ERRORS,
},
), ),
"retrieve": extend_schema( "retrieve": extend_schema(
tags=[ tags=[
@ -1004,7 +1062,10 @@ VENDOR_SCHEMA = {
"vendors", "vendors",
], ],
summary=_("list all vendors (simple view)"), summary=_("list all vendors (simple view)"),
responses={status.HTTP_200_OK: VendorSimpleSerializer(many=True), **BASE_ERRORS}, responses={
status.HTTP_200_OK: VendorSimpleSerializer(many=True),
**BASE_ERRORS,
},
), ),
"retrieve": extend_schema( "retrieve": extend_schema(
tags=[ tags=[
@ -1049,7 +1110,10 @@ PRODUCT_IMAGE_SCHEMA = {
"productImages", "productImages",
], ],
summary=_("list all product images (simple view)"), summary=_("list all product images (simple view)"),
responses={status.HTTP_200_OK: ProductImageSimpleSerializer(many=True), **BASE_ERRORS}, responses={
status.HTTP_200_OK: ProductImageSimpleSerializer(many=True),
**BASE_ERRORS,
},
), ),
"retrieve": extend_schema( "retrieve": extend_schema(
tags=[ tags=[
@ -1063,7 +1127,10 @@ PRODUCT_IMAGE_SCHEMA = {
"productImages", "productImages",
], ],
summary=_("create a product image"), summary=_("create a product image"),
responses={status.HTTP_201_CREATED: ProductImageDetailSerializer(), **BASE_ERRORS}, responses={
status.HTTP_201_CREATED: ProductImageDetailSerializer(),
**BASE_ERRORS,
},
), ),
"destroy": extend_schema( "destroy": extend_schema(
tags=[ tags=[
@ -1083,7 +1150,9 @@ PRODUCT_IMAGE_SCHEMA = {
tags=[ tags=[
"productImages", "productImages",
], ],
summary=_("rewrite some fields of an existing product image saving non-editables"), summary=_(
"rewrite some fields of an existing product image saving non-editables"
),
responses={status.HTTP_200_OK: ProductImageDetailSerializer(), **BASE_ERRORS}, responses={status.HTTP_200_OK: ProductImageDetailSerializer(), **BASE_ERRORS},
), ),
} }
@ -1094,7 +1163,10 @@ PROMOCODE_SCHEMA = {
"promocodes", "promocodes",
], ],
summary=_("list all promo codes (simple view)"), summary=_("list all promo codes (simple view)"),
responses={status.HTTP_200_OK: PromoCodeSimpleSerializer(many=True), **BASE_ERRORS}, responses={
status.HTTP_200_OK: PromoCodeSimpleSerializer(many=True),
**BASE_ERRORS,
},
), ),
"retrieve": extend_schema( "retrieve": extend_schema(
tags=[ tags=[
@ -1139,7 +1211,10 @@ PROMOTION_SCHEMA = {
"promotions", "promotions",
], ],
summary=_("list all promotions (simple view)"), summary=_("list all promotions (simple view)"),
responses={status.HTTP_200_OK: PromotionSimpleSerializer(many=True), **BASE_ERRORS}, responses={
status.HTTP_200_OK: PromotionSimpleSerializer(many=True),
**BASE_ERRORS,
},
), ),
"retrieve": extend_schema( "retrieve": extend_schema(
tags=[ tags=[
@ -1218,7 +1293,9 @@ STOCK_SCHEMA = {
tags=[ tags=[
"stocks", "stocks",
], ],
summary=_("rewrite some fields of an existing stock record saving non-editables"), summary=_(
"rewrite some fields of an existing stock record saving non-editables"
),
responses={status.HTTP_200_OK: StockDetailSerializer(), **BASE_ERRORS}, responses={status.HTTP_200_OK: StockDetailSerializer(), **BASE_ERRORS},
), ),
} }
@ -1229,7 +1306,10 @@ PRODUCT_TAG_SCHEMA = {
"productTags", "productTags",
], ],
summary=_("list all product tags (simple view)"), summary=_("list all product tags (simple view)"),
responses={status.HTTP_200_OK: ProductTagSimpleSerializer(many=True), **BASE_ERRORS}, responses={
status.HTTP_200_OK: ProductTagSimpleSerializer(many=True),
**BASE_ERRORS,
},
), ),
"retrieve": extend_schema( "retrieve": extend_schema(
tags=[ tags=[
@ -1243,7 +1323,10 @@ PRODUCT_TAG_SCHEMA = {
"productTags", "productTags",
], ],
summary=_("create a product tag"), summary=_("create a product tag"),
responses={status.HTTP_201_CREATED: ProductTagDetailSerializer(), **BASE_ERRORS}, responses={
status.HTTP_201_CREATED: ProductTagDetailSerializer(),
**BASE_ERRORS,
},
), ),
"destroy": extend_schema( "destroy": extend_schema(
tags=[ tags=[
@ -1263,7 +1346,9 @@ PRODUCT_TAG_SCHEMA = {
tags=[ tags=[
"productTags", "productTags",
], ],
summary=_("rewrite some fields of an existing product tag saving non-editables"), summary=_(
"rewrite some fields of an existing product tag saving non-editables"
),
responses={status.HTTP_200_OK: ProductTagDetailSerializer(), **BASE_ERRORS}, responses={status.HTTP_200_OK: ProductTagDetailSerializer(), **BASE_ERRORS},
), ),
} }

View file

@ -1,7 +1,6 @@
import re import re
from typing import Any from typing import Any, Callable
from typing import Callable
from django.conf import settings from django.conf import settings
from django.db.models import QuerySet from django.db.models import QuerySet
from django.http import Http404 from django.http import Http404
@ -86,7 +85,13 @@ functions = [
"weight": 0.3, "weight": 0.3,
}, },
{ {
"filter": Q("bool", must=[Q("term", **{"_index": "products"}), Q("term", **{"personal_orders_only": False})]), "filter": Q(
"bool",
must=[
Q("term", **{"_index": "products"}),
Q("term", **{"personal_orders_only": False}),
],
),
"weight": 0.7, "weight": 0.7,
}, },
{ {
@ -176,9 +181,15 @@ def process_query(
if is_code_like: if is_code_like:
text_shoulds.extend( text_shoulds.extend(
[ [
Q("term", **{"partnumber.raw": {"value": query.lower(), "boost": 14.0}}), Q(
"term",
**{"partnumber.raw": {"value": query.lower(), "boost": 14.0}},
),
Q("term", **{"sku.raw": {"value": query.lower(), "boost": 12.0}}), Q("term", **{"sku.raw": {"value": query.lower(), "boost": 12.0}}),
Q("prefix", **{"partnumber.raw": {"value": query.lower(), "boost": 4.0}}), Q(
"prefix",
**{"partnumber.raw": {"value": query.lower(), "boost": 4.0}},
),
] ]
) )
@ -227,14 +238,25 @@ def process_query(
search_products = build_search(["products"], size=33) search_products = build_search(["products"], size=33)
resp_products = search_products.execute() resp_products = search_products.execute()
results: dict[str, list[dict[str, Any]]] = {"products": [], "categories": [], "brands": [], "posts": []} results: dict[str, list[dict[str, Any]]] = {
uuids_by_index: dict[str, list[str]] = {"products": [], "categories": [], "brands": []} "products": [],
"categories": [],
"brands": [],
"posts": [],
}
uuids_by_index: dict[str, list[str]] = {
"products": [],
"categories": [],
"brands": [],
}
hit_cache: list[Any] = [] hit_cache: list[Any] = []
seen_keys: set[tuple[str, str]] = set() seen_keys: set[tuple[str, str]] = set()
def _hit_key(hittee: Any) -> tuple[str, str]: def _hit_key(hittee: Any) -> tuple[str, str]:
return hittee.meta.index, str(getattr(hittee, "uuid", None) or hittee.meta.id) return hittee.meta.index, str(
getattr(hittee, "uuid", None) or hittee.meta.id
)
def _collect_hits(hits: list[Any]) -> None: def _collect_hits(hits: list[Any]) -> None:
for hh in hits: for hh in hits:
@ -267,7 +289,12 @@ def process_query(
] ]
for qx in product_exact_sequence: for qx in product_exact_sequence:
try: try:
resp_exact = Search(index=["products"]).query(qx).extra(size=5, track_total_hits=False).execute() resp_exact = (
Search(index=["products"])
.query(qx)
.extra(size=5, track_total_hits=False)
.execute()
)
except NotFoundError: except NotFoundError:
resp_exact = None resp_exact = None
if resp_exact is not None and getattr(resp_exact, "hits", None): if resp_exact is not None and getattr(resp_exact, "hits", None):
@ -314,13 +341,23 @@ def process_query(
.prefetch_related("images") .prefetch_related("images")
} }
if uuids_by_index.get("brands"): 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"])} 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"): 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"])} cats_by_uuid = {
str(c.uuid): c
for c in Category.objects.filter(
uuid__in=uuids_by_index["categories"]
)
}
for hit in hit_cache: 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 = getattr(hit, "slug", "") or ( obj_slug = getattr(hit, "slug", "") or (
slugify(obj_name) if hit.meta.index in {"brands", "categories"} else "" slugify(obj_name) if hit.meta.index in {"brands", "categories"} else ""
) )
@ -353,8 +390,12 @@ def process_query(
if idx == "products": if idx == "products":
hit_result["rating_debug"] = getattr(hit, "rating", 0) hit_result["rating_debug"] = getattr(hit, "rating", 0)
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_result["category_priority_debug"] = getattr(hit, "category_priority", 0) hit, "brand_priority", 0
)
hit_result["category_priority_debug"] = getattr(
hit, "category_priority", 0
)
if idx in ("brands", "categories"): if idx in ("brands", "categories"):
hit_result["priority_debug"] = getattr(hit, "priority", 0) hit_result["priority_debug"] = getattr(hit, "priority", 0)
@ -402,14 +443,22 @@ class ActiveOnlyMixin:
COMMON_ANALYSIS = { COMMON_ANALYSIS = {
"char_filter": { "char_filter": {
"icu_nfkc_cf": {"type": "icu_normalizer", "name": "nfkc_cf"}, "icu_nfkc_cf": {"type": "icu_normalizer", "name": "nfkc_cf"},
"strip_ws_punct": {"type": "pattern_replace", "pattern": "[\\s\\p{Punct}]+", "replacement": ""}, "strip_ws_punct": {
"type": "pattern_replace",
"pattern": "[\\s\\p{Punct}]+",
"replacement": "",
},
}, },
"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},
"cjk_bigram": {"type": "cjk_bigram"}, "cjk_bigram": {"type": "cjk_bigram"},
"icu_folding": {"type": "icu_folding"}, "icu_folding": {"type": "icu_folding"},
"double_metaphone": {"type": "phonetic", "encoder": "double_metaphone", "replace": False}, "double_metaphone": {
"type": "phonetic",
"encoder": "double_metaphone",
"replace": False,
},
"arabic_norm": {"type": "arabic_normalization"}, "arabic_norm": {"type": "arabic_normalization"},
"indic_norm": {"type": "indic_normalization"}, "indic_norm": {"type": "indic_normalization"},
"icu_any_latin": {"type": "icu_transform", "id": "Any-Latin"}, "icu_any_latin": {"type": "icu_transform", "id": "Any-Latin"},
@ -520,9 +569,13 @@ def add_multilang_fields(cls: Any) -> None:
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="icu_query"), "ngram": fields.TextField(
analyzer="name_ngram", search_analyzer="icu_query"
),
"phonetic": fields.TextField(analyzer="name_phonetic"), "phonetic": fields.TextField(analyzer="name_phonetic"),
"translit": fields.TextField(analyzer="translit_index", search_analyzer="translit_query"), "translit": fields.TextField(
analyzer="translit_index", search_analyzer="translit_query"
),
}, },
), ),
) )
@ -542,9 +595,13 @@ def add_multilang_fields(cls: Any) -> None:
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="icu_query"), "ngram": fields.TextField(
analyzer="name_ngram", search_analyzer="icu_query"
),
"phonetic": fields.TextField(analyzer="name_phonetic"), "phonetic": fields.TextField(analyzer="name_phonetic"),
"translit": fields.TextField(analyzer="translit_index", search_analyzer="translit_query"), "translit": fields.TextField(
analyzer="translit_index", search_analyzer="translit_query"
),
}, },
), ),
) )
@ -582,7 +639,9 @@ def process_system_query(
if is_cjk or is_rtl_or_indic: if is_cjk or is_rtl_or_indic:
fields_all = [f for f in fields_all if ".phonetic" not in f] fields_all = [f for f in fields_all if ".phonetic" not in f]
fields_all = [ fields_all = [
f.replace("ngram^6", "ngram^8").replace("ngram^5", "ngram^7").replace("ngram^3", "ngram^4") f.replace("ngram^6", "ngram^8")
.replace("ngram^5", "ngram^7")
.replace("ngram^3", "ngram^4")
for f in fields_all for f in fields_all
] ]
@ -601,7 +660,11 @@ def process_system_query(
results: dict[str, list[dict[str, Any]]] = {idx: [] for idx in indexes} results: dict[str, list[dict[str, Any]]] = {idx: [] for idx in indexes}
for idx in indexes: for idx in indexes:
s = Search(index=[idx]).query(mm).extra(size=size_per_index, track_total_hits=False) s = (
Search(index=[idx])
.query(mm)
.extra(size=size_per_index, track_total_hits=False)
)
resp = s.execute() resp = s.execute()
for h in resp.hits: for h in resp.hits:
name = getattr(h, "name", None) or getattr(h, "title", None) or "N/A" name = getattr(h, "name", None) or getattr(h, "title", None) or "N/A"

View file

@ -5,7 +5,11 @@ 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 engine.core.elasticsearch import COMMON_ANALYSIS, ActiveOnlyMixin, add_multilang_fields from engine.core.elasticsearch import (
COMMON_ANALYSIS,
ActiveOnlyMixin,
add_multilang_fields,
)
from engine.core.models import Brand, Category, Product from engine.core.models import Brand, Category, Product
@ -15,10 +19,16 @@ class BaseDocument(Document): # type: ignore [misc]
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="icu_query"), "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(
"translit": fields.TextField(analyzer="translit_index", search_analyzer="translit_query"), analyzer="autocomplete", search_analyzer="autocomplete_search"
),
"translit": fields.TextField(
analyzer="translit_index", search_analyzer="translit_query"
),
"ci": fields.TextField(analyzer="name_exact", search_analyzer="name_exact"), "ci": fields.TextField(analyzer="name_exact", search_analyzer="name_exact"),
}, },
) )
@ -27,10 +37,16 @@ class BaseDocument(Document): # type: ignore [misc]
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="icu_query"), "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(
"translit": fields.TextField(analyzer="translit_index", search_analyzer="translit_query"), analyzer="autocomplete", search_analyzer="autocomplete_search"
),
"translit": fields.TextField(
analyzer="translit_index", search_analyzer="translit_query"
),
}, },
) )
slug = fields.KeywordField(attr="slug") slug = fields.KeywordField(attr="slug")
@ -70,10 +86,16 @@ class ProductDocument(ActiveOnlyMixin, BaseDocument):
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="icu_query"), "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(
"translit": fields.TextField(analyzer="translit_index", search_analyzer="translit_query"), analyzer="autocomplete", search_analyzer="autocomplete_search"
),
"translit": fields.TextField(
analyzer="translit_index", search_analyzer="translit_query"
),
}, },
) )
category_name = fields.TextField( category_name = fields.TextField(
@ -81,10 +103,16 @@ class ProductDocument(ActiveOnlyMixin, BaseDocument):
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="icu_query"), "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(
"translit": fields.TextField(analyzer="translit_index", search_analyzer="translit_query"), analyzer="autocomplete", search_analyzer="autocomplete_search"
),
"translit": fields.TextField(
analyzer="translit_index", search_analyzer="translit_query"
),
}, },
) )
@ -93,8 +121,12 @@ class ProductDocument(ActiveOnlyMixin, BaseDocument):
normalizer="lc_norm", normalizer="lc_norm",
fields={ fields={
"raw": fields.KeywordField(normalizer="lc_norm"), "raw": fields.KeywordField(normalizer="lc_norm"),
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"), "ngram": fields.TextField(
"auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"), analyzer="name_ngram", search_analyzer="icu_query"
),
"auto": fields.TextField(
analyzer="autocomplete", search_analyzer="autocomplete_search"
),
}, },
) )
@ -103,8 +135,12 @@ class ProductDocument(ActiveOnlyMixin, BaseDocument):
normalizer="lc_norm", normalizer="lc_norm",
fields={ fields={
"raw": fields.KeywordField(normalizer="lc_norm"), "raw": fields.KeywordField(normalizer="lc_norm"),
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"), "ngram": fields.TextField(
"auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"), analyzer="name_ngram", search_analyzer="icu_query"
),
"auto": fields.TextField(
analyzer="autocomplete", search_analyzer="autocomplete_search"
),
}, },
) )

View file

@ -37,7 +37,16 @@ from graphene import Context
from rest_framework.request import Request from rest_framework.request import Request
from engine.core.elasticsearch import process_query from engine.core.elasticsearch import process_query
from engine.core.models import Address, Brand, Category, Feedback, Order, Product, Stock, Wishlist from engine.core.models import (
Address,
Brand,
Category,
Feedback,
Order,
Product,
Stock,
Wishlist,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -69,19 +78,31 @@ class ProductFilter(FilterSet): # type: ignore [misc]
search = CharFilter(field_name="name", method="search_products", label=_("Search")) search = CharFilter(field_name="name", method="search_products", label=_("Search"))
uuid = UUIDFilter(field_name="uuid", lookup_expr="exact", label=_("UUID")) uuid = UUIDFilter(field_name="uuid", lookup_expr="exact", label=_("UUID"))
name = CharFilter(lookup_expr="icontains", label=_("Name")) name = CharFilter(lookup_expr="icontains", label=_("Name"))
categories = CaseInsensitiveListFilter(field_name="category__name", label=_("Categories")) categories = CaseInsensitiveListFilter(
field_name="category__name", label=_("Categories")
)
category_uuid = CharFilter(method="filter_category", label="Category (UUID)") category_uuid = CharFilter(method="filter_category", label="Category (UUID)")
categories_slugs = CaseInsensitiveListFilter(field_name="category__slug", label=_("Categories Slugs")) categories_slugs = CaseInsensitiveListFilter(
field_name="category__slug", label=_("Categories Slugs")
)
tags = CaseInsensitiveListFilter(field_name="tags__tag_name", label=_("Tags")) tags = CaseInsensitiveListFilter(field_name="tags__tag_name", label=_("Tags"))
min_price = NumberFilter(field_name="stocks__price", lookup_expr="gte", label=_("Min Price")) min_price = NumberFilter(
max_price = NumberFilter(field_name="stocks__price", lookup_expr="lte", label=_("Max Price")) field_name="stocks__price", lookup_expr="gte", label=_("Min Price")
)
max_price = NumberFilter(
field_name="stocks__price", lookup_expr="lte", label=_("Max Price")
)
is_active = BooleanFilter(field_name="is_active", label=_("Is Active")) is_active = BooleanFilter(field_name="is_active", label=_("Is Active"))
brand = CharFilter(field_name="brand__name", lookup_expr="iexact", label=_("Brand")) brand = CharFilter(field_name="brand__name", lookup_expr="iexact", label=_("Brand"))
attributes = CharFilter(method="filter_attributes", label=_("Attributes")) attributes = CharFilter(method="filter_attributes", label=_("Attributes"))
quantity = NumberFilter(field_name="stocks__quantity", lookup_expr="gt", label=_("Quantity")) quantity = NumberFilter(
field_name="stocks__quantity", lookup_expr="gt", label=_("Quantity")
)
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( include_personal_ordered = BooleanFilter(
method="filter_include_personal_ordered", method="filter_include_personal_ordered",
label=_("Include personal ordered"), label=_("Include personal ordered"),
@ -161,7 +182,9 @@ class ProductFilter(FilterSet): # type: ignore [misc]
) )
) )
def search_products(self, queryset: QuerySet[Product], name: str, value: str) -> QuerySet[Product]: def search_products(
self, queryset: QuerySet[Product], name: str, value: str
) -> QuerySet[Product]:
if not value: if not value:
return queryset return queryset
@ -173,23 +196,35 @@ class ProductFilter(FilterSet): # type: ignore [misc]
# Preserve ES order using a CASE expression # Preserve ES order using a CASE expression
when_statements = [When(uuid=u, then=pos) for pos, u in enumerate(uuids)] when_statements = [When(uuid=u, then=pos) for pos, u in enumerate(uuids)]
queryset = queryset.filter(uuid__in=uuids).annotate( queryset = queryset.filter(uuid__in=uuids).annotate(
es_rank=Case(*when_statements, default=Value(9999), output_field=IntegerField()) es_rank=Case(
*when_statements, default=Value(9999), output_field=IntegerField()
)
) )
# Mark that ES ranking is applied, qs() will order appropriately # Mark that ES ranking is applied, qs() will order appropriately
self._es_rank_applied = True self._es_rank_applied = True
return queryset return queryset
def filter_include_flag(self, queryset: QuerySet[Product], name: str, value: str) -> QuerySet[Product]: def filter_include_flag(
self, queryset: QuerySet[Product], name: str, value: str
) -> QuerySet[Product]:
if not self.data.get("category_uuid"): if not self.data.get("category_uuid"):
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: QuerySet[Product], name: str, value: str) -> QuerySet[Product]: def filter_include_personal_ordered(
self, queryset: QuerySet[Product], name: str, value: str
) -> QuerySet[Product]:
if self.data.get("include_personal_ordered", False): if self.data.get("include_personal_ordered", False):
queryset = queryset.filter(stocks__isnull=False, stocks__quantity__gt=0, stocks__price__gt=0) queryset = queryset.filter(
stocks__isnull=False, stocks__quantity__gt=0, stocks__price__gt=0
)
return queryset return queryset
def filter_attributes(self, queryset: QuerySet[Product], name: str, value: str) -> QuerySet[Product]: def filter_attributes(
self, queryset: QuerySet[Product], name: str, value: str
) -> QuerySet[Product]:
if not value: if not value:
return queryset return queryset
@ -251,7 +286,9 @@ class ProductFilter(FilterSet): # type: ignore [misc]
return queryset return queryset
def filter_category(self, queryset: QuerySet[Product], name: str, value: str) -> QuerySet[Product]: def filter_category(
self, queryset: QuerySet[Product], name: str, value: str
) -> QuerySet[Product]:
if not value: if not value:
return queryset return queryset
@ -313,7 +350,11 @@ class ProductFilter(FilterSet): # type: ignore [misc]
) )
) )
requested = [part.strip() for part in ordering_param.split(",") if part.strip()] if ordering_param else [] requested = (
[part.strip() for part in ordering_param.split(",") if part.strip()]
if ordering_param
else []
)
mapped_requested: list[str] = [] mapped_requested: list[str] = []
for part in requested: for part in requested:
@ -327,7 +368,11 @@ class ProductFilter(FilterSet): # type: ignore [misc]
mapped_requested.append("?") mapped_requested.append("?")
continue continue
if key in {"personal_orders_only", "personal_order_only", "personal_order_tail"}: if key in {
"personal_orders_only",
"personal_order_only",
"personal_order_tail",
}:
continue continue
mapped_requested.append(f"-{key}" if desc else key) mapped_requested.append(f"-{key}" if desc else key)
@ -353,12 +398,20 @@ class OrderFilter(FilterSet): # type: ignore [misc]
label=_("Search (ID, product name or part number)"), label=_("Search (ID, product name or part number)"),
) )
min_buy_time = DateTimeFilter(field_name="buy_time", lookup_expr="gte", label=_("Bought after (inclusive)")) min_buy_time = DateTimeFilter(
max_buy_time = DateTimeFilter(field_name="buy_time", lookup_expr="lte", label=_("Bought before (inclusive)")) field_name="buy_time", lookup_expr="gte", label=_("Bought after (inclusive)")
)
max_buy_time = DateTimeFilter(
field_name="buy_time", lookup_expr="lte", label=_("Bought before (inclusive)")
)
uuid = UUIDFilter(field_name="uuid", lookup_expr="exact") uuid = UUIDFilter(field_name="uuid", lookup_expr="exact")
user_email = CharFilter(field_name="user__email", lookup_expr="iexact", label=_("User email")) user_email = CharFilter(
user = UUIDFilter(field_name="user__uuid", lookup_expr="exact", label=_("User UUID")) field_name="user__email", lookup_expr="iexact", label=_("User email")
)
user = UUIDFilter(
field_name="user__uuid", lookup_expr="exact", label=_("User UUID")
)
status = CharFilter(field_name="status", lookup_expr="icontains", label=_("Status")) status = CharFilter(field_name="status", lookup_expr="icontains", label=_("Status"))
human_readable_id = CharFilter( human_readable_id = CharFilter(
field_name="human_readable_id", field_name="human_readable_id",
@ -404,8 +457,12 @@ class OrderFilter(FilterSet): # type: ignore [misc]
class WishlistFilter(FilterSet): # type: ignore [misc] class WishlistFilter(FilterSet): # type: ignore [misc]
uuid = UUIDFilter(field_name="uuid", lookup_expr="exact") uuid = UUIDFilter(field_name="uuid", lookup_expr="exact")
user_email = CharFilter(field_name="user__email", lookup_expr="iexact", label=_("User email")) user_email = CharFilter(
user = UUIDFilter(field_name="user__uuid", lookup_expr="exact", label=_("User UUID")) field_name="user__email", lookup_expr="iexact", label=_("User email")
)
user = UUIDFilter(
field_name="user__uuid", lookup_expr="exact", label=_("User UUID")
)
order_by = OrderingFilter( order_by = OrderingFilter(
fields=( fields=(
@ -423,7 +480,9 @@ class WishlistFilter(FilterSet): # type: ignore [misc]
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
class CategoryFilter(FilterSet): # type: ignore [misc] class CategoryFilter(FilterSet): # type: ignore [misc]
search = CharFilter(field_name="name", method="search_categories", label=_("Search")) search = CharFilter(
field_name="name", method="search_categories", label=_("Search")
)
uuid = UUIDFilter(field_name="uuid", lookup_expr="exact") uuid = UUIDFilter(field_name="uuid", lookup_expr="exact")
name = CharFilter(lookup_expr="icontains", label=_("Name")) name = CharFilter(lookup_expr="icontains", label=_("Name"))
parent_uuid = CharFilter(method="filter_parent_uuid", label=_("Parent")) parent_uuid = CharFilter(method="filter_parent_uuid", label=_("Parent"))
@ -451,15 +510,24 @@ class CategoryFilter(FilterSet): # type: ignore [misc]
"whole", "whole",
] ]
def search_categories(self, queryset: QuerySet[Category], name: str, value: str) -> QuerySet[Category]: def search_categories(
self, queryset: QuerySet[Category], name: str, value: str
) -> QuerySet[Category]:
if not value: if not value:
return queryset return queryset
uuids = [category.get("uuid") for category in process_query(query=value, indexes=("categories",))["categories"]] # type: ignore uuids = [
category.get("uuid")
for category in process_query(query=value, indexes=("categories",))[
"categories"
]
] # type: ignore
return queryset.filter(uuid__in=uuids) return queryset.filter(uuid__in=uuids)
def filter_order_by(self, queryset: QuerySet[Category], name: str, value: str) -> QuerySet[Category]: def filter_order_by(
self, queryset: QuerySet[Category], name: str, value: str
) -> QuerySet[Category]:
if not value: if not value:
return queryset return queryset
@ -505,7 +573,9 @@ class CategoryFilter(FilterSet): # type: ignore [misc]
if depth <= 0: if depth <= 0:
return None return None
children_qs = Category.objects.all().order_by(order_expression, "tree_id", "lft") children_qs = Category.objects.all().order_by(
order_expression, "tree_id", "lft"
)
nested_prefetch = build_ordered_prefetch(depth - 1) nested_prefetch = build_ordered_prefetch(depth - 1)
if nested_prefetch: if nested_prefetch:
@ -521,7 +591,9 @@ class CategoryFilter(FilterSet): # type: ignore [misc]
return qs return qs
def filter_whole_categories(self, queryset: QuerySet[Category], name: str, value: str) -> QuerySet[Category]: def filter_whole_categories(
self, queryset: QuerySet[Category], name: str, value: str
) -> QuerySet[Category]:
has_own_products = Exists(Product.objects.filter(category=OuterRef("pk"))) has_own_products = Exists(Product.objects.filter(category=OuterRef("pk")))
has_desc_products = Exists( has_desc_products = Exists(
Product.objects.filter( Product.objects.filter(
@ -554,7 +626,9 @@ class BrandFilter(FilterSet): # type: ignore [misc]
uuid = UUIDFilter(field_name="uuid", lookup_expr="exact") uuid = UUIDFilter(field_name="uuid", lookup_expr="exact")
name = CharFilter(lookup_expr="icontains", label=_("Name")) name = CharFilter(lookup_expr="icontains", label=_("Name"))
slug = CharFilter(field_name="slug", lookup_expr="exact", label=_("Slug")) 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=(
@ -570,11 +644,16 @@ class BrandFilter(FilterSet): # type: ignore [misc]
model = Brand model = Brand
fields = ["uuid", "name", "slug", "priority"] fields = ["uuid", "name", "slug", "priority"]
def search_brands(self, queryset: QuerySet[Brand], name: str, value: str) -> QuerySet[Brand]: def search_brands(
self, queryset: QuerySet[Brand], name: str, value: str
) -> QuerySet[Brand]:
if not value: if not value:
return queryset return queryset
uuids = [brand.get("uuid") for brand in process_query(query=value, indexes=("brands",))["brands"]] # type: ignore uuids = [
brand.get("uuid")
for brand in process_query(query=value, indexes=("brands",))["brands"]
] # type: ignore
return queryset.filter(uuid__in=uuids) return queryset.filter(uuid__in=uuids)
@ -610,8 +689,12 @@ class FeedbackFilter(FilterSet): # type: ignore [misc]
class AddressFilter(FilterSet): # type: ignore [misc] class AddressFilter(FilterSet): # type: ignore [misc]
uuid = UUIDFilter(field_name="uuid", lookup_expr="exact", label=_("UUID")) uuid = UUIDFilter(field_name="uuid", lookup_expr="exact", label=_("UUID"))
user_uuid = UUIDFilter(field_name="user__uuid", lookup_expr="exact", label=_("User UUID")) user_uuid = UUIDFilter(
user_email = CharFilter(field_name="user__email", lookup_expr="iexact", label=_("User email")) field_name="user__uuid", lookup_expr="exact", label=_("User UUID")
)
user_email = CharFilter(
field_name="user__email", lookup_expr="iexact", label=_("User email")
)
order_by = OrderingFilter( order_by = OrderingFilter(
fields=( fields=(

View file

@ -37,7 +37,9 @@ class CacheOperator(BaseMutation):
description = _("cache I/O") description = _("cache I/O")
class Arguments: class Arguments:
key = String(required=True, description=_("key to look for in or set into the cache")) key = String(
required=True, description=_("key to look for in or set into the cache")
)
data = GenericScalar(required=False, description=_("data to store in cache")) data = GenericScalar(required=False, description=_("data to store in cache"))
timeout = Int( timeout = Int(
required=False, required=False,
@ -68,7 +70,9 @@ class RequestCursedURL(BaseMutation):
try: try:
data = cache.get(url, None) data = cache.get(url, None)
if not data: if not data:
response = requests.get(url, headers={"content-type": "application/json"}) response = requests.get(
url, headers={"content-type": "application/json"}
)
response.raise_for_status() response.raise_for_status()
data = camelize(response.json()) data = camelize(response.json())
cache.set(url, data, 86400) cache.set(url, data, 86400)
@ -97,7 +101,9 @@ class AddOrderProduct(BaseMutation):
if not (user.has_perm("core.add_orderproduct") or user == order.user): if not (user.has_perm("core.add_orderproduct") or user == order.user):
raise PermissionDenied(permission_denied_message) raise PermissionDenied(permission_denied_message)
order = order.add_product(product_uuid=product_uuid, attributes=format_attributes(attributes)) order = order.add_product(
product_uuid=product_uuid, attributes=format_attributes(attributes)
)
return AddOrderProduct(order=order) return AddOrderProduct(order=order)
except Order.DoesNotExist as dne: except Order.DoesNotExist as dne:
@ -124,7 +130,9 @@ class RemoveOrderProduct(BaseMutation):
if not (user.has_perm("core.change_orderproduct") or user == order.user): if not (user.has_perm("core.change_orderproduct") or user == order.user):
raise PermissionDenied(permission_denied_message) raise PermissionDenied(permission_denied_message)
order = order.remove_product(product_uuid=product_uuid, attributes=format_attributes(attributes)) order = order.remove_product(
product_uuid=product_uuid, attributes=format_attributes(attributes)
)
return RemoveOrderProduct(order=order) return RemoveOrderProduct(order=order)
except Order.DoesNotExist as dne: except Order.DoesNotExist as dne:
@ -208,7 +216,11 @@ class BuyOrder(BaseMutation):
chosen_products=None, chosen_products=None,
): # type: ignore [override] ): # type: ignore [override]
if not any([order_uuid, order_hr_id]) or all([order_uuid, order_hr_id]): if not any([order_uuid, order_hr_id]) or all([order_uuid, order_hr_id]):
raise BadRequest(_("please provide either order_uuid or order_hr_id - mutually exclusive")) raise BadRequest(
_(
"please provide either order_uuid or order_hr_id - mutually exclusive"
)
)
user = info.context.user user = info.context.user
try: try:
order = None order = None
@ -233,7 +245,11 @@ class BuyOrder(BaseMutation):
case "<class 'engine.core.models.Order'>": case "<class 'engine.core.models.Order'>":
return BuyOrder(order=instance) return BuyOrder(order=instance)
case _: case _:
raise TypeError(_(f"wrong type came from order.buy() method: {type(instance)!s}")) raise TypeError(
_(
f"wrong type came from order.buy() method: {type(instance)!s}"
)
)
except Order.DoesNotExist as dne: except Order.DoesNotExist as dne:
raise Http404(_(f"order {order_uuid} not found")) from dne raise Http404(_(f"order {order_uuid} not found")) from dne
@ -262,7 +278,11 @@ class BulkOrderAction(BaseMutation):
order_hr_id=None, order_hr_id=None,
): # type: ignore [override] ): # type: ignore [override]
if not any([order_uuid, order_hr_id]) or all([order_uuid, order_hr_id]): if not any([order_uuid, order_hr_id]) or all([order_uuid, order_hr_id]):
raise BadRequest(_("please provide either order_uuid or order_hr_id - mutually exclusive")) raise BadRequest(
_(
"please provide either order_uuid or order_hr_id - mutually exclusive"
)
)
user = info.context.user user = info.context.user
try: try:
order = None order = None
@ -491,14 +511,20 @@ class BuyWishlist(BaseMutation):
): ):
order.add_product(product_uuid=product.pk) order.add_product(product_uuid=product.pk)
instance = order.buy(force_balance=force_balance, force_payment=force_payment) instance = order.buy(
force_balance=force_balance, force_payment=force_payment
)
match str(type(instance)): match str(type(instance)):
case "<class 'engine.payments.models.Transaction'>": case "<class 'engine.payments.models.Transaction'>":
return BuyWishlist(transaction=instance) return BuyWishlist(transaction=instance)
case "<class 'engine.core.models.Order'>": case "<class 'engine.core.models.Order'>":
return BuyWishlist(order=instance) return BuyWishlist(order=instance)
case _: case _:
raise TypeError(_(f"wrong type came from order.buy() method: {type(instance)!s}")) raise TypeError(
_(
f"wrong type came from order.buy() method: {type(instance)!s}"
)
)
except Wishlist.DoesNotExist as dne: except Wishlist.DoesNotExist as dne:
raise Http404(_(f"wishlist {wishlist_uuid} not found")) from dne raise Http404(_(f"wishlist {wishlist_uuid} not found")) from dne
@ -513,7 +539,9 @@ class BuyProduct(BaseMutation):
product_uuid = UUID(required=True) product_uuid = UUID(required=True)
attributes = String( attributes = String(
required=False, required=False,
description=_("please send the attributes as the string formatted like attr1=value1,attr2=value2"), description=_(
"please send the attributes as the string formatted like attr1=value1,attr2=value2"
),
) )
force_balance = Boolean(required=False) force_balance = Boolean(required=False)
force_payment = Boolean(required=False) force_payment = Boolean(required=False)
@ -532,7 +560,9 @@ class BuyProduct(BaseMutation):
): # type: ignore [override] ): # type: ignore [override]
user = info.context.user user = info.context.user
order = Order.objects.create(user=user, status="MOMENTAL") order = Order.objects.create(user=user, status="MOMENTAL")
order.add_product(product_uuid=product_uuid, attributes=format_attributes(attributes)) order.add_product(
product_uuid=product_uuid, attributes=format_attributes(attributes)
)
instance = order.buy(force_balance=force_balance, force_payment=force_payment) instance = order.buy(force_balance=force_balance, force_payment=force_payment)
match str(type(instance)): match str(type(instance)):
case "<class 'engine.payments.models.Transaction'>": case "<class 'engine.payments.models.Transaction'>":
@ -540,7 +570,9 @@ class BuyProduct(BaseMutation):
case "<class 'engine.core.models.Order'>": case "<class 'engine.core.models.Order'>":
return BuyProduct(order=instance) return BuyProduct(order=instance)
case _: case _:
raise TypeError(_(f"wrong type came from order.buy() method: {type(instance)!s}")) raise TypeError(
_(f"wrong type came from order.buy() method: {type(instance)!s}")
)
# noinspection PyUnusedLocal,PyTypeChecker # noinspection PyUnusedLocal,PyTypeChecker
@ -566,7 +598,9 @@ class FeedbackProductAction(BaseMutation):
feedback = None feedback = None
match action: match action:
case "add": case "add":
feedback = order_product.do_feedback(comment=comment, rating=rating, action="add") feedback = order_product.do_feedback(
comment=comment, rating=rating, action="add"
)
case "remove": case "remove":
feedback = order_product.do_feedback(action="remove") feedback = order_product.do_feedback(action="remove")
case _: case _:
@ -579,7 +613,9 @@ class FeedbackProductAction(BaseMutation):
# noinspection PyUnusedLocal,PyTypeChecker # noinspection PyUnusedLocal,PyTypeChecker
class CreateAddress(BaseMutation): class CreateAddress(BaseMutation):
class Arguments: class Arguments:
raw_data = String(required=True, description=_("original address string provided by the user")) raw_data = String(
required=True, description=_("original address string provided by the user")
)
address = Field(AddressType) address = Field(AddressType)

View file

@ -119,7 +119,15 @@ class BrandType(DjangoObjectType): # type: ignore [misc]
class Meta: class Meta:
model = Brand model = Brand
interfaces = (relay.Node,) interfaces = (relay.Node,)
fields = ("uuid", "categories", "name", "description", "big_logo", "small_logo", "slug") fields = (
"uuid",
"categories",
"name",
"description",
"big_logo",
"small_logo",
"slug",
)
filter_fields = ["uuid", "name"] filter_fields = ["uuid", "name"]
description = _("brands") description = _("brands")
@ -129,12 +137,20 @@ class BrandType(DjangoObjectType): # type: ignore [misc]
return self.categories.filter(is_active=True) return self.categories.filter(is_active=True)
def resolve_big_logo(self: Brand, info) -> str | None: def resolve_big_logo(self: Brand, info) -> str | None:
return info.context.build_absolute_uri(self.big_logo.url) if self.big_logo else "" return (
info.context.build_absolute_uri(self.big_logo.url) if self.big_logo else ""
)
def resolve_small_logo(self: Brand, info) -> str | None: def resolve_small_logo(self: Brand, info) -> str | None:
return info.context.build_absolute_uri(self.small_logo.url) if self.small_logo else "" return (
info.context.build_absolute_uri(self.small_logo.url)
if self.small_logo
else ""
)
def resolve_seo_meta(self: Brand, info) -> dict[str, str | list[Any] | dict[str, str] | None]: def resolve_seo_meta(
self: Brand, info
) -> dict[str, str | list[Any] | dict[str, str] | None]:
lang = graphene_current_lang() lang = graphene_current_lang()
base = f"https://{settings.BASE_DOMAIN}" base = f"https://{settings.BASE_DOMAIN}"
canonical = f"{base}/{lang}/brand/{self.slug}" canonical = f"{base}/{lang}/brand/{self.slug}"
@ -154,7 +170,12 @@ class BrandType(DjangoObjectType): # type: ignore [misc]
"url": canonical, "url": canonical,
"image": logo_url or "", "image": logo_url or "",
} }
tw = {"card": "summary_large_image", "title": title, "description": description, "image": logo_url or ""} tw = {
"card": "summary_large_image",
"title": title,
"description": description,
"image": logo_url or "",
}
crumbs = [("Home", f"{base}/"), (self.name, canonical)] crumbs = [("Home", f"{base}/"), (self.name, canonical)]
@ -196,14 +217,22 @@ class CategoryType(DjangoObjectType): # type: ignore [misc]
markup_percent = Float(required=False, description=_("markup percentage")) markup_percent = Float(required=False, description=_("markup percentage"))
filterable_attributes = List( filterable_attributes = List(
NonNull(FilterableAttributeType), NonNull(FilterableAttributeType),
description=_("which attributes and values can be used for filtering this category."), description=_(
"which attributes and values can be used for filtering this category."
),
) )
min_max_prices = Field( min_max_prices = Field(
NonNull(MinMaxPriceType), NonNull(MinMaxPriceType),
description=_("minimum and maximum prices for products in this category, if available."), description=_(
"minimum and maximum prices for products in this category, if available."
),
)
tags = DjangoFilterConnectionField(
lambda: CategoryTagType, description=_("tags for this category")
)
products = DjangoFilterConnectionField(
lambda: ProductType, description=_("products in this category")
) )
tags = DjangoFilterConnectionField(lambda: CategoryTagType, description=_("tags for 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:
@ -253,7 +282,9 @@ class CategoryType(DjangoObjectType): # type: ignore [misc]
) )
min_max_prices["min_price"] = price_aggregation.get("min_price", 0.0) min_max_prices["min_price"] = price_aggregation.get("min_price", 0.0)
min_max_prices["max_price"] = price_aggregation.get("max_price", 0.0) min_max_prices["max_price"] = price_aggregation.get("max_price", 0.0)
cache.set(key=f"{self.name}_min_max_prices", value=min_max_prices, timeout=86400) cache.set(
key=f"{self.name}_min_max_prices", value=min_max_prices, timeout=86400
)
return { return {
"min_price": min_max_prices["min_price"], "min_price": min_max_prices["min_price"],
@ -267,7 +298,11 @@ class CategoryType(DjangoObjectType): # type: ignore [misc]
title = f"{self.name} | {settings.PROJECT_NAME}" title = f"{self.name} | {settings.PROJECT_NAME}"
description = (self.description or "")[:180] description = (self.description or "")[:180]
og_image = graphene_abs(info.context, self.image.url) if getattr(self, "image", None) else "" og_image = (
graphene_abs(info.context, self.image.url)
if getattr(self, "image", None)
else ""
)
og = { og = {
"title": title, "title": title,
@ -276,14 +311,24 @@ class CategoryType(DjangoObjectType): # type: ignore [misc]
"url": canonical, "url": canonical,
"image": og_image, "image": og_image,
} }
tw = {"card": "summary_large_image", "title": title, "description": description, "image": og_image} tw = {
"card": "summary_large_image",
"title": title,
"description": description,
"image": og_image,
}
crumbs = [("Home", f"{base}/")] crumbs = [("Home", f"{base}/")]
for c in self.get_ancestors(): for c in self.get_ancestors():
crumbs.append((c.name, f"{base}/{lang}/catalog/{c.slug}")) crumbs.append((c.name, f"{base}/{lang}/catalog/{c.slug}"))
crumbs.append((self.name, canonical)) crumbs.append((self.name, canonical))
json_ld = [org_schema(), website_schema(), breadcrumb_schema(crumbs), category_schema(self, canonical)] json_ld = [
org_schema(),
website_schema(),
breadcrumb_schema(crumbs),
category_schema(self, canonical),
]
product_urls = [] product_urls = []
qs = ( qs = (
@ -356,7 +401,9 @@ class AddressType(DjangoObjectType): # type: ignore [misc]
class FeedbackType(DjangoObjectType): # type: ignore [misc] class FeedbackType(DjangoObjectType): # type: ignore [misc]
comment = String(description=_("comment")) comment = String(description=_("comment"))
rating = Int(description=_("rating value from 1 to 10, inclusive, or 0 if not set.")) rating = Int(
description=_("rating value from 1 to 10, inclusive, or 0 if not set.")
)
class Meta: class Meta:
model = Feedback model = Feedback
@ -369,7 +416,9 @@ class FeedbackType(DjangoObjectType): # type: ignore [misc]
class OrderProductType(DjangoObjectType): # type: ignore [misc] class OrderProductType(DjangoObjectType): # type: ignore [misc]
attributes = GenericScalar(description=_("attributes")) attributes = GenericScalar(description=_("attributes"))
notifications = GenericScalar(description=_("notifications")) notifications = GenericScalar(description=_("notifications"))
download_url = String(description=_("download url for this order product if applicable")) download_url = String(
description=_("download url for this order product if applicable")
)
feedback = Field(lambda: FeedbackType, description=_("feedback")) feedback = Field(lambda: FeedbackType, description=_("feedback"))
class Meta: class Meta:
@ -409,14 +458,18 @@ class OrderType(DjangoObjectType): # type: ignore [misc]
billing_address = Field(AddressType, description=_("billing address")) billing_address = Field(AddressType, description=_("billing address"))
shipping_address = Field( shipping_address = Field(
AddressType, AddressType,
description=_("shipping address for this order, leave blank if same as billing address or if not applicable"), description=_(
"shipping address for this order, leave blank if same as billing address or if not applicable"
),
) )
total_price = Float(description=_("total price of this order")) total_price = Float(description=_("total price of this order"))
total_quantity = Int(description=_("total quantity of products in order")) total_quantity = Int(description=_("total quantity of products in order"))
is_whole_digital = Float(description=_("are all products in the order digital")) is_whole_digital = Float(description=_("are all products in the order digital"))
attributes = GenericScalar(description=_("attributes")) attributes = GenericScalar(description=_("attributes"))
notifications = GenericScalar(description=_("notifications")) notifications = GenericScalar(description=_("notifications"))
payments_transactions = Field(TransactionType, description=_("transactions for this order")) payments_transactions = Field(
TransactionType, description=_("transactions for this order")
)
class Meta: class Meta:
model = Order model = Order
@ -474,13 +527,17 @@ class ProductType(DjangoObjectType): # type: ignore [misc]
images = DjangoFilterConnectionField(ProductImageType, description=_("images")) images = DjangoFilterConnectionField(ProductImageType, description=_("images"))
feedbacks = DjangoFilterConnectionField(FeedbackType, description=_("feedbacks")) feedbacks = DjangoFilterConnectionField(FeedbackType, description=_("feedbacks"))
brand = Field(BrandType, description=_("brand")) brand = Field(BrandType, description=_("brand"))
attribute_groups = DjangoFilterConnectionField(AttributeGroupType, description=_("attribute groups")) attribute_groups = DjangoFilterConnectionField(
AttributeGroupType, description=_("attribute groups")
)
price = Float(description=_("price")) price = Float(description=_("price"))
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"))
rating = Float(description=_("rating value from 1 to 10, inclusive, or 0 if not set.")) rating = Float(
description=_("rating value from 1 to 10, inclusive, or 0 if not set.")
)
discount_price = Float(description=_("discount price")) discount_price = Float(description=_("discount price"))
class Meta: class Meta:
@ -524,7 +581,9 @@ class ProductType(DjangoObjectType): # type: ignore [misc]
def resolve_attribute_groups(self: Product, info): def resolve_attribute_groups(self: Product, info):
info.context._product_uuid = self.uuid info.context._product_uuid = self.uuid
return AttributeGroup.objects.filter(attributes__values__product=self).distinct() return AttributeGroup.objects.filter(
attributes__values__product=self
).distinct()
def resolve_quantity(self: Product, _info) -> int: def resolve_quantity(self: Product, _info) -> int:
return self.quantity or 0 return self.quantity or 0
@ -549,7 +608,12 @@ class ProductType(DjangoObjectType): # type: ignore [misc]
"url": canonical, "url": canonical,
"image": og_image, "image": og_image,
} }
tw = {"card": "summary_large_image", "title": title, "description": description, "image": og_image} tw = {
"card": "summary_large_image",
"title": title,
"description": description,
"image": og_image,
}
crumbs = [("Home", f"{base}/")] crumbs = [("Home", f"{base}/")]
if self.category: if self.category:
@ -611,14 +675,20 @@ class PromoCodeType(DjangoObjectType): # type: ignore [misc]
description = _("promocodes") description = _("promocodes")
def resolve_discount(self: PromoCode, _info) -> float: def resolve_discount(self: PromoCode, _info) -> float:
return float(self.discount_percent) if self.discount_percent else float(self.discount_amount) # type: ignore [arg-type] return (
float(self.discount_percent)
if self.discount_percent
else float(self.discount_amount)
) # type: ignore [arg-type]
def resolve_discount_type(self: PromoCode, _info) -> str: def resolve_discount_type(self: PromoCode, _info) -> str:
return "percent" if self.discount_percent else "amount" return "percent" if self.discount_percent else "amount"
class PromotionType(DjangoObjectType): # type: ignore [misc] class PromotionType(DjangoObjectType): # type: ignore [misc]
products = DjangoFilterConnectionField(ProductType, description=_("products on sale")) products = DjangoFilterConnectionField(
ProductType, description=_("products on sale")
)
class Meta: class Meta:
model = Promotion model = Promotion
@ -641,7 +711,9 @@ class StockType(DjangoObjectType): # type: ignore [misc]
class WishlistType(DjangoObjectType): # type: ignore [misc] class WishlistType(DjangoObjectType): # type: ignore [misc]
products = DjangoFilterConnectionField(ProductType, description=_("wishlisted products")) products = DjangoFilterConnectionField(
ProductType, description=_("wishlisted products")
)
class Meta: class Meta:
model = Wishlist model = Wishlist
@ -651,7 +723,9 @@ class WishlistType(DjangoObjectType): # type: ignore [misc]
class ProductTagType(DjangoObjectType): # type: ignore [misc] class ProductTagType(DjangoObjectType): # type: ignore [misc]
product_set = DjangoFilterConnectionField(ProductType, description=_("tagged products")) product_set = DjangoFilterConnectionField(
ProductType, description=_("tagged products")
)
class Meta: class Meta:
model = ProductTag model = ProductTag
@ -662,7 +736,9 @@ class ProductTagType(DjangoObjectType): # type: ignore [misc]
class CategoryTagType(DjangoObjectType): # type: ignore [misc] class CategoryTagType(DjangoObjectType): # type: ignore [misc]
category_set = DjangoFilterConnectionField(CategoryType, description=_("tagged categories")) category_set = DjangoFilterConnectionField(
CategoryType, description=_("tagged categories")
)
class Meta: class Meta:
model = CategoryTag model = CategoryTag
@ -677,7 +753,11 @@ class ConfigType(ObjectType): # type: ignore [misc]
company_name = String(description=_("company name")) company_name = String(description=_("company name"))
company_address = String(description=_("company address")) company_address = String(description=_("company address"))
company_phone_number = String(description=_("company phone number")) company_phone_number = String(description=_("company phone number"))
email_from = String(description=_("email from, sometimes it must be used instead of host user value")) email_from = String(
description=_(
"email from, sometimes it must be used instead of host user value"
)
)
email_host_user = String(description=_("email host user")) email_host_user = String(description=_("email host user"))
payment_gateway_maximum = Float(description=_("maximum amount for payment")) payment_gateway_maximum = Float(description=_("maximum amount for payment"))
payment_gateway_minimum = Float(description=_("minimum amount for payment")) payment_gateway_minimum = Float(description=_("minimum amount for payment"))
@ -725,9 +805,15 @@ class SearchPostsResultsType(ObjectType): # type: ignore [misc]
class SearchResultsType(ObjectType): # type: ignore [misc] class SearchResultsType(ObjectType): # type: ignore [misc]
products = List(description=_("products search results"), of_type=SearchProductsResultsType) products = List(
categories = List(description=_("products search results"), of_type=SearchCategoriesResultsType) description=_("products search results"), of_type=SearchProductsResultsType
brands = List(description=_("products search results"), of_type=SearchBrandsResultsType) )
categories = List(
description=_("products search results"), of_type=SearchCategoriesResultsType
)
brands = List(
description=_("products search results"), of_type=SearchBrandsResultsType
)
posts = List(description=_("posts search results"), of_type=SearchPostsResultsType) posts = List(description=_("posts search results"), of_type=SearchPostsResultsType)

View file

@ -113,13 +113,19 @@ class Query(ObjectType):
users = DjangoFilterConnectionField(UserType, filterset_class=UserFilter) users = DjangoFilterConnectionField(UserType, filterset_class=UserFilter)
addresses = DjangoFilterConnectionField(AddressType, filterset_class=AddressFilter) addresses = DjangoFilterConnectionField(AddressType, filterset_class=AddressFilter)
attribute_groups = DjangoFilterConnectionField(AttributeGroupType) attribute_groups = DjangoFilterConnectionField(AttributeGroupType)
categories = DjangoFilterConnectionField(CategoryType, filterset_class=CategoryFilter) categories = DjangoFilterConnectionField(
CategoryType, filterset_class=CategoryFilter
)
vendors = DjangoFilterConnectionField(VendorType) vendors = DjangoFilterConnectionField(VendorType)
feedbacks = DjangoFilterConnectionField(FeedbackType, filterset_class=FeedbackFilter) feedbacks = DjangoFilterConnectionField(
FeedbackType, filterset_class=FeedbackFilter
)
order_products = DjangoFilterConnectionField(OrderProductType) order_products = DjangoFilterConnectionField(OrderProductType)
product_images = DjangoFilterConnectionField(ProductImageType) product_images = DjangoFilterConnectionField(ProductImageType)
stocks = DjangoFilterConnectionField(StockType) stocks = DjangoFilterConnectionField(StockType)
wishlists = DjangoFilterConnectionField(WishlistType, filterset_class=WishlistFilter) wishlists = DjangoFilterConnectionField(
WishlistType, filterset_class=WishlistFilter
)
product_tags = DjangoFilterConnectionField(ProductTagType) product_tags = DjangoFilterConnectionField(ProductTagType)
category_tags = DjangoFilterConnectionField(CategoryTagType) category_tags = DjangoFilterConnectionField(CategoryTagType)
promotions = DjangoFilterConnectionField(PromotionType) promotions = DjangoFilterConnectionField(PromotionType)
@ -137,7 +143,12 @@ class Query(ObjectType):
if not languages: if not languages:
languages = [ languages = [
{"code": lang[0], "name": lang[1], "flag": get_flag_by_language(lang[0])} for lang in settings.LANGUAGES {
"code": lang[0],
"name": lang[1],
"flag": get_flag_by_language(lang[0]),
}
for lang in settings.LANGUAGES
] ]
cache.set("languages", languages, 60 * 60) cache.set("languages", languages, 60 * 60)
@ -152,10 +163,16 @@ class Query(ObjectType):
def resolve_products(_parent, info, **kwargs): def resolve_products(_parent, info, **kwargs):
if info.context.user.is_authenticated and kwargs.get("uuid"): if info.context.user.is_authenticated and kwargs.get("uuid"):
product = Product.objects.get(uuid=kwargs["uuid"]) product = Product.objects.get(uuid=kwargs["uuid"])
if product.is_active and product.brand.is_active and product.category.is_active: if (
product.is_active
and product.brand.is_active
and product.category.is_active
):
info.context.user.add_to_recently_viewed(product.uuid) info.context.user.add_to_recently_viewed(product.uuid)
base_qs = ( base_qs = (
Product.objects.all().select_related("brand", "category").prefetch_related("images", "stocks") Product.objects.all()
.select_related("brand", "category")
.prefetch_related("images", "stocks")
if info.context.user.has_perm("core.view_product") if info.context.user.has_perm("core.view_product")
else Product.objects.filter( else Product.objects.filter(
is_active=True, is_active=True,
@ -318,7 +335,10 @@ class Query(ObjectType):
def resolve_promocodes(_parent, info, **kwargs): def resolve_promocodes(_parent, info, **kwargs):
promocodes = PromoCode.objects promocodes = PromoCode.objects
if info.context.user.has_perm("core.view_promocode"): if info.context.user.has_perm("core.view_promocode"):
return promocodes.filter(user__uuid=kwargs.get("user_uuid")) or promocodes.all() return (
promocodes.filter(user__uuid=kwargs.get("user_uuid"))
or promocodes.all()
)
return promocodes.filter( return promocodes.filter(
is_active=True, is_active=True,
user=info.context.user, user=info.context.user,

View file

@ -10,7 +10,7 @@ from django.apps import apps
from django.conf import settings from django.conf import settings
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from engine.core.management.commands import RootDirectory, TRANSLATABLE_APPS from engine.core.management.commands import TRANSLATABLE_APPS, RootDirectory
# Patterns to identify placeholders # Patterns to identify placeholders
PLACEHOLDER_REGEXES = [ PLACEHOLDER_REGEXES = [
@ -118,7 +118,9 @@ class Command(BaseCommand):
for lang in langs: for lang in langs:
loc = lang.replace("-", "_") loc = lang.replace("-", "_")
po_path = os.path.join(app_conf.path, "locale", loc, "LC_MESSAGES", "django.po") po_path = os.path.join(
app_conf.path, "locale", loc, "LC_MESSAGES", "django.po"
)
if not os.path.exists(po_path): if not os.path.exists(po_path):
continue continue
@ -141,7 +143,9 @@ class Command(BaseCommand):
display = po_path.replace("/app/", root_path) display = po_path.replace("/app/", root_path)
if "\\" in root_path: if "\\" in root_path:
display = display.replace("/", "\\") display = display.replace("/", "\\")
lang_issues.append(f" {display}:{line_no}: missing={sorted(missing)} extra={sorted(extra)}") lang_issues.append(
f" {display}:{line_no}: missing={sorted(missing)} extra={sorted(extra)}"
)
if lang_issues: if lang_issues:
# Header for language with issues # Header for language with issues
@ -157,7 +161,11 @@ class Command(BaseCommand):
self.stdout.write("") self.stdout.write("")
else: else:
# No issues in any language for this app # No issues in any language for this app
self.stdout.write(self.style.SUCCESS(f"App {app_conf.label} has no placeholder issues.")) self.stdout.write(
self.style.SUCCESS(
f"App {app_conf.label} has no placeholder issues."
)
)
self.stdout.write("") self.stdout.write("")
self.stdout.write(self.style.SUCCESS("Done scanning.")) self.stdout.write(self.style.SUCCESS("Done scanning."))

View file

@ -37,7 +37,11 @@ class Command(BaseCommand):
if stock_deletions: if stock_deletions:
Stock.objects.filter(uuid__in=stock_deletions).delete() Stock.objects.filter(uuid__in=stock_deletions).delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {len(stock_deletions)} duplicate stock entries.")) self.stdout.write(
self.style.SUCCESS(
f"Deleted {len(stock_deletions)} duplicate stock entries."
)
)
# 2. Clean up duplicate Category entries based on name (case-insensitive) # 2. Clean up duplicate Category entries based on name (case-insensitive)
category_groups = defaultdict(list) category_groups = defaultdict(list)
@ -61,7 +65,9 @@ class Command(BaseCommand):
for duplicate in cat_list: for duplicate in cat_list:
if duplicate.uuid == keep_category.uuid: if duplicate.uuid == keep_category.uuid:
continue continue
total_product_updates += Product.objects.filter(category=duplicate).update(category=keep_category) total_product_updates += Product.objects.filter(
category=duplicate
).update(category=keep_category)
categories_to_delete.append(str(duplicate.uuid)) categories_to_delete.append(str(duplicate.uuid))
if categories_to_delete: if categories_to_delete:
@ -78,13 +84,25 @@ class Command(BaseCommand):
count_inactive = inactive_products.count() count_inactive = inactive_products.count()
if count_inactive: if count_inactive:
inactive_products.update(is_active=False) inactive_products.update(is_active=False)
self.stdout.write(self.style.SUCCESS(f"Set {count_inactive} product(s) as inactive due to missing stocks.")) self.stdout.write(
self.style.SUCCESS(
f"Set {count_inactive} product(s) as inactive due to missing stocks."
)
)
# 4. Delete stocks without an associated product. # 4. Delete stocks without an associated product.
orphan_stocks = Stock.objects.filter(product__isnull=True) orphan_stocks = Stock.objects.filter(product__isnull=True)
orphan_count = orphan_stocks.count() orphan_count = orphan_stocks.count()
if orphan_count: if orphan_count:
orphan_stocks.delete() orphan_stocks.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {orphan_count} stock(s) without an associated product.")) self.stdout.write(
self.style.SUCCESS(
f"Deleted {orphan_count} stock(s) without an associated product."
)
)
self.stdout.write(self.style.SUCCESS("Started fetching products task in worker container without errors!")) self.stdout.write(
self.style.SUCCESS(
"Started fetching products task in worker container without errors!"
)
)

View file

@ -10,7 +10,11 @@ import requests
from django.apps import apps from django.apps import apps
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from engine.core.management.commands import DEEPL_TARGET_LANGUAGES_MAPPING, TRANSLATABLE_APPS, RootDirectory from engine.core.management.commands import (
DEEPL_TARGET_LANGUAGES_MAPPING,
TRANSLATABLE_APPS,
RootDirectory,
)
# Patterns to identify placeholders # Patterns to identify placeholders
PLACEHOLDER_REGEXES = [ PLACEHOLDER_REGEXES = [
@ -60,7 +64,9 @@ def load_po_sanitized(path: str) -> polib.POFile | None:
parts = text.split("\n\n", 1) parts = text.split("\n\n", 1)
header = parts[0] header = parts[0]
rest = parts[1] if len(parts) > 1 else "" rest = parts[1] if len(parts) > 1 else ""
rest_clean = re.sub(r"^msgid \"\"\s*\nmsgstr \"\"\s*\n?", "", rest, flags=re.MULTILINE) rest_clean = re.sub(
r"^msgid \"\"\s*\nmsgstr \"\"\s*\n?", "", rest, flags=re.MULTILINE
)
sanitized = header + "\n\n" + rest_clean sanitized = header + "\n\n" + rest_clean
tmp = NamedTemporaryFile( # noqa: SIM115 tmp = NamedTemporaryFile( # noqa: SIM115
mode="w+", delete=False, suffix=".po", encoding="utf-8" mode="w+", delete=False, suffix=".po", encoding="utf-8"
@ -124,13 +130,19 @@ class Command(BaseCommand):
for target_lang in target_langs: for target_lang in target_langs:
api_code = DEEPL_TARGET_LANGUAGES_MAPPING.get(target_lang) api_code = DEEPL_TARGET_LANGUAGES_MAPPING.get(target_lang)
if not api_code: if not api_code:
self.stdout.write(self.style.WARNING(f"Unknown language '{target_lang}'")) self.stdout.write(
self.style.WARNING(f"Unknown language '{target_lang}'")
)
continue continue
if api_code == "unsupported": if api_code == "unsupported":
self.stdout.write(self.style.WARNING(f"Unsupported language '{target_lang}'")) self.stdout.write(
self.style.WARNING(f"Unsupported language '{target_lang}'")
)
continue continue
self.stdout.write(self.style.MIGRATE_HEADING(f"→ Translating into {target_lang}")) self.stdout.write(
self.style.MIGRATE_HEADING(f"→ Translating into {target_lang}")
)
configs = list(apps.get_app_configs()) + [RootDirectory()] configs = list(apps.get_app_configs()) + [RootDirectory()]
@ -138,9 +150,13 @@ class Command(BaseCommand):
if app_conf.label not in target_apps: if app_conf.label not in target_apps:
continue continue
en_path = os.path.join(app_conf.path, "locale", "en_GB", "LC_MESSAGES", "django.po") en_path = os.path.join(
app_conf.path, "locale", "en_GB", "LC_MESSAGES", "django.po"
)
if not os.path.isfile(en_path): if not os.path.isfile(en_path):
self.stdout.write(self.style.WARNING(f"{app_conf.label}: no en_GB PO")) self.stdout.write(
self.style.WARNING(f"{app_conf.label}: no en_GB PO")
)
continue continue
self.stdout.write(f"{app_conf.label}: loading English PO…") self.stdout.write(f"{app_conf.label}: loading English PO…")
@ -148,9 +164,13 @@ class Command(BaseCommand):
if not en_po: if not en_po:
raise CommandError(f"Failed to load en_GB PO for {app_conf.label}") raise CommandError(f"Failed to load en_GB PO for {app_conf.label}")
missing = [e for e in en_po if e.msgid and not e.msgstr and not e.obsolete] missing = [
e for e in en_po if e.msgid and not e.msgstr and not e.obsolete
]
if missing: if missing:
self.stdout.write(self.style.NOTICE(f"⚠️ {len(missing)} missing in en_GB")) self.stdout.write(
self.style.NOTICE(f"⚠️ {len(missing)} missing in en_GB")
)
for e in missing: for e in missing:
default = e.msgid default = e.msgid
if readline: if readline:
@ -193,7 +213,11 @@ class Command(BaseCommand):
try: try:
old_tgt = load_po_sanitized(str(tgt_path)) old_tgt = load_po_sanitized(str(tgt_path))
except Exception as e: except Exception as e:
self.stdout.write(self.style.WARNING(f"Existing PO parse error({e!s}), starting fresh")) self.stdout.write(
self.style.WARNING(
f"Existing PO parse error({e!s}), starting fresh"
)
)
new_po = polib.POFile() new_po = polib.POFile()
new_po.metadata = en_po.metadata.copy() new_po.metadata = en_po.metadata.copy()
@ -215,7 +239,9 @@ class Command(BaseCommand):
to_trans = [e for e in new_po if not e.msgstr] to_trans = [e for e in new_po if not e.msgstr]
if not to_trans: if not to_trans:
self.stdout.write(self.style.WARNING(f"All done for {app_conf.label}")) self.stdout.write(
self.style.WARNING(f"All done for {app_conf.label}")
)
continue continue
protected = [] protected = []
@ -239,7 +265,9 @@ class Command(BaseCommand):
trans = result.get("translations", []) trans = result.get("translations", [])
if len(trans) != len(to_trans): if len(trans) != len(to_trans):
raise CommandError(f"Got {len(trans)} translations, expected {len(to_trans)}") raise CommandError(
f"Got {len(trans)} translations, expected {len(to_trans)}"
)
for entry, obj, pmap in zip(to_trans, trans, maps, strict=True): for entry, obj, pmap in zip(to_trans, trans, maps, strict=True):
entry.msgstr = deplaceholderize(obj["text"], pmap) entry.msgstr = deplaceholderize(obj["text"], pmap)

View file

@ -21,7 +21,11 @@ class Command(BaseCommand):
def handle(self, *args: list[Any], **options: dict[Any, Any]) -> None: def handle(self, *args: list[Any], **options: dict[Any, Any]) -> None:
size: int = options["size"] # type: ignore [assignment] size: int = options["size"] # type: ignore [assignment]
while True: while True:
batch_ids = list(Product.objects.filter(orderproduct__isnull=True).values_list("pk", flat=True)[:size]) batch_ids = list(
Product.objects.filter(orderproduct__isnull=True).values_list(
"pk", flat=True
)[:size]
)
if not batch_ids: if not batch_ids:
break break
try: try:
@ -29,7 +33,10 @@ class Command(BaseCommand):
ProductImage.objects.filter(product_id__in=batch_ids).delete() ProductImage.objects.filter(product_id__in=batch_ids).delete()
Product.objects.filter(pk__in=batch_ids).delete() Product.objects.filter(pk__in=batch_ids).delete()
except Exception as e: except Exception as e:
self.stdout.write("Couldn't delete some of the products(will retry later): %s" % str(e)) self.stdout.write(
"Couldn't delete some of the products(will retry later): %s"
% str(e)
)
continue continue
self.stdout.write(f"Deleted {len(batch_ids)} products…") self.stdout.write(f"Deleted {len(batch_ids)} products…")

View file

@ -22,7 +22,9 @@ class Command(BaseCommand):
size: int = options["size"] # type: ignore [assignment] size: int = options["size"] # type: ignore [assignment]
while True: while True:
batch_ids = list( batch_ids = list(
Product.objects.filter(description__iexact="EVIBES_DELETED_PRODUCT").values_list("pk", flat=True)[:size] Product.objects.filter(
description__iexact="EVIBES_DELETED_PRODUCT"
).values_list("pk", flat=True)[:size]
) )
if not batch_ids: if not batch_ids:
break break
@ -31,7 +33,10 @@ class Command(BaseCommand):
ProductImage.objects.filter(product_id__in=batch_ids).delete() ProductImage.objects.filter(product_id__in=batch_ids).delete()
Product.objects.filter(pk__in=batch_ids).delete() Product.objects.filter(pk__in=batch_ids).delete()
except Exception as e: except Exception as e:
self.stdout.write("Couldn't delete some of the products(will retry later): %s" % str(e)) self.stdout.write(
"Couldn't delete some of the products(will retry later): %s"
% str(e)
)
continue continue
self.stdout.write(f"Deleted {len(batch_ids)} products…") self.stdout.write(f"Deleted {len(batch_ids)} products…")

View file

@ -7,8 +7,14 @@ from engine.core.tasks import update_products_task
class Command(BaseCommand): class Command(BaseCommand):
def handle(self, *args: list[Any], **options: dict[Any, Any]) -> None: def handle(self, *args: list[Any], **options: dict[Any, Any]) -> None:
self.stdout.write(self.style.SUCCESS("Starting fetching products task in worker container...")) self.stdout.write(
self.style.SUCCESS("Starting fetching products task in worker container...")
)
update_products_task.delay() # type: ignore [attr-defined] update_products_task.delay() # type: ignore [attr-defined]
self.stdout.write(self.style.SUCCESS("Started fetching products task in worker container without errors!")) self.stdout.write(
self.style.SUCCESS(
"Started fetching products task in worker container without errors!"
)
)

View file

@ -53,19 +53,28 @@ class Command(BaseCommand):
continue continue
if any( if any(
line.startswith("#~") or (line.startswith("#,") and line.lstrip("#, ").startswith("msgid ")) line.startswith("#~")
or (line.startswith("#,") and line.lstrip("#, ").startswith("msgid "))
for line in ent for line in ent
): ):
changed = True changed = True
continue continue
fuzzy_idx = next( fuzzy_idx = next(
(i for i, line in enumerate(ent) if line.startswith("#,") and "fuzzy" in line), (
i
for i, line in enumerate(ent)
if line.startswith("#,") and "fuzzy" in line
),
None, None,
) )
if fuzzy_idx is not None: if fuzzy_idx is not None:
flags = [f.strip() for f in ent[fuzzy_idx][2:].split(",") if f.strip() != "fuzzy"] flags = [
f.strip()
for f in ent[fuzzy_idx][2:].split(",")
if f.strip() != "fuzzy"
]
if flags: if flags:
ent[fuzzy_idx] = "#, " + ", ".join(flags) + "\n" ent[fuzzy_idx] = "#, " + ", ".join(flags) + "\n"
else: else:
@ -73,7 +82,9 @@ class Command(BaseCommand):
ent = [line for line in ent if not line.startswith("#| msgid")] ent = [line for line in ent if not line.startswith("#| msgid")]
ent = ['msgstr ""\n' if line.startswith("msgstr") else line for line in ent] ent = [
'msgstr ""\n' if line.startswith("msgstr") else line for line in ent
]
changed = True changed = True

View file

@ -16,9 +16,13 @@ class Command(BaseCommand):
for product in Product.objects.filter(stocks__isnull=False): for product in Product.objects.filter(stocks__isnull=False):
for stock in product.stocks.all(): for stock in product.stocks.all():
try: try:
stock.price = AbstractVendor.round_price_marketologically(stock.price) stock.price = AbstractVendor.round_price_marketologically(
stock.price
)
stock.save() stock.save()
except Exception as e: except Exception as e:
self.stdout.write(self.style.WARNING(f"Couldn't fix price on {stock.uuid}")) self.stdout.write(
self.style.WARNING(f"Couldn't fix price on {stock.uuid}")
)
self.stdout.write(self.style.WARNING(f"Error: {e}")) self.stdout.write(self.style.WARNING(f"Error: {e}"))
self.stdout.write(self.style.SUCCESS("Successfully fixed stocks' prices!")) self.stdout.write(self.style.SUCCESS("Successfully fixed stocks' prices!"))

View file

@ -151,43 +151,65 @@ class Command(BaseCommand):
stored_date = datetime.min stored_date = datetime.min
if not (settings.RELEASE_DATE > stored_date): if not (settings.RELEASE_DATE > stored_date):
self.stdout.write(self.style.WARNING("Initialization skipped: already up-to-date.")) self.stdout.write(
self.style.WARNING("Initialization skipped: already up-to-date.")
)
return return
Vendor.objects.get_or_create(name="INNER") Vendor.objects.get_or_create(name="INNER")
user_support, is_user_support_created = Group.objects.get_or_create(name="User Support") user_support, is_user_support_created = Group.objects.get_or_create(
name="User Support"
)
if is_user_support_created: if is_user_support_created:
perms = Permission.objects.filter(codename__in=user_support_permissions) perms = Permission.objects.filter(codename__in=user_support_permissions)
user_support.permissions.add(*perms) user_support.permissions.add(*perms)
stock_manager, is_stock_manager_created = Group.objects.get_or_create(name="Stock Manager") stock_manager, is_stock_manager_created = Group.objects.get_or_create(
name="Stock Manager"
)
if is_stock_manager_created: if is_stock_manager_created:
perms = Permission.objects.filter(codename__in=stock_manager_permissions) perms = Permission.objects.filter(codename__in=stock_manager_permissions)
stock_manager.permissions.add(*perms) stock_manager.permissions.add(*perms)
head_stock_manager, is_head_stock_manager_created = Group.objects.get_or_create(name="Head Stock Manager") head_stock_manager, is_head_stock_manager_created = Group.objects.get_or_create(
name="Head Stock Manager"
)
if is_head_stock_manager_created: if is_head_stock_manager_created:
perms = Permission.objects.filter(codename__in=head_stock_manager_permissions) perms = Permission.objects.filter(
codename__in=head_stock_manager_permissions
)
head_stock_manager.permissions.add(*perms) head_stock_manager.permissions.add(*perms)
marketing_admin, is_marketing_admin_created = Group.objects.get_or_create(name="Marketing Admin") marketing_admin, is_marketing_admin_created = Group.objects.get_or_create(
name="Marketing Admin"
)
if is_marketing_admin_created: if is_marketing_admin_created:
perms = Permission.objects.filter(codename__in=marketing_admin_permissions) perms = Permission.objects.filter(codename__in=marketing_admin_permissions)
marketing_admin.permissions.add(*perms) marketing_admin.permissions.add(*perms)
e_commerce_admin, is_e_commerce_admin_created = Group.objects.get_or_create(name="E-Commerce Admin") e_commerce_admin, is_e_commerce_admin_created = Group.objects.get_or_create(
name="E-Commerce Admin"
)
if is_e_commerce_admin_created: if is_e_commerce_admin_created:
perms = Permission.objects.filter(codename__in=e_commerce_admin_permissions) perms = Permission.objects.filter(codename__in=e_commerce_admin_permissions)
e_commerce_admin.permissions.add(*perms) e_commerce_admin.permissions.add(*perms)
valid_codes = [code for code, _ in settings.LANGUAGES] valid_codes = [code for code, _ in settings.LANGUAGES]
(User.objects.filter(Q(language="") | ~Q(language__in=valid_codes)).update(language=settings.LANGUAGE_CODE)) (
User.objects.filter(Q(language="") | ~Q(language__in=valid_codes)).update(
language=settings.LANGUAGE_CODE
)
)
try: try:
if not settings.DEBUG: if not settings.DEBUG:
initialized_path.write_text(settings.RELEASE_DATE.isoformat(), encoding="utf-8") initialized_path.write_text(
settings.RELEASE_DATE.isoformat(), encoding="utf-8"
)
except Exception as exc: except Exception as exc:
logger.error("Failed to update .initialized file: %s", exc) logger.error("Failed to update .initialized file: %s", exc)
self.stdout.write(self.style.SUCCESS("Successfully initialized must-have instances!")) self.stdout.write(
self.style.SUCCESS("Successfully initialized must-have instances!")
)

View file

@ -14,10 +14,17 @@ class Command(BaseCommand):
def reset_em(self, queryset: QuerySet[Any]) -> None: def reset_em(self, queryset: QuerySet[Any]) -> None:
total = queryset.count() total = queryset.count()
self.stdout.write(f"Starting slug rebuilding for {total} {queryset.model._meta.verbose_name_plural}") self.stdout.write(
f"Starting slug rebuilding for {total} {queryset.model._meta.verbose_name_plural}"
)
for idx, instance in enumerate(queryset.iterator(), start=1): for idx, instance in enumerate(queryset.iterator(), start=1):
try: try:
while queryset.filter(name=instance.name).exclude(uuid=instance.uuid).count() >= 1: while (
queryset.filter(name=instance.name)
.exclude(uuid=instance.uuid)
.count()
>= 1
):
instance.name = f"{instance.name} - {get_random_string(length=3, allowed_chars='0123456789')}" instance.name = f"{instance.name} - {get_random_string(length=3, allowed_chars='0123456789')}"
instance.save() instance.save()
instance.slug = None instance.slug = None

View file

@ -52,7 +52,9 @@ class Command(BaseCommand):
module = importlib.import_module(module_path) module = importlib.import_module(module_path)
model = getattr(module, model_name) model = getattr(module, model_name)
except (ImportError, AttributeError) as e: except (ImportError, AttributeError) as e:
raise CommandError(f"Could not import model '{model_name}' from '{module_path}': {e}") from e raise CommandError(
f"Could not import model '{model_name}' from '{module_path}': {e}"
) from e
dest_suffix = lang.replace("-", "_") dest_suffix = lang.replace("-", "_")
dest_field = f"{field_name}_{dest_suffix}" dest_field = f"{field_name}_{dest_suffix}"
@ -67,13 +69,17 @@ class Command(BaseCommand):
if not auth_key: if not auth_key:
raise CommandError("Environment variable DEEPL_AUTH_KEY is not set.") raise CommandError("Environment variable DEEPL_AUTH_KEY is not set.")
qs = model.objects.exclude(**{f"{field_name}__isnull": True}).exclude(**{f"{field_name}": ""}) qs = model.objects.exclude(**{f"{field_name}__isnull": True}).exclude(
**{f"{field_name}": ""}
)
total = qs.count() total = qs.count()
if total == 0: if total == 0:
self.stdout.write("No instances with non-empty source field found.") self.stdout.write("No instances with non-empty source field found.")
return return
self.stdout.write(f"Translating {total} objects from '{field_name}' into '{dest_field}'.") self.stdout.write(
f"Translating {total} objects from '{field_name}' into '{dest_field}'."
)
for obj in qs.iterator(): for obj in qs.iterator():
src_text = getattr(obj, field_name) src_text = getattr(obj, field_name)
@ -92,7 +98,9 @@ class Command(BaseCommand):
timeout=30, timeout=30,
) )
if resp.status_code != 200: if resp.status_code != 200:
self.stderr.write(f"DeepL API error for {obj.pk}: {resp.status_code} {resp.text}") self.stderr.write(
f"DeepL API error for {obj.pk}: {resp.status_code} {resp.text}"
)
continue continue
data = resp.json() data = resp.json()

View file

@ -23,7 +23,9 @@ class AddressManager(models.Manager):
resp.raise_for_status() resp.raise_for_status()
results = resp.json() results = resp.json()
if not results: if not results:
raise ValueError(f"No geocoding result for address: {kwargs.get('raw_data')}") raise ValueError(
f"No geocoding result for address: {kwargs.get('raw_data')}"
)
data = results[0] data = results[0]
addr = data.get("address", {}) addr = data.get("address", {})

View file

@ -47,7 +47,9 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
( (
@ -111,7 +113,9 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
( (
@ -216,7 +220,9 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
( (
@ -607,7 +613,9 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
( (
@ -675,13 +683,17 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
( (
"tag_name", "tag_name",
models.CharField( models.CharField(
help_text="internal tag identifier for the product tag", max_length=255, verbose_name="tag name" help_text="internal tag identifier for the product tag",
max_length=255,
verbose_name="tag name",
), ),
), ),
( (
@ -893,7 +905,9 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
( (
@ -996,7 +1010,9 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
( (
@ -1409,13 +1425,17 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
( (
"price", "price",
models.FloatField( models.FloatField(
default=0.0, help_text="final price to the customer after markups", verbose_name="selling price" default=0.0,
help_text="final price to the customer after markups",
verbose_name="selling price",
), ),
), ),
( (
@ -1492,7 +1512,9 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
( (
@ -1516,7 +1538,14 @@ class Migration(migrations.Migration):
verbose_name="vendor markup percentage", verbose_name="vendor markup percentage",
), ),
), ),
("name", models.CharField(help_text="name of this vendor", max_length=255, verbose_name="vendor name")), (
"name",
models.CharField(
help_text="name of this vendor",
max_length=255,
verbose_name="vendor name",
),
),
], ],
options={ options={
"verbose_name": "vendor", "verbose_name": "vendor",
@ -1556,7 +1585,9 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
], ],
@ -1598,13 +1629,17 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
( (
"name", "name",
models.CharField( models.CharField(
help_text="attribute group's name", max_length=255, verbose_name="attribute group's name" help_text="attribute group's name",
max_length=255,
verbose_name="attribute group's name",
), ),
), ),
( (
@ -1820,7 +1855,9 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
( (
@ -1842,115 +1879,171 @@ class Migration(migrations.Migration):
( (
"name", "name",
models.CharField( models.CharField(
help_text="name of this attribute", max_length=255, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
verbose_name="attribute's name",
), ),
), ),
( (
"name_en_GB", "name_en_GB",
models.CharField( models.CharField(
help_text="name of this attribute", max_length=255, null=True, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
), ),
), ),
( (
"name_ar_AR", "name_ar_AR",
models.CharField( models.CharField(
help_text="name of this attribute", max_length=255, null=True, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
), ),
), ),
( (
"name_cs_CZ", "name_cs_CZ",
models.CharField( models.CharField(
help_text="name of this attribute", max_length=255, null=True, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
), ),
), ),
( (
"name_da_DK", "name_da_DK",
models.CharField( models.CharField(
help_text="name of this attribute", max_length=255, null=True, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
), ),
), ),
( (
"name_de_DE", "name_de_DE",
models.CharField( models.CharField(
help_text="name of this attribute", max_length=255, null=True, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
), ),
), ),
( (
"name_en_US", "name_en_US",
models.CharField( models.CharField(
help_text="name of this attribute", max_length=255, null=True, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
), ),
), ),
( (
"name_es_ES", "name_es_ES",
models.CharField( models.CharField(
help_text="name of this attribute", max_length=255, null=True, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
), ),
), ),
( (
"name_fr_FR", "name_fr_FR",
models.CharField( models.CharField(
help_text="name of this attribute", max_length=255, null=True, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
), ),
), ),
( (
"name_hi_IN", "name_hi_IN",
models.CharField( models.CharField(
help_text="name of this attribute", max_length=255, null=True, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
), ),
), ),
( (
"name_it_IT", "name_it_IT",
models.CharField( models.CharField(
help_text="name of this attribute", max_length=255, null=True, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
), ),
), ),
( (
"name_ja_JP", "name_ja_JP",
models.CharField( models.CharField(
help_text="name of this attribute", max_length=255, null=True, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
), ),
), ),
( (
"name_kk_KZ", "name_kk_KZ",
models.CharField( models.CharField(
help_text="name of this attribute", max_length=255, null=True, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
), ),
), ),
( (
"name_nl_NL", "name_nl_NL",
models.CharField( models.CharField(
help_text="name of this attribute", max_length=255, null=True, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
), ),
), ),
( (
"name_pl_PL", "name_pl_PL",
models.CharField( models.CharField(
help_text="name of this attribute", max_length=255, null=True, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
), ),
), ),
( (
"name_pt_BR", "name_pt_BR",
models.CharField( models.CharField(
help_text="name of this attribute", max_length=255, null=True, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
), ),
), ),
( (
"name_ro_RO", "name_ro_RO",
models.CharField( models.CharField(
help_text="name of this attribute", max_length=255, null=True, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
), ),
), ),
( (
"name_ru_RU", "name_ru_RU",
models.CharField( models.CharField(
help_text="name of this attribute", max_length=255, null=True, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
), ),
), ),
( (
"name_zh_hans", "name_zh_hans",
models.CharField( models.CharField(
help_text="name of this attribute", max_length=255, null=True, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
), ),
), ),
( (
@ -2002,119 +2095,160 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
( (
"value", "value",
models.TextField(help_text="the specific value for this attribute", verbose_name="attribute value"), models.TextField(
help_text="the specific value for this attribute",
verbose_name="attribute value",
),
), ),
( (
"value_en_GB", "value_en_GB",
models.TextField( models.TextField(
help_text="the specific value for this attribute", null=True, verbose_name="attribute value" help_text="the specific value for this attribute",
null=True,
verbose_name="attribute value",
), ),
), ),
( (
"value_ar_AR", "value_ar_AR",
models.TextField( models.TextField(
help_text="the specific value for this attribute", null=True, verbose_name="attribute value" help_text="the specific value for this attribute",
null=True,
verbose_name="attribute value",
), ),
), ),
( (
"value_cs_CZ", "value_cs_CZ",
models.TextField( models.TextField(
help_text="the specific value for this attribute", null=True, verbose_name="attribute value" help_text="the specific value for this attribute",
null=True,
verbose_name="attribute value",
), ),
), ),
( (
"value_da_DK", "value_da_DK",
models.TextField( models.TextField(
help_text="the specific value for this attribute", null=True, verbose_name="attribute value" help_text="the specific value for this attribute",
null=True,
verbose_name="attribute value",
), ),
), ),
( (
"value_de_DE", "value_de_DE",
models.TextField( models.TextField(
help_text="the specific value for this attribute", null=True, verbose_name="attribute value" help_text="the specific value for this attribute",
null=True,
verbose_name="attribute value",
), ),
), ),
( (
"value_en_US", "value_en_US",
models.TextField( models.TextField(
help_text="the specific value for this attribute", null=True, verbose_name="attribute value" help_text="the specific value for this attribute",
null=True,
verbose_name="attribute value",
), ),
), ),
( (
"value_es_ES", "value_es_ES",
models.TextField( models.TextField(
help_text="the specific value for this attribute", null=True, verbose_name="attribute value" help_text="the specific value for this attribute",
null=True,
verbose_name="attribute value",
), ),
), ),
( (
"value_fr_FR", "value_fr_FR",
models.TextField( models.TextField(
help_text="the specific value for this attribute", null=True, verbose_name="attribute value" help_text="the specific value for this attribute",
null=True,
verbose_name="attribute value",
), ),
), ),
( (
"value_hi_IN", "value_hi_IN",
models.TextField( models.TextField(
help_text="the specific value for this attribute", null=True, verbose_name="attribute value" help_text="the specific value for this attribute",
null=True,
verbose_name="attribute value",
), ),
), ),
( (
"value_it_IT", "value_it_IT",
models.TextField( models.TextField(
help_text="the specific value for this attribute", null=True, verbose_name="attribute value" help_text="the specific value for this attribute",
null=True,
verbose_name="attribute value",
), ),
), ),
( (
"value_ja_JP", "value_ja_JP",
models.TextField( models.TextField(
help_text="the specific value for this attribute", null=True, verbose_name="attribute value" help_text="the specific value for this attribute",
null=True,
verbose_name="attribute value",
), ),
), ),
( (
"value_kk_KZ", "value_kk_KZ",
models.TextField( models.TextField(
help_text="the specific value for this attribute", null=True, verbose_name="attribute value" help_text="the specific value for this attribute",
null=True,
verbose_name="attribute value",
), ),
), ),
( (
"value_nl_NL", "value_nl_NL",
models.TextField( models.TextField(
help_text="the specific value for this attribute", null=True, verbose_name="attribute value" help_text="the specific value for this attribute",
null=True,
verbose_name="attribute value",
), ),
), ),
( (
"value_pl_PL", "value_pl_PL",
models.TextField( models.TextField(
help_text="the specific value for this attribute", null=True, verbose_name="attribute value" help_text="the specific value for this attribute",
null=True,
verbose_name="attribute value",
), ),
), ),
( (
"value_pt_BR", "value_pt_BR",
models.TextField( models.TextField(
help_text="the specific value for this attribute", null=True, verbose_name="attribute value" help_text="the specific value for this attribute",
null=True,
verbose_name="attribute value",
), ),
), ),
( (
"value_ro_RO", "value_ro_RO",
models.TextField( models.TextField(
help_text="the specific value for this attribute", null=True, verbose_name="attribute value" help_text="the specific value for this attribute",
null=True,
verbose_name="attribute value",
), ),
), ),
( (
"value_ru_RU", "value_ru_RU",
models.TextField( models.TextField(
help_text="the specific value for this attribute", null=True, verbose_name="attribute value" help_text="the specific value for this attribute",
null=True,
verbose_name="attribute value",
), ),
), ),
( (
"value_zh_hans", "value_zh_hans",
models.TextField( models.TextField(
help_text="the specific value for this attribute", null=True, verbose_name="attribute value" help_text="the specific value for this attribute",
null=True,
verbose_name="attribute value",
), ),
), ),
( (
@ -2166,7 +2300,9 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
( (
@ -2175,7 +2311,9 @@ class Migration(migrations.Migration):
help_text="upload an image representing this category", help_text="upload an image representing this category",
null=True, null=True,
upload_to="categories/", upload_to="categories/",
validators=[engine.core.validators.validate_category_image_dimensions], validators=[
engine.core.validators.validate_category_image_dimensions
],
verbose_name="category image", verbose_name="category image",
), ),
), ),
@ -2194,7 +2332,9 @@ class Migration(migrations.Migration):
( (
"name", "name",
models.CharField( models.CharField(
help_text="provide a name for this category", max_length=255, verbose_name="category name" help_text="provide a name for this category",
max_length=255,
verbose_name="category name",
), ),
), ),
( (
@ -2586,10 +2726,19 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
),
),
(
"name",
models.CharField(
help_text="name of this brand",
max_length=255,
verbose_name="brand name",
), ),
), ),
("name", models.CharField(help_text="name of this brand", max_length=255, verbose_name="brand name")),
( (
"category", "category",
models.ForeignKey( models.ForeignKey(
@ -2650,7 +2799,9 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
( (

View file

@ -203,7 +203,8 @@ class Migration(migrations.Migration):
migrations.AddIndex( migrations.AddIndex(
model_name="orderproduct", model_name="orderproduct",
index=django.contrib.postgres.indexes.GinIndex( index=django.contrib.postgres.indexes.GinIndex(
fields=["notifications", "attributes"], name="core_orderp_notific_cd27e9_gin" fields=["notifications", "attributes"],
name="core_orderp_notific_cd27e9_gin",
), ),
), ),
] ]

View file

@ -11,7 +11,10 @@ class Migration(migrations.Migration):
model_name="attribute", model_name="attribute",
name="name", name="name",
field=models.CharField( field=models.CharField(
help_text="name of this attribute", max_length=255, unique=True, verbose_name="attribute's name" help_text="name of this attribute",
max_length=255,
unique=True,
verbose_name="attribute's name",
), ),
), ),
migrations.AlterField( migrations.AlterField(
@ -216,7 +219,10 @@ class Migration(migrations.Migration):
model_name="attributegroup", model_name="attributegroup",
name="name", name="name",
field=models.CharField( field=models.CharField(
help_text="attribute group's name", max_length=255, unique=True, verbose_name="attribute group's name" help_text="attribute group's name",
max_length=255,
unique=True,
verbose_name="attribute group's name",
), ),
), ),
migrations.AlterField( migrations.AlterField(
@ -421,14 +427,20 @@ class Migration(migrations.Migration):
model_name="brand", model_name="brand",
name="name", name="name",
field=models.CharField( field=models.CharField(
help_text="name of this brand", max_length=255, unique=True, verbose_name="brand name" help_text="name of this brand",
max_length=255,
unique=True,
verbose_name="brand name",
), ),
), ),
migrations.AlterField( migrations.AlterField(
model_name="category", model_name="category",
name="name", name="name",
field=models.CharField( field=models.CharField(
help_text="provide a name for this category", max_length=255, unique=True, verbose_name="category name" help_text="provide a name for this category",
max_length=255,
unique=True,
verbose_name="category name",
), ),
), ),
migrations.AlterField( migrations.AlterField(
@ -1049,7 +1061,10 @@ class Migration(migrations.Migration):
model_name="vendor", model_name="vendor",
name="name", name="name",
field=models.CharField( field=models.CharField(
help_text="name of this vendor", max_length=255, unique=True, verbose_name="vendor name" help_text="name of this vendor",
max_length=255,
unique=True,
verbose_name="vendor name",
), ),
), ),
] ]

View file

@ -44,14 +44,18 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
("num_downloads", models.IntegerField(default=0)), ("num_downloads", models.IntegerField(default=0)),
( (
"order_product", "order_product",
models.OneToOneField( models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE, related_name="download", to="core.orderproduct" on_delete=django.db.models.deletion.CASCADE,
related_name="download",
to="core.orderproduct",
), ),
), ),
], ],

View file

@ -46,14 +46,23 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
),
),
(
"document",
models.FileField(
upload_to=engine.core.utils.get_product_uuid_as_path
), ),
), ),
("document", models.FileField(upload_to=engine.core.utils.get_product_uuid_as_path)),
( (
"product", "product",
models.ForeignKey( models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="documentaries", to="core.product" on_delete=django.db.models.deletion.CASCADE,
related_name="documentaries",
to="core.product",
), ),
), ),
], ],

View file

@ -8,7 +8,11 @@ def fix_duplicates(apps, schema_editor):
if schema_editor: if schema_editor:
pass pass
Order = apps.get_model("core", "Order") Order = apps.get_model("core", "Order")
duplicates = Order.objects.values("human_readable_id").annotate(count=Count("uuid")).filter(count__gt=1) duplicates = (
Order.objects.values("human_readable_id")
.annotate(count=Count("uuid"))
.filter(count__gt=1)
)
for duplicate in duplicates: for duplicate in duplicates:
h_id = duplicate["human_readable_id"] h_id = duplicate["human_readable_id"]
orders = Order.objects.filter(human_readable_id=h_id).order_by("uuid") orders = Order.objects.filter(human_readable_id=h_id).order_by("uuid")

View file

@ -47,15 +47,39 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
("street", models.CharField(max_length=255, null=True, verbose_name="street")), (
("district", models.CharField(max_length=255, null=True, verbose_name="district")), "street",
("city", models.CharField(max_length=100, null=True, verbose_name="city")), models.CharField(max_length=255, null=True, verbose_name="street"),
("region", models.CharField(max_length=100, null=True, verbose_name="region")), ),
("postal_code", models.CharField(max_length=20, null=True, verbose_name="postal code")), (
("country", models.CharField(max_length=40, null=True, verbose_name="country")), "district",
models.CharField(
max_length=255, null=True, verbose_name="district"
),
),
(
"city",
models.CharField(max_length=100, null=True, verbose_name="city"),
),
(
"region",
models.CharField(max_length=100, null=True, verbose_name="region"),
),
(
"postal_code",
models.CharField(
max_length=20, null=True, verbose_name="postal code"
),
),
(
"country",
models.CharField(max_length=40, null=True, verbose_name="country"),
),
( (
"location", "location",
django.contrib.gis.db.models.fields.PointField( django.contrib.gis.db.models.fields.PointField(
@ -69,26 +93,37 @@ class Migration(migrations.Migration):
( (
"raw_data", "raw_data",
models.JSONField( models.JSONField(
blank=True, help_text="full JSON response from geocoder for this address", null=True blank=True,
help_text="full JSON response from geocoder for this address",
null=True,
), ),
), ),
( (
"api_response", "api_response",
models.JSONField( models.JSONField(
blank=True, help_text="stored JSON response from the geocoding service", null=True blank=True,
help_text="stored JSON response from the geocoding service",
null=True,
), ),
), ),
( (
"user", "user",
models.ForeignKey( models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
), ),
), ),
], ],
options={ options={
"verbose_name": "address", "verbose_name": "address",
"verbose_name_plural": "addresses", "verbose_name_plural": "addresses",
"indexes": [models.Index(fields=["location"], name="core_addres_locatio_eb6b39_idx")], "indexes": [
models.Index(
fields=["location"], name="core_addres_locatio_eb6b39_idx"
)
],
}, },
), ),
] ]

View file

@ -24,7 +24,12 @@ class Migration(migrations.Migration):
model_name="category", model_name="category",
name="slug", name="slug",
field=django_extensions.db.fields.AutoSlugField( field=django_extensions.db.fields.AutoSlugField(
allow_unicode=True, blank=True, editable=False, null=True, populate_from=("uuid", "name"), unique=True allow_unicode=True,
blank=True,
editable=False,
null=True,
populate_from=("uuid", "name"),
unique=True,
), ),
), ),
migrations.RunPython(populate_slugs, reverse_code=migrations.RunPython.noop), migrations.RunPython(populate_slugs, reverse_code=migrations.RunPython.noop),

View file

@ -11,7 +11,10 @@ class Migration(migrations.Migration):
model_name="address", model_name="address",
name="address_line", name="address_line",
field=models.TextField( field=models.TextField(
blank=True, help_text="address line for the customer", null=True, verbose_name="address line" blank=True,
help_text="address line for the customer",
null=True,
verbose_name="address line",
), ),
), ),
] ]

View file

@ -44,13 +44,17 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
( (
"tag_name", "tag_name",
models.CharField( models.CharField(
help_text="internal tag identifier for the product tag", max_length=255, verbose_name="tag name" help_text="internal tag identifier for the product tag",
max_length=255,
verbose_name="tag name",
), ),
), ),
( (
@ -247,7 +251,10 @@ class Migration(migrations.Migration):
"verbose_name": "category tag", "verbose_name": "category tag",
"verbose_name_plural": "category tags", "verbose_name_plural": "category tags",
}, },
bases=(django_prometheus.models.ExportModelOperationsMixin("category_tag"), models.Model), bases=(
django_prometheus.models.ExportModelOperationsMixin("category_tag"),
models.Model,
),
), ),
migrations.AddField( migrations.AddField(
model_name="category", model_name="category",

View file

@ -1,7 +1,8 @@
import engine.core.utils.db
import django_extensions.db.fields import django_extensions.db.fields
from django.db import migrations from django.db import migrations
import engine.core.utils.db
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [

View file

@ -1,7 +1,8 @@
import engine.core.utils
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import engine.core.utils
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -23,6 +24,8 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name="vendor", model_name="vendor",
name="users", name="users",
field=models.ManyToManyField(blank=True, related_name="vendors", to=settings.AUTH_USER_MODEL), field=models.ManyToManyField(
blank=True, related_name="vendors", to=settings.AUTH_USER_MODEL
),
), ),
] ]

View file

@ -21,14 +21,18 @@ def backfill_sku(apps, schema_editor):
while True: while True:
ids = list( ids = list(
Product.objects.filter(sku__isnull=True, pk__gt=last_pk).order_by("pk").values_list("pk", flat=True)[:BATCH] Product.objects.filter(sku__isnull=True, pk__gt=last_pk)
.order_by("pk")
.values_list("pk", flat=True)[:BATCH]
) )
if not ids: if not ids:
break break
updates = [] updates = []
for pk in ids: for pk in ids:
updates.append(Product(pk=pk, sku=generate_unique_sku(make_candidate, taken))) updates.append(
Product(pk=pk, sku=generate_unique_sku(make_candidate, taken))
)
with transaction.atomic(): with transaction.atomic():
Product.objects.bulk_update(updates, ["sku"], batch_size=BATCH) Product.objects.bulk_update(updates, ["sku"], batch_size=BATCH)

View file

@ -1,6 +1,7 @@
import engine.core.utils
from django.db import migrations, models from django.db import migrations, models
import engine.core.utils
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [

View file

@ -1,7 +1,8 @@
import uuid
import django.db.models.deletion import django.db.models.deletion
import django_extensions.db.fields import django_extensions.db.fields
import django_prometheus.models import django_prometheus.models
import uuid
from django.db import migrations, models from django.db import migrations, models
@ -55,11 +56,15 @@ class Migration(migrations.Migration):
), ),
( (
"integration_url", "integration_url",
models.URLField(blank=True, help_text="URL of the integration", null=True), models.URLField(
blank=True, help_text="URL of the integration", null=True
),
), ),
( (
"authentication", "authentication",
models.JSONField(blank=True, help_text="authentication credentials", null=True), models.JSONField(
blank=True, help_text="authentication credentials", null=True
),
), ),
( (
"attributes", "attributes",

View file

@ -1,6 +1,7 @@
import engine.core.utils
from django.db import migrations, models from django.db import migrations, models
import engine.core.utils
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [

View file

@ -329,19 +329,27 @@ class Migration(migrations.Migration):
), ),
migrations.AddIndex( migrations.AddIndex(
model_name="order", model_name="order",
index=models.Index(fields=["user", "status"], name="core_order_user_id_4407f8_idx"), index=models.Index(
fields=["user", "status"], name="core_order_user_id_4407f8_idx"
),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name="order", model_name="order",
index=models.Index(fields=["status", "buy_time"], name="core_order_status_4a088a_idx"), index=models.Index(
fields=["status", "buy_time"], name="core_order_status_4a088a_idx"
),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name="orderproduct", model_name="orderproduct",
index=models.Index(fields=["order", "status"], name="core_orderp_order_i_d16192_idx"), index=models.Index(
fields=["order", "status"], name="core_orderp_order_i_d16192_idx"
),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name="orderproduct", model_name="orderproduct",
index=models.Index(fields=["product", "status"], name="core_orderp_product_ee8abb_idx"), index=models.Index(
fields=["product", "status"], name="core_orderp_product_ee8abb_idx"
),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name="product", model_name="product",

View file

@ -10,6 +10,8 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name="stock", model_name="stock",
name="system_attributes", name="system_attributes",
field=models.JSONField(blank=True, default=dict, verbose_name="system attributes"), field=models.JSONField(
blank=True, default=dict, verbose_name="system attributes"
),
), ),
] ]

View file

@ -121,7 +121,9 @@ class Vendor(ExportModelOperationsMixin("vendor"), NiceModel): # type: ignore [
authentication = JSONField( authentication = JSONField(
blank=True, blank=True,
null=True, null=True,
help_text=_("stores credentials and endpoints required for vendor communication"), help_text=_(
"stores credentials and endpoints required for vendor communication"
),
verbose_name=_("authentication info"), verbose_name=_("authentication info"),
) )
markup_percent = IntegerField( markup_percent = IntegerField(
@ -138,8 +140,12 @@ class Vendor(ExportModelOperationsMixin("vendor"), NiceModel): # type: ignore [
null=False, null=False,
unique=True, unique=True,
) )
users = ManyToManyField(to=settings.AUTH_USER_MODEL, related_name="vendors", blank=True) users = ManyToManyField(
b2b_auth_token = CharField(default=generate_human_readable_token, max_length=20, null=True, blank=True) to=settings.AUTH_USER_MODEL, related_name="vendors", blank=True
)
b2b_auth_token = CharField(
default=generate_human_readable_token, max_length=20, null=True, blank=True
)
last_processing_response = FileField( last_processing_response = FileField(
upload_to=get_vendor_name_as_path, upload_to=get_vendor_name_as_path,
blank=True, blank=True,
@ -339,10 +345,15 @@ class Category(ExportModelOperationsMixin("category"), NiceModel, MPTTModel): #
def get_tree_depth(self): def get_tree_depth(self):
if self.is_leaf_node(): if self.is_leaf_node():
return 0 return 0
return self.get_descendants().aggregate(max_depth=Max("level"))["max_depth"] - self.get_level() return (
self.get_descendants().aggregate(max_depth=Max("level"))["max_depth"]
- self.get_level()
)
@classmethod @classmethod
def bulk_prefetch_filterable_attributes(cls, categories: Iterable["Category"]) -> None: def bulk_prefetch_filterable_attributes(
cls, categories: Iterable["Category"]
) -> None:
cat_list = [c for c in categories] cat_list = [c for c in categories]
if not cat_list: if not cat_list:
return return
@ -383,7 +394,10 @@ class Category(ExportModelOperationsMixin("category"), NiceModel, MPTTModel): #
"value_type": value_type, "value_type": value_type,
} }
cat_bucket[attr_id] = bucket cat_bucket[attr_id] = bucket
if len(bucket["possible_values"]) < 128 and value not in bucket["possible_values"]: if (
len(bucket["possible_values"]) < 128
and value not in bucket["possible_values"]
):
bucket["possible_values"].append(value) bucket["possible_values"].append(value)
for c in cat_list: for c in cat_list:
@ -401,7 +415,9 @@ class Category(ExportModelOperationsMixin("category"), NiceModel, MPTTModel): #
attribute__is_filterable=True, attribute__is_filterable=True,
value_length__lte=30, value_length__lte=30,
) )
.values_list("attribute_id", "attribute__name", "attribute__value_type", "value") .values_list(
"attribute_id", "attribute__name", "attribute__value_type", "value"
)
.distinct() .distinct()
) )
@ -415,7 +431,10 @@ class Category(ExportModelOperationsMixin("category"), NiceModel, MPTTModel): #
"value_type": value_type, "value_type": value_type,
} }
by_attr[attr_id] = bucket by_attr[attr_id] = bucket
if len(bucket["possible_values"]) < 128 and value not in bucket["possible_values"]: if (
len(bucket["possible_values"]) < 128
and value not in bucket["possible_values"]
):
bucket["possible_values"].append(value) bucket["possible_values"].append(value)
return list(by_attr.values()) # type: ignore [arg-type] return list(by_attr.values()) # type: ignore [arg-type]
@ -557,7 +576,9 @@ class Stock(ExportModelOperationsMixin("stock"), NiceModel): # type: ignore [mi
verbose_name=_("digital file"), verbose_name=_("digital file"),
upload_to="downloadables/", upload_to="downloadables/",
) )
system_attributes = JSONField(default=dict, verbose_name=_("system attributes"), blank=True) system_attributes = JSONField(
default=dict, verbose_name=_("system attributes"), blank=True
)
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.vendor.name} - {self.product!s}" return f"{self.vendor.name} - {self.product!s}"
@ -756,7 +777,9 @@ class Attribute(ExportModelOperationsMixin("attribute"), NiceModel): # type: ig
is_filterable = BooleanField( is_filterable = BooleanField(
default=True, default=True,
verbose_name=_("is filterable"), verbose_name=_("is filterable"),
help_text=_("designates whether this attribute can be used for filtering or not"), help_text=_(
"designates whether this attribute can be used for filtering or not"
),
) )
def __str__(self): def __str__(self):
@ -991,7 +1014,9 @@ class Documentary(ExportModelOperationsMixin("attribute_group"), NiceModel): #
is_publicly_visible = True is_publicly_visible = True
product = ForeignKey(to="core.Product", on_delete=CASCADE, related_name="documentaries") product = ForeignKey(
to="core.Product", on_delete=CASCADE, related_name="documentaries"
)
document = FileField(upload_to=get_product_uuid_as_path) document = FileField(upload_to=get_product_uuid_as_path)
class Meta: class Meta:
@ -1044,7 +1069,11 @@ class Address(ExportModelOperationsMixin("address"), NiceModel): # type: ignore
help_text=_("geolocation point: (longitude, latitude)"), help_text=_("geolocation point: (longitude, latitude)"),
) )
raw_data = JSONField(blank=True, null=True, help_text=_("full JSON response from geocoder for this address")) raw_data = JSONField(
blank=True,
null=True,
help_text=_("full JSON response from geocoder for this address"),
)
api_response = JSONField( api_response = JSONField(
blank=True, blank=True,
@ -1052,7 +1081,9 @@ class Address(ExportModelOperationsMixin("address"), NiceModel): # type: ignore
help_text=_("stored JSON response from the geocoding service"), help_text=_("stored JSON response from the geocoding service"),
) )
user = ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=CASCADE, blank=True, null=True) user = ForeignKey(
to=settings.AUTH_USER_MODEL, on_delete=CASCADE, blank=True, null=True
)
objects = AddressManager() objects = AddressManager()
@ -1147,7 +1178,9 @@ class PromoCode(ExportModelOperationsMixin("promocode"), NiceModel): # type: ig
self.discount_amount is None and self.discount_percent is None self.discount_amount is None and self.discount_percent is None
): ):
raise ValidationError( raise ValidationError(
_("only one type of discount should be defined (amount or percent), but not both or neither.") _(
"only one type of discount should be defined (amount or percent), but not both or neither."
)
) )
return super().save( return super().save(
force_insert=force_insert, force_insert=force_insert,
@ -1176,12 +1209,18 @@ class PromoCode(ExportModelOperationsMixin("promocode"), NiceModel): # type: ig
promo_amount = order.total_price promo_amount = order.total_price
if self.discount_type == "percent": if self.discount_type == "percent":
promo_amount -= round(promo_amount * (float(self.discount_percent) / 100), 2) # type: ignore [arg-type] promo_amount -= round(
order.attributes.update({"promocode_uuid": str(self.uuid), "final_price": promo_amount}) promo_amount * (float(self.discount_percent) / 100), 2
) # type: ignore [arg-type]
order.attributes.update(
{"promocode_uuid": str(self.uuid), "final_price": promo_amount}
)
order.save() order.save()
elif self.discount_type == "amount": elif self.discount_type == "amount":
promo_amount -= round(float(self.discount_amount), 2) # type: ignore [arg-type] promo_amount -= round(float(self.discount_amount), 2) # type: ignore [arg-type]
order.attributes.update({"promocode_uuid": str(self.uuid), "final_price": promo_amount}) order.attributes.update(
{"promocode_uuid": str(self.uuid), "final_price": promo_amount}
)
order.save() order.save()
else: else:
raise ValueError(_(f"invalid discount type for promocode {self.uuid}")) raise ValueError(_(f"invalid discount type for promocode {self.uuid}"))
@ -1296,8 +1335,13 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
self.user.save() self.user.save()
return False return False
with suppress(Exception): with suppress(Exception):
return (self.attributes.get("is_business", False) if self.attributes else False) or ( return (
(self.user.attributes.get("is_business", False) and self.user.attributes.get("business_identificator")) # type: ignore [union-attr] 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")
) # type: ignore [union-attr]
if self.user if self.user
else False else False
) )
@ -1348,7 +1392,9 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
attributes = [] attributes = []
if self.status not in ["PENDING", "MOMENTAL"]: if self.status not in ["PENDING", "MOMENTAL"]:
raise ValueError(_("you cannot add products to an order that is not a pending one")) raise ValueError(
_("you cannot add products to an order that is not a pending one")
)
try: try:
product = Product.objects.get(uuid=product_uuid) product = Product.objects.get(uuid=product_uuid)
@ -1357,10 +1403,14 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
buy_price = product.price buy_price = product.price
promotions = Promotion.objects.filter(is_active=True, products__in=[product]).order_by("discount_percent") promotions = Promotion.objects.filter(
is_active=True, products__in=[product]
).order_by("discount_percent")
if promotions.exists(): if promotions.exists():
buy_price -= round(product.price * (promotions.first().discount_percent / 100), 2) # type: ignore [union-attr] buy_price -= round(
product.price * (promotions.first().discount_percent / 100), 2
) # type: ignore [union-attr]
order_product, is_created = OrderProduct.objects.get_or_create( order_product, is_created = OrderProduct.objects.get_or_create(
product=product, product=product,
@ -1370,7 +1420,9 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
) )
if not is_created and update_quantity: if not is_created and update_quantity:
if product.quantity < order_product.quantity + 1: if product.quantity < order_product.quantity + 1:
raise BadRequest(_("you cannot add more products than available in stock")) raise BadRequest(
_("you cannot add more products than available in stock")
)
order_product.quantity += 1 order_product.quantity += 1
order_product.buy_price = product.price order_product.buy_price = product.price
order_product.save() order_product.save()
@ -1392,7 +1444,9 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
attributes = {} attributes = {}
if self.status not in ["PENDING", "MOMENTAL"]: if self.status not in ["PENDING", "MOMENTAL"]:
raise ValueError(_("you cannot remove products from an order that is not a pending one")) raise ValueError(
_("you cannot remove products from an order that is not a pending one")
)
try: try:
product = Product.objects.get(uuid=product_uuid) product = Product.objects.get(uuid=product_uuid)
order_product = self.order_products.get(product=product, order=self) order_product = self.order_products.get(product=product, order=self)
@ -1412,12 +1466,16 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
raise Http404(_(f"{name} does not exist: {uuid}")) from dne raise Http404(_(f"{name} does not exist: {uuid}")) from dne
except OrderProduct.DoesNotExist as dne: except OrderProduct.DoesNotExist as dne:
name = "OrderProduct" name = "OrderProduct"
query = f"product: {product_uuid}, order: {self.uuid}, attributes: {attributes}" query = (
f"product: {product_uuid}, order: {self.uuid}, attributes: {attributes}"
)
raise Http404(_(f"{name} does not exist with query <{query}>")) from dne raise Http404(_(f"{name} does not exist with query <{query}>")) from dne
def remove_all_products(self): def remove_all_products(self):
if self.status not in ["PENDING", "MOMENTAL"]: if self.status not in ["PENDING", "MOMENTAL"]:
raise ValueError(_("you cannot remove products from an order that is not a pending one")) raise ValueError(
_("you cannot remove products from an order that is not a pending one")
)
for order_product in self.order_products.all(): for order_product in self.order_products.all():
self.order_products.remove(order_product) self.order_products.remove(order_product)
order_product.delete() order_product.delete()
@ -1425,7 +1483,9 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
def remove_products_of_a_kind(self, product_uuid): def remove_products_of_a_kind(self, product_uuid):
if self.status not in ["PENDING", "MOMENTAL"]: if self.status not in ["PENDING", "MOMENTAL"]:
raise ValueError(_("you cannot remove products from an order that is not a pending one")) raise ValueError(
_("you cannot remove products from an order that is not a pending one")
)
try: try:
product = Product.objects.get(uuid=product_uuid) product = Product.objects.get(uuid=product_uuid)
order_product = self.order_products.get(product=product, order=self) order_product = self.order_products.get(product=product, order=self)
@ -1439,7 +1499,10 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
@property @property
def is_whole_digital(self): def is_whole_digital(self):
return self.order_products.count() == self.order_products.filter(product__is_digital=True).count() return (
self.order_products.count()
== self.order_products.filter(product__is_digital=True).count()
)
def apply_promocode(self, promocode_uuid): def apply_promocode(self, promocode_uuid):
try: try:
@ -1448,10 +1511,21 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
raise Http404(_("promocode does not exist")) from dne raise Http404(_("promocode does not exist")) from dne
return promocode.use(self) return promocode.use(self)
def apply_addresses(self, billing_address_uuid: str | None = None, shipping_address_uuid: str | None = None): def apply_addresses(
self,
billing_address_uuid: str | None = None,
shipping_address_uuid: str | None = None,
):
try: try:
if not any([shipping_address_uuid, billing_address_uuid]) and not self.is_whole_digital: if (
raise ValueError(_("you can only buy physical products with shipping address specified")) not any([shipping_address_uuid, billing_address_uuid])
and not self.is_whole_digital
):
raise ValueError(
_(
"you can only buy physical products with shipping address specified"
)
)
if billing_address_uuid and not shipping_address_uuid: if billing_address_uuid and not shipping_address_uuid:
shipping_address = Address.objects.get(uuid=billing_address_uuid) shipping_address = Address.objects.get(uuid=billing_address_uuid)
@ -1491,9 +1565,13 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
order.bulk_add_products(chosen_products, update_quantity=True) order.bulk_add_products(chosen_products, update_quantity=True)
if config.DISABLED_COMMERCE: if config.DISABLED_COMMERCE:
raise DisabledCommerceError(_("you can not buy at this moment, please try again in a few minutes")) raise DisabledCommerceError(
_("you can not buy at this moment, please try again in a few minutes")
)
if (not force_balance and not force_payment) or (force_balance and force_payment): if (not force_balance and not force_payment) or (
force_balance and force_payment
):
raise ValueError(_("invalid force value")) raise ValueError(_("invalid force value"))
if any([billing_address, shipping_address]): if any([billing_address, shipping_address]):
@ -1512,7 +1590,9 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
amount = self.attributes.get("final_amount") or order.total_price amount = self.attributes.get("final_amount") or order.total_price
if self.attributes.get("promocode_uuid") and not self.attributes.get("final_amount"): if self.attributes.get("promocode_uuid") and not self.attributes.get(
"final_amount"
):
amount = order.apply_promocode(self.attributes.get("promocode_uuid")) amount = order.apply_promocode(self.attributes.get("promocode_uuid"))
if promocode_uuid and not self.attributes.get("final_amount"): if promocode_uuid and not self.attributes.get("final_amount"):
@ -1522,9 +1602,9 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
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 type(order.user.attributes) is dict:
if order.user.attributes.get("is_business", False) or order.user.attributes.get( if order.user.attributes.get(
"business_identificator", "" "is_business", False
): ) or order.user.attributes.get("business_identificator", ""):
if type(order.attributes) is not dict: if type(order.attributes) is not dict:
order.attributes = {} order.attributes = {}
order.attributes.update({"is_business": True}) order.attributes.update({"is_business": True})
@ -1538,7 +1618,9 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
match force: match force:
case "balance": case "balance":
if order.user.payments_balance.amount < amount: if order.user.payments_balance.amount < amount:
raise NotEnoughMoneyError(_("insufficient funds to complete the order")) raise NotEnoughMoneyError(
_("insufficient funds to complete the order")
)
with transaction.atomic(): with transaction.atomic():
order.status = "CREATED" order.status = "CREATED"
order.buy_time = timezone.now() order.buy_time = timezone.now()
@ -1558,14 +1640,20 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
case _: case _:
raise ValueError(_("invalid force value")) 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:
raise DisabledCommerceError(_("you can not buy at this moment, please try again in a few minutes")) raise DisabledCommerceError(
_("you can not buy at this moment, please try again in a few minutes")
)
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") or kwargs.get("business_identificator") 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")
@ -1581,17 +1669,25 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
available_payment_methods = cache.get("payment_methods").get("payment_methods") available_payment_methods = cache.get("payment_methods").get("payment_methods")
if payment_method not in available_payment_methods: if payment_method not in available_payment_methods:
raise ValueError(_(f"invalid payment method: {payment_method} from {available_payment_methods}")) raise ValueError(
_(
f"invalid payment method: {payment_method} from {available_payment_methods}"
)
)
billing_customer_address_uuid = kwargs.get("billing_customer_address") billing_customer_address_uuid = kwargs.get("billing_customer_address")
shipping_customer_address_uuid = kwargs.get("shipping_customer_address") shipping_customer_address_uuid = kwargs.get("shipping_customer_address")
self.apply_addresses(billing_customer_address_uuid, shipping_customer_address_uuid) self.apply_addresses(
billing_customer_address_uuid, shipping_customer_address_uuid
)
for product_uuid in products: for product_uuid in products:
self.add_product(product_uuid) self.add_product(product_uuid)
amount = self.apply_promocode(promocode_uuid) if promocode_uuid else self.total_price amount = (
self.apply_promocode(promocode_uuid) if promocode_uuid else self.total_price
)
self.status = "CREATED" self.status = "CREATED"
@ -1635,7 +1731,9 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
def update_order_products_statuses(self, status: str = "PENDING"): def update_order_products_statuses(self, status: str = "PENDING"):
self.order_products.update(status=status) self.order_products.update(status=status)
def bulk_add_products(self, products: list[dict[str, Any]], update_quantity: bool = False): def bulk_add_products(
self, products: list[dict[str, Any]], update_quantity: bool = False
):
for product in products: for product in products:
self.add_product( self.add_product(
product.get("uuid") or product.get("product_uuid"), product.get("uuid") or product.get("product_uuid"),
@ -1657,7 +1755,9 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
crm_links = OrderCrmLink.objects.filter(order=self) crm_links = OrderCrmLink.objects.filter(order=self)
if crm_links.exists(): if crm_links.exists():
crm_link = crm_links.first() crm_link = crm_links.first()
crm_integration = create_object(crm_link.crm.integration_location, crm_link.crm.name) crm_integration = create_object(
crm_link.crm.integration_location, crm_link.crm.name
)
try: try:
crm_integration.process_order_changes(self) crm_integration.process_order_changes(self)
return True return True
@ -1678,19 +1778,25 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
return True return True
except Exception as e: except Exception as e:
logger.error( logger.error(
"failed to trigger CRM integration %s for order %s: %s", crm.name, self.uuid, str(e), exc_info=True "failed to trigger CRM integration %s for order %s: %s",
crm.name,
self.uuid,
str(e),
exc_info=True,
) )
return False return False
@property @property
def business_identificator(self) -> str | None: def business_identificator(self) -> str | None:
if self.attributes: if self.attributes:
return self.attributes.get("business_identificator") or self.attributes.get("businessIdentificator") return self.attributes.get("business_identificator") or self.attributes.get(
"businessIdentificator"
)
if self.user: if self.user:
if self.user.attributes: if self.user.attributes:
return self.user.attributes.get("business_identificator") or self.user.attributes.get( return self.user.attributes.get(
"businessIdentificator" "business_identificator"
) ) or self.user.attributes.get("businessIdentificator")
return None return None
@ -1716,7 +1822,9 @@ class Feedback(ExportModelOperationsMixin("feedback"), NiceModel): # type: igno
on_delete=CASCADE, on_delete=CASCADE,
blank=False, blank=False,
null=False, null=False,
help_text=_("references the specific product in an order that this feedback is about"), help_text=_(
"references the specific product in an order that this feedback is about"
),
verbose_name=_("related order product"), verbose_name=_("related order product"),
) )
rating = FloatField( rating = FloatField(
@ -1728,7 +1836,11 @@ class Feedback(ExportModelOperationsMixin("feedback"), NiceModel): # type: igno
) )
def __str__(self) -> str: def __str__(self) -> str:
if self.order_product and self.order_product.order and self.order_product.order.user: if (
self.order_product
and self.order_product.order
and self.order_product.order.user
):
return f"{self.rating} by {self.order_product.order.user.email}" return f"{self.rating} by {self.order_product.order.user.email}"
return f"{self.rating} | {self.uuid}" return f"{self.rating} | {self.uuid}"
@ -1838,7 +1950,9 @@ class OrderProduct(ExportModelOperationsMixin("order_product"), NiceModel): # t
"errors": [ "errors": [
{ {
"detail": ( "detail": (
error if error else f"Something went wrong with {self.uuid} for some reason..." error
if error
else f"Something went wrong with {self.uuid} for some reason..."
) )
}, },
] ]
@ -1883,17 +1997,27 @@ class OrderProduct(ExportModelOperationsMixin("order_product"), NiceModel): # t
if action == "add": if action == "add":
if not feedback_exists: if not feedback_exists:
if self.order.status == "FINISHED": if self.order.status == "FINISHED":
return Feedback.objects.create(rating=rating, comment=comment, order_product=self) return Feedback.objects.create(
rating=rating, comment=comment, order_product=self
)
else: else:
raise ValueError(_("you cannot feedback an order which is not received")) raise ValueError(
_("you cannot feedback an order which is not received")
)
return None return None
class CustomerRelationshipManagementProvider(ExportModelOperationsMixin("crm_provider"), NiceModel): # type: ignore [misc] class CustomerRelationshipManagementProvider(
ExportModelOperationsMixin("crm_provider"), NiceModel
): # type: ignore [misc]
name = CharField(max_length=128, unique=True, verbose_name=_("name")) name = CharField(max_length=128, unique=True, verbose_name=_("name"))
integration_url = URLField(blank=True, null=True, help_text=_("URL of the integration")) integration_url = URLField(
authentication = JSONField(blank=True, null=True, help_text=_("authentication credentials")) 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")) attributes = JSONField(blank=True, null=True, verbose_name=_("attributes"))
integration_location = CharField(max_length=128, blank=True, null=True) integration_location = CharField(max_length=128, blank=True, null=True)
default = BooleanField(default=False) default = BooleanField(default=False)
@ -1931,7 +2055,11 @@ class CustomerRelationshipManagementProvider(ExportModelOperationsMixin("crm_pro
class OrderCrmLink(ExportModelOperationsMixin("order_crm_link"), NiceModel): # type: ignore class OrderCrmLink(ExportModelOperationsMixin("order_crm_link"), NiceModel): # type: ignore
order = ForeignKey(to=Order, on_delete=PROTECT, related_name="crm_links") order = ForeignKey(to=Order, on_delete=PROTECT, related_name="crm_links")
crm = ForeignKey(to=CustomerRelationshipManagementProvider, on_delete=PROTECT, related_name="order_links") crm = ForeignKey(
to=CustomerRelationshipManagementProvider,
on_delete=PROTECT,
related_name="order_links",
)
crm_lead_id = CharField(max_length=30, unique=True, db_index=True) crm_lead_id = CharField(max_length=30, unique=True, db_index=True)
def __str__(self) -> str: def __str__(self) -> str:
@ -1954,7 +2082,9 @@ class DigitalAssetDownload(ExportModelOperationsMixin("attribute_group"), NiceMo
is_publicly_visible = False is_publicly_visible = False
order_product = OneToOneField(to=OrderProduct, on_delete=CASCADE, related_name="download") order_product = OneToOneField(
to=OrderProduct, on_delete=CASCADE, related_name="download"
)
num_downloads = IntegerField(default=0) num_downloads = IntegerField(default=0)
class Meta: class Meta:
@ -1966,6 +2096,4 @@ class DigitalAssetDownload(ExportModelOperationsMixin("attribute_group"), NiceMo
@property @property
def url(self): def url(self):
return ( return f"https://api.{settings.BASE_DOMAIN}/download/{urlsafe_base64_encode(force_bytes(self.order_product.uuid))}"
f"https://api.{settings.BASE_DOMAIN}/download/{urlsafe_base64_encode(force_bytes(self.order_product.uuid))}"
)

View file

@ -60,10 +60,15 @@ class EvibesPermission(permissions.BasePermission):
return True return True
perm_prefix = self.ACTION_PERM_MAP.get(action) perm_prefix = self.ACTION_PERM_MAP.get(action)
if perm_prefix and request.user.has_perm(f"{app_label}.{perm_prefix}_{model_name}"): if perm_prefix and request.user.has_perm(
f"{app_label}.{perm_prefix}_{model_name}"
):
return True return True
return bool(action in ("list", "retrieve") and getattr(model, "is_publicly_visible", False)) return bool(
action in ("list", "retrieve")
and getattr(model, "is_publicly_visible", False)
)
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS: if request.method in permissions.SAFE_METHODS:
@ -76,7 +81,10 @@ class EvibesPermission(permissions.BasePermission):
model_name = obj._meta.model_name model_name = obj._meta.model_name
action = view.action action = view.action
perm_prefix = self.ACTION_PERM_MAP.get(action) perm_prefix = self.ACTION_PERM_MAP.get(action)
return bool(perm_prefix and request.user.has_perm(f"{app_label}.{perm_prefix}_{model_name}")) return bool(
perm_prefix
and request.user.has_perm(f"{app_label}.{perm_prefix}_{model_name}")
)
perm_prefix = self.ACTION_PERM_MAP.get(view.action) perm_prefix = self.ACTION_PERM_MAP.get(view.action)
return bool( return bool(
@ -100,7 +108,9 @@ class EvibesPermission(permissions.BasePermission):
return queryset.none() return queryset.none()
base = queryset.filter(is_active=True, user=request.user) base = queryset.filter(is_active=True, user=request.user)
if request.user.has_perm(f"{app_label}.{self.ACTION_PERM_MAP.get(view.action)}_{model_name}"): if request.user.has_perm(
f"{app_label}.{self.ACTION_PERM_MAP.get(view.action)}_{model_name}"
):
return queryset.filter(is_active=True) return queryset.filter(is_active=True)
return base return base

View file

@ -25,7 +25,10 @@ from engine.core.models import (
Vendor, Vendor,
Wishlist, Wishlist,
) )
from engine.core.serializers.simple import CategorySimpleSerializer, ProductSimpleSerializer from engine.core.serializers.simple import (
CategorySimpleSerializer,
ProductSimpleSerializer,
)
from engine.core.serializers.utility import AddressSerializer from engine.core.serializers.utility import AddressSerializer
from engine.core.typing import FilterableAttribute from engine.core.typing import FilterableAttribute
@ -85,7 +88,11 @@ class CategoryDetailSerializer(ModelSerializer):
else: else:
children = obj.children.filter(is_active=True) children = obj.children.filter(is_active=True)
return CategorySimpleSerializer(children, many=True, context=self.context).data if obj.children.exists() else [] # type: ignore [return-value] return (
CategorySimpleSerializer(children, many=True, context=self.context).data
if obj.children.exists()
else []
) # type: ignore [return-value]
class BrandDetailSerializer(ModelSerializer): class BrandDetailSerializer(ModelSerializer):

View file

@ -59,7 +59,11 @@ class CategorySimpleSerializer(ModelSerializer): # type: ignore [type-arg]
else: else:
children = obj.children.filter(is_active=True) children = obj.children.filter(is_active=True)
return CategorySimpleSerializer(children, many=True, context=self.context).data if obj.children.exists() else [] # type: ignore [return-value] return (
CategorySimpleSerializer(children, many=True, context=self.context).data
if obj.children.exists()
else []
) # type: ignore [return-value]
class BrandSimpleSerializer(ModelSerializer): # type: ignore [type-arg] class BrandSimpleSerializer(ModelSerializer): # type: ignore [type-arg]

View file

@ -86,7 +86,11 @@ class DoFeedbackSerializer(Serializer): # type: ignore [type-arg]
def validate(self, data: dict[str, Any]) -> dict[str, Any]: def validate(self, data: dict[str, Any]) -> dict[str, Any]:
if data["action"] == "add" and not all([data["comment"], data["rating"]]): if data["action"] == "add" and not all([data["comment"], data["rating"]]):
raise ValidationError(_("you must provide a comment, rating, and order product uuid to add feedback.")) raise ValidationError(
_(
"you must provide a comment, rating, and order product uuid to add feedback."
)
)
return data return data
@ -150,11 +154,15 @@ class RemoveWishlistProductSerializer(Serializer): # type: ignore [type-arg]
class BulkAddWishlistProductSerializer(Serializer): # type: ignore [type-arg] class BulkAddWishlistProductSerializer(Serializer): # type: ignore [type-arg]
product_uuids = ListField(child=CharField(required=True), allow_empty=False, max_length=64) product_uuids = ListField(
child=CharField(required=True), allow_empty=False, max_length=64
)
class BulkRemoveWishlistProductSerializer(Serializer): # type: ignore [type-arg] class BulkRemoveWishlistProductSerializer(Serializer): # type: ignore [type-arg]
product_uuids = ListField(child=CharField(required=True), allow_empty=False, max_length=64) product_uuids = ListField(
child=CharField(required=True), allow_empty=False, max_length=64
)
class BuyOrderSerializer(Serializer): # type: ignore [type-arg] class BuyOrderSerializer(Serializer): # type: ignore [type-arg]

View file

@ -13,21 +13,34 @@ from sentry_sdk import capture_exception
from engine.core.crm import any_crm_integrations from engine.core.crm import any_crm_integrations
from engine.core.crm.exceptions import CRMException from engine.core.crm.exceptions import CRMException
from engine.core.models import Category, DigitalAssetDownload, Order, Product, PromoCode, Wishlist from engine.core.models import (
Category,
DigitalAssetDownload,
Order,
Product,
PromoCode,
Wishlist,
)
from engine.core.utils import ( from engine.core.utils import (
generate_human_readable_id, generate_human_readable_id,
resolve_translations_for_elasticsearch, resolve_translations_for_elasticsearch,
) )
from engine.core.utils.emailing import send_order_created_email, send_order_finished_email, send_promocode_created_email from engine.core.utils.emailing import (
from evibes.utils.misc import create_object send_order_created_email,
send_order_finished_email,
send_promocode_created_email,
)
from engine.vibes_auth.models import User from engine.vibes_auth.models import User
from evibes.utils.misc import create_object
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def create_order_on_user_creation_signal(instance: User, created: bool, **kwargs: dict[Any, Any]) -> None: def create_order_on_user_creation_signal(
instance: User, created: bool, **kwargs: dict[Any, Any]
) -> None:
if created: if created:
try: try:
Order.objects.create(user=instance, status="PENDING") Order.objects.create(user=instance, status="PENDING")
@ -37,27 +50,35 @@ def create_order_on_user_creation_signal(instance: User, created: bool, **kwargs
if Order.objects.filter(human_readable_id=human_readable_id).exists(): if Order.objects.filter(human_readable_id=human_readable_id).exists():
human_readable_id = generate_human_readable_id() human_readable_id = generate_human_readable_id()
continue continue
Order.objects.create(user=instance, status="PENDING", human_readable_id=human_readable_id) Order.objects.create(
user=instance, status="PENDING", human_readable_id=human_readable_id
)
break break
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def create_wishlist_on_user_creation_signal(instance: User, created: bool, **kwargs: dict[Any, Any]) -> None: def create_wishlist_on_user_creation_signal(
instance: User, created: bool, **kwargs: dict[Any, Any]
) -> None:
if created: if created:
Wishlist.objects.create(user=instance) Wishlist.objects.create(user=instance)
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def create_promocode_on_user_referring(instance: User, created: bool, **kwargs: dict[Any, Any]) -> None: def create_promocode_on_user_referring(
instance: User, created: bool, **kwargs: dict[Any, Any]
) -> None:
try: try:
if type(instance.attributes) is not dict: if type(instance.attributes) is not dict:
instance.attributes = {} instance.attributes = {}
instance.save() instance.save()
if created and instance.attributes.get("referrer", ""): if created and instance.attributes.get("referrer", ""):
referrer_uuid = urlsafe_base64_decode(instance.attributes.get("referrer", "")).decode() referrer_uuid = urlsafe_base64_decode(
instance.attributes.get("referrer", "")
).decode()
referrer = User.objects.get(uuid=referrer_uuid) referrer = User.objects.get(uuid=referrer_uuid)
code = f"WELCOME-{get_random_string(6)}" code = f"WELCOME-{get_random_string(6)}"
PromoCode.objects.create( PromoCode.objects.create(
@ -93,7 +114,9 @@ def process_order_changes(instance: Order, created: bool, **kwargs: dict[Any, An
human_readable_id = generate_human_readable_id() human_readable_id = generate_human_readable_id()
while True: while True:
try: try:
if Order.objects.filter(human_readable_id=human_readable_id).exists(): if Order.objects.filter(
human_readable_id=human_readable_id
).exists():
human_readable_id = generate_human_readable_id() human_readable_id = generate_human_readable_id()
continue continue
Order.objects.create( Order.objects.create(
@ -109,11 +132,15 @@ def process_order_changes(instance: Order, created: bool, **kwargs: dict[Any, An
if not instance.is_whole_digital: if not instance.is_whole_digital:
send_order_created_email.delay(str(instance.uuid)) send_order_created_email.delay(str(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 not order_product.product: if not order_product.product:
continue continue
stocks_qs = order_product.product.stocks.filter(digital_asset__isnull=False).exclude(digital_asset="") stocks_qs = order_product.product.stocks.filter(
digital_asset__isnull=False
).exclude(digital_asset="")
stock = stocks_qs.first() stock = stocks_qs.first()
@ -124,8 +151,12 @@ def process_order_changes(instance: Order, created: bool, **kwargs: dict[Any, An
if has_file: if has_file:
order_product.status = "FINISHED" order_product.status = "FINISHED"
DigitalAssetDownload.objects.get_or_create(order_product=order_product) DigitalAssetDownload.objects.get_or_create(
order_product.order.user.payments_balance.amount -= order_product.buy_price # type: ignore [union-attr, operator] order_product=order_product
)
order_product.order.user.payments_balance.amount -= (
order_product.buy_price
) # type: ignore [union-attr, operator]
order_product.order.user.payments_balance.save() # type: ignore [union-attr] order_product.order.user.payments_balance.save() # type: ignore [union-attr]
order_product.save() order_product.save()
continue continue
@ -134,24 +165,37 @@ def process_order_changes(instance: Order, created: bool, **kwargs: dict[Any, An
try: try:
vendor_name = ( vendor_name = (
order_product.product.stocks.filter(price=order_product.buy_price).first().vendor.name.lower() # type: ignore [union-attr, attr-defined, misc] order_product.product.stocks.filter(
price=order_product.buy_price
)
.first()
.vendor.name.lower() # type: ignore [union-attr, attr-defined, misc]
) )
vendor = create_object(f"core.vendors.{vendor_name}", f"{vendor_name.title()}Vendor") vendor = create_object(
f"core.vendors.{vendor_name}", f"{vendor_name.title()}Vendor"
)
vendor.buy_order_product(order_product) # type: ignore [attr-defined] vendor.buy_order_product(order_product) # type: ignore [attr-defined]
except Exception as e: except Exception as e:
order_product.add_error(f"Failed to buy {order_product.uuid}. Reason: {e}...") order_product.add_error(
f"Failed to buy {order_product.uuid}. Reason: {e}..."
)
else: else:
instance.finalize() instance.finalize()
if instance.order_products.filter(status="FAILED").count() == instance.order_products.count(): if (
instance.order_products.filter(status="FAILED").count()
== instance.order_products.count()
):
instance.status = "FAILED" instance.status = "FAILED"
instance.save() instance.save()
if instance.status == "FINISHED" and not instance.attributes.get("system_email_sent", False): if instance.status == "FINISHED" and not instance.attributes.get(
"system_email_sent", False
):
instance.attributes["system_email_sent"] = True instance.attributes["system_email_sent"] = True
instance.save() instance.save()
send_order_finished_email.delay(str(instance.uuid)) send_order_finished_email.delay(str(instance.uuid))
@ -159,7 +203,9 @@ def process_order_changes(instance: Order, created: bool, **kwargs: dict[Any, An
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
@receiver(post_save, sender=Product) @receiver(post_save, sender=Product)
def update_product_name_lang(instance: Product, created: bool, **kwargs: dict[Any, Any]) -> None: def update_product_name_lang(
instance: Product, created: bool, **kwargs: dict[Any, Any]
) -> None:
if created: if created:
pass pass
resolve_translations_for_elasticsearch(instance, "name") resolve_translations_for_elasticsearch(instance, "name")
@ -168,7 +214,9 @@ def update_product_name_lang(instance: Product, created: bool, **kwargs: dict[An
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
@receiver(post_save, sender=Category) @receiver(post_save, sender=Category)
def update_category_name_lang(instance: Category, created: bool, **kwargs: dict[Any, Any]) -> None: def update_category_name_lang(
instance: Category, created: bool, **kwargs: dict[Any, Any]
) -> None:
if created: if created:
pass pass
resolve_translations_for_elasticsearch(instance, "name") resolve_translations_for_elasticsearch(instance, "name")
@ -177,6 +225,8 @@ def update_category_name_lang(instance: Category, created: bool, **kwargs: dict[
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
@receiver(post_save, sender=PromoCode) @receiver(post_save, sender=PromoCode)
def send_promocode_creation_email(instance: PromoCode, created: bool, **kwargs: dict[Any, Any]) -> None: def send_promocode_creation_email(
instance: PromoCode, created: bool, **kwargs: dict[Any, Any]
) -> None:
if created: if created:
send_promocode_created_email.delay(str(instance.uuid)) send_promocode_created_email.delay(str(instance.uuid))

View file

@ -38,9 +38,9 @@ class StaticPagesSitemap(SitemapLanguageMixin, Sitemap): # type: ignore [type-a
}, },
] ]
for static_post_page in Post.objects.filter(is_static_page=True, is_active=True).only( for static_post_page in Post.objects.filter(
"title", "slug", "modified" is_static_page=True, is_active=True
): ).only("title", "slug", "modified"):
pages.append( pages.append(
{ {
"name": static_post_page.title, "name": static_post_page.title,

View file

@ -134,7 +134,9 @@ def remove_stale_product_images() -> tuple[bool, str]:
# Load all current product UUIDs into a set. # Load all current product UUIDs into a set.
# This query returns all product UUIDs (as strings or UUID objects). # This query returns all product UUIDs (as strings or UUID objects).
current_product_uuids = set(Product.objects.values_list("uuid", flat=True)) current_product_uuids = set(Product.objects.values_list("uuid", flat=True))
logger.info("Loaded %d product UUIDs from the database.", len(current_product_uuids)) logger.info(
"Loaded %d product UUIDs from the database.", len(current_product_uuids)
)
# Iterate through all subdirectories in the products folder. # Iterate through all subdirectories in the products folder.
for entry in os.listdir(products_dir): for entry in os.listdir(products_dir):
@ -151,7 +153,9 @@ def remove_stale_product_images() -> tuple[bool, str]:
if product_uuid not in current_product_uuids: if product_uuid not in current_product_uuids:
try: try:
shutil.rmtree(entry_path) shutil.rmtree(entry_path)
logger.info("Removed stale product images directory: %s", entry_path) logger.info(
"Removed stale product images directory: %s", entry_path
)
except Exception as e: except Exception as e:
logger.error("Error removing directory %s: %s", entry_path, e) logger.error("Error removing directory %s: %s", entry_path, e)
return True, "Successfully removed stale product images." return True, "Successfully removed stale product images."
@ -192,7 +196,9 @@ def process_promotions() -> tuple[bool, str]:
) )
response.raise_for_status() response.raise_for_status()
except Exception as e: except Exception as e:
logger.warning("Couldn't fetch holiday data for %s: %s", checked_date, str(e)) logger.warning(
"Couldn't fetch holiday data for %s: %s", checked_date, str(e)
)
return False, f"Couldn't fetch holiday data for {checked_date}: {e!s}" return False, f"Couldn't fetch holiday data for {checked_date}: {e!s}"
holidays = response.json() holidays = response.json()
if holidays: if holidays:
@ -224,7 +230,8 @@ def process_promotions() -> tuple[bool, str]:
selected_products.append(product) selected_products.append(product)
promotion = Promotion.objects.update_or_create( promotion = Promotion.objects.update_or_create(
name=promotion_name, defaults={"discount_percent": discount_percent, "is_active": True} name=promotion_name,
defaults={"discount_percent": discount_percent, "is_active": True},
)[0] )[0]
for product in selected_products: for product in selected_products:

View file

@ -20,12 +20,20 @@ class DRFCoreViewsTests(TestCase):
) )
self.user_password = "Str0ngPass!word2" self.user_password = "Str0ngPass!word2"
self.user = User.objects.create( self.user = User.objects.create(
email="test-superuser@email.com", password=self.user_password, is_active=True, is_verified=True email="test-superuser@email.com",
password=self.user_password,
is_active=True,
is_verified=True,
) )
def _get_authorization_token(self, user): def _get_authorization_token(self, user):
serializer = TokenObtainPairSerializer( serializer = TokenObtainPairSerializer(
data={"email": user.email, "password": self.superuser_password if user.is_superuser else self.user_password} data={
"email": user.email,
"password": self.superuser_password
if user.is_superuser
else self.user_password,
}
) )
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
return serializer.validated_data["access_token"] return serializer.validated_data["access_token"]

View file

@ -1,7 +1,12 @@
from django.urls import include, path from django.urls import include, path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from engine.core.sitemaps import BrandSitemap, CategorySitemap, ProductSitemap, StaticPagesSitemap from engine.core.sitemaps import (
BrandSitemap,
CategorySitemap,
ProductSitemap,
StaticPagesSitemap,
)
from engine.core.views import ( from engine.core.views import (
CacheOperatorView, CacheOperatorView,
ContactUsView, ContactUsView,

View file

@ -15,7 +15,6 @@ from django.utils.translation import get_language
from graphene import Context from graphene import Context
from rest_framework.request import Request from rest_framework.request import Request
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -183,7 +182,11 @@ def generate_human_readable_id(length: int = 6) -> str:
""" """
chars = [secrets.choice(CROCKFORD) for _ in range(length)] chars = [secrets.choice(CROCKFORD) for _ in range(length)]
pos = (secrets.randbelow(length - 1) + 1) if secrets.choice([True, False]) else (length // 2) pos = (
(secrets.randbelow(length - 1) + 1)
if secrets.choice([True, False])
else (length // 2)
)
chars.insert(pos, "-") chars.insert(pos, "-")
return "".join(chars) return "".join(chars)

View file

@ -29,7 +29,9 @@ def get_cached_value(user: Type[User], key: str, default: Any = None) -> Any:
return None return None
def set_cached_value(user: Type[User], key: str, value: object, timeout: int = 3600) -> None | object: def set_cached_value(
user: Type[User], key: str, value: object, timeout: int = 3600
) -> None | object:
if user.is_staff or user.is_superuser: if user.is_staff or user.is_superuser:
cache.set(key, value, timeout) cache.set(key, value, timeout)
return value return value
@ -37,13 +39,17 @@ def set_cached_value(user: Type[User], key: str, value: object, timeout: int = 3
return None return None
def web_cache(request: Request | Context, key: str, data: dict[str, Any], timeout: int) -> dict[str, Any]: def web_cache(
request: Request | Context, key: str, data: dict[str, Any], timeout: int
) -> dict[str, Any]:
if not data and not timeout: if not data and not timeout:
return {"data": get_cached_value(request.user, key)} # type: ignore [assignment, arg-type] return {"data": get_cached_value(request.user, key)} # type: ignore [assignment, arg-type]
if (data and not timeout) or (timeout and not data): if (data and not timeout) or (timeout and not data):
raise BadRequest(_("both data and timeout are required")) raise BadRequest(_("both data and timeout are required"))
if not 0 < int(timeout) < 216000: if not 0 < int(timeout) < 216000:
raise BadRequest(_("invalid timeout value, it must be between 0 and 216000 seconds")) raise BadRequest(
_("invalid timeout value, it must be between 0 and 216000 seconds")
)
return {"data": set_cached_value(request.user, key, data, timeout)} # type: ignore [assignment, arg-type] return {"data": set_cached_value(request.user, key, data, timeout)} # type: ignore [assignment, arg-type]

View file

@ -18,14 +18,19 @@ def get_period_order_products(
statuses = ["FINISHED"] statuses = ["FINISHED"]
current = now() current = now()
perioded = current - period perioded = current - period
orders = Order.objects.filter(status="FINISHED", buy_time__lte=current, buy_time__gte=perioded) orders = Order.objects.filter(
status="FINISHED", buy_time__lte=current, buy_time__gte=perioded
)
return OrderProduct.objects.filter(status__in=statuses, order__in=orders) return OrderProduct.objects.filter(status__in=statuses, order__in=orders)
def get_revenue(clear: bool = True, period: timedelta = timedelta(days=30)) -> float: def get_revenue(clear: bool = True, period: timedelta = timedelta(days=30)) -> float:
order_products = get_period_order_products(period) order_products = get_period_order_products(period)
total: float = ( total: float = (
order_products.aggregate(total=Coalesce(Sum(F("buy_price") * F("quantity")), 0.0)).get("total") or 0.0 order_products.aggregate(
total=Coalesce(Sum(F("buy_price") * F("quantity")), 0.0)
).get("total")
or 0.0
) )
try: try:
total = float(total) total = float(total)
@ -62,7 +67,10 @@ def get_revenue(clear: bool = True, period: timedelta = timedelta(days=30)) -> f
def get_returns(period: timedelta = timedelta(days=30)) -> float: def get_returns(period: timedelta = timedelta(days=30)) -> float:
order_products = get_period_order_products(period, ["RETURNED"]) order_products = get_period_order_products(period, ["RETURNED"])
total_returns: float = ( total_returns: float = (
order_products.aggregate(total=Coalesce(Sum(F("buy_price") * F("quantity")), 0.0)).get("total") or 0.0 order_products.aggregate(
total=Coalesce(Sum(F("buy_price") * F("quantity")), 0.0)
).get("total")
or 0.0
) )
try: try:
return round(float(total_returns), 2) return round(float(total_returns), 2)
@ -74,11 +82,15 @@ def get_total_processed_orders(period: timedelta = timedelta(days=30)) -> int:
return get_period_order_products(period, ["RETURNED", "FINISHED"]).count() return get_period_order_products(period, ["RETURNED", "FINISHED"]).count()
def get_daily_finished_orders_count(period: timedelta = timedelta(days=30)) -> dict[date, int]: def get_daily_finished_orders_count(
period: timedelta = timedelta(days=30),
) -> dict[date, int]:
current = now() current = now()
period_start = current - period period_start = current - period
qs = ( qs = (
Order.objects.filter(status="FINISHED", buy_time__lte=current, buy_time__gte=period_start) Order.objects.filter(
status="FINISHED", buy_time__lte=current, buy_time__gte=period_start
)
.annotate(day=TruncDate("buy_time")) .annotate(day=TruncDate("buy_time"))
.values("day") .values("day")
.annotate(cnt=Count("pk")) .annotate(cnt=Count("pk"))
@ -93,7 +105,9 @@ def get_daily_finished_orders_count(period: timedelta = timedelta(days=30)) -> d
return result return result
def get_daily_gross_revenue(period: timedelta = timedelta(days=30)) -> dict[date, float]: def get_daily_gross_revenue(
period: timedelta = timedelta(days=30),
) -> dict[date, float]:
qs = ( qs = (
get_period_order_products(period, ["FINISHED"]) # OrderProduct queryset get_period_order_products(period, ["FINISHED"]) # OrderProduct queryset
.annotate(day=TruncDate("order__buy_time")) .annotate(day=TruncDate("order__buy_time"))
@ -114,7 +128,9 @@ def get_daily_gross_revenue(period: timedelta = timedelta(days=30)) -> dict[date
return result return result
def get_top_returned_products(period: timedelta = timedelta(days=30), limit: int = 10) -> list[dict[str, Any]]: def get_top_returned_products(
period: timedelta = timedelta(days=30), limit: int = 10
) -> list[dict[str, Any]]:
current = now() current = now()
period_start = current - period period_start = current - period
qs = ( qs = (
@ -161,7 +177,12 @@ def get_customer_mix(period: timedelta = timedelta(days=30)) -> dict[str, int]:
current = now() current = now()
period_start = current - period period_start = current - period
period_users = ( period_users = (
Order.objects.filter(status="FINISHED", buy_time__lte=current, buy_time__gte=period_start, user__isnull=False) Order.objects.filter(
status="FINISHED",
buy_time__lte=current,
buy_time__gte=period_start,
user__isnull=False,
)
.values_list("user_id", flat=True) .values_list("user_id", flat=True)
.distinct() .distinct()
) )
@ -169,7 +190,9 @@ def get_customer_mix(period: timedelta = timedelta(days=30)) -> dict[str, int]:
return {"new": 0, "returning": 0} return {"new": 0, "returning": 0}
lifetime_counts = ( lifetime_counts = (
Order.objects.filter(status="FINISHED", user_id__in=period_users).values("user_id").annotate(c=Count("pk")) Order.objects.filter(status="FINISHED", user_id__in=period_users)
.values("user_id")
.annotate(c=Count("pk"))
) )
new_cnt = 0 new_cnt = 0
ret_cnt = 0 ret_cnt = 0
@ -182,7 +205,9 @@ def get_customer_mix(period: timedelta = timedelta(days=30)) -> dict[str, int]:
return {"new": new_cnt, "returning": ret_cnt} return {"new": new_cnt, "returning": ret_cnt}
def get_top_categories_by_qty(period: timedelta = timedelta(days=30), limit: int = 10) -> list[dict[str, Any]]: def get_top_categories_by_qty(
period: timedelta = timedelta(days=30), limit: int = 10
) -> list[dict[str, Any]]:
current = now() current = now()
period_start = current - period period_start = current - period
qs = ( qs = (
@ -222,7 +247,9 @@ def get_top_categories_by_qty(period: timedelta = timedelta(days=30), limit: int
return result return result
def get_shipped_vs_digital_mix(period: timedelta = timedelta(days=30)) -> dict[str, float | int]: def get_shipped_vs_digital_mix(
period: timedelta = timedelta(days=30),
) -> dict[str, float | int]:
current = now() current = now()
period_start = current - period period_start = current - period
qs = ( qs = (

View file

@ -22,7 +22,9 @@ def unicode_slugify_function(content: Any) -> str:
class TweakedAutoSlugField(AutoSlugField): class TweakedAutoSlugField(AutoSlugField):
def get_slug_fields(self, model_instance: Model, lookup_value: str | Callable[[Any], Any]) -> str | Model: def get_slug_fields(
self, model_instance: Model, lookup_value: str | Callable[[Any], Any]
) -> str | Model:
if callable(lookup_value): if callable(lookup_value):
return f"{lookup_value(model_instance)}" return f"{lookup_value(model_instance)}"

View file

@ -31,7 +31,9 @@ def contact_us_email(contact_info) -> tuple[bool, str]:
"email": contact_info.get("email"), "email": contact_info.get("email"),
"name": contact_info.get("name"), "name": contact_info.get("name"),
"subject": contact_info.get("subject", "Without subject"), "subject": contact_info.get("subject", "Without subject"),
"phone_number": contact_info.get("phone_number", "Without phone number"), "phone_number": contact_info.get(
"phone_number", "Without phone number"
),
"message": contact_info.get("message"), "message": contact_info.get("message"),
"config": config, "config": config,
}, },
@ -56,7 +58,13 @@ def send_order_created_email(order_pk: str) -> tuple[bool, str]:
if type(order.attributes) is not dict: if type(order.attributes) is not dict:
order.attributes = {} order.attributes = {}
if not any([order.user, order.attributes.get("email", None), order.attributes.get("customer_email", None)]): if not any(
[
order.user,
order.attributes.get("email", None),
order.attributes.get("customer_email", None),
]
):
return False, f"Order's user not found with the given pk: {order_pk}" return False, f"Order's user not found with the given pk: {order_pk}"
language = settings.LANGUAGE_CODE language = settings.LANGUAGE_CODE
@ -72,7 +80,9 @@ def send_order_created_email(order_pk: str) -> tuple[bool, str]:
email = EmailMessage( email = EmailMessage(
_(f"{settings.PROJECT_NAME} | order confirmation"), _(f"{settings.PROJECT_NAME} | order confirmation"),
render_to_string( render_to_string(
"digital_order_created_email.html" if order.is_whole_digital else "shipped_order_created_email.html", "digital_order_created_email.html"
if order.is_whole_digital
else "shipped_order_created_email.html",
{ {
"order": order, "order": order,
"today": datetime.today(), "today": datetime.today(),
@ -122,7 +132,12 @@ def send_order_finished_email(order_pk: str) -> tuple[bool, str]:
) )
email.content_subtype = "html" email.content_subtype = "html"
result = email.send() result = email.send()
logger.debug("Order %s: Tried to send email to %s, resulted with %s", order.pk, order.user.email, result) logger.debug(
"Order %s: Tried to send email to %s, resulted with %s",
order.pk,
order.user.email,
result,
)
def send_thank_you_email(ops: list[OrderProduct]) -> None: def send_thank_you_email(ops: list[OrderProduct]) -> None:
if ops: if ops:
@ -204,7 +219,10 @@ def send_promocode_created_email(promocode_pk: str) -> tuple[bool, str]:
email.content_subtype = "html" email.content_subtype = "html"
result = email.send() result = email.send()
logger.debug( logger.debug(
"Promocode %s: Tried to send email to %s, resulted with %s", promocode.pk, promocode.user.email, result "Promocode %s: Tried to send email to %s, resulted with %s",
promocode.pk,
promocode.user.email,
result,
) )
return True, str(promocode.uuid) return True, str(promocode.uuid)

View file

@ -33,7 +33,8 @@ def breadcrumb_schema(items):
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "BreadcrumbList", "@type": "BreadcrumbList",
"itemListElement": [ "itemListElement": [
{"@type": "ListItem", "position": i + 1, "name": name, "item": url} for i, (name, url) in enumerate(items) {"@type": "ListItem", "position": i + 1, "name": name, "item": url}
for i, (name, url) in enumerate(items)
], ],
} }
@ -42,7 +43,10 @@ def item_list_schema(urls):
return { return {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "ItemList", "@type": "ItemList",
"itemListElement": [{"@type": "ListItem", "position": i + 1, "url": u} for i, u in enumerate(urls)], "itemListElement": [
{"@type": "ListItem", "position": i + 1, "url": u}
for i, u in enumerate(urls)
],
} }
@ -54,7 +58,9 @@ def product_schema(product, images, rating=None):
"@type": "Offer", "@type": "Offer",
"price": round(stock.price, 2), "price": round(stock.price, 2),
"priceCurrency": settings.CURRENCY_CODE, "priceCurrency": settings.CURRENCY_CODE,
"availability": "https://schema.org/InStock" if stock.quantity > 0 else "https://schema.org/OutOfStock", "availability": "https://schema.org/InStock"
if stock.quantity > 0
else "https://schema.org/OutOfStock",
"sku": stock.sku, "sku": stock.sku,
"url": f"https://{settings.BASE_DOMAIN}/product/{product.slug}", "url": f"https://{settings.BASE_DOMAIN}/product/{product.slug}",
} }
@ -65,7 +71,9 @@ def product_schema(product, images, rating=None):
"name": product.name, "name": product.name,
"description": product.description or "", "description": product.description or "",
"sku": product.partnumber or "", "sku": product.partnumber or "",
"brand": {"@type": "Brand", "name": product.brand.name} if product.brand else None, "brand": {"@type": "Brand", "name": product.brand.name}
if product.brand
else None,
"image": [img.image.url for img in images] or [], "image": [img.image.url for img in images] or [],
"offers": offers[:1] if offers else None, "offers": offers[:1] if offers else None,
} }

View file

@ -20,7 +20,10 @@ def get_vendors_integrations(name: str | None = None) -> list[AbstractVendor]:
vendors_integrations.append(create_object(module_name, class_name)) vendors_integrations.append(create_object(module_name, class_name))
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"Couldn't load integration %s for vendor %s: %s", vendor.integration_path, vendor.name, str(e) "Couldn't load integration %s for vendor %s: %s",
vendor.integration_path,
vendor.name,
str(e),
) )
return vendors_integrations return vendors_integrations

View file

@ -16,4 +16,8 @@ def validate_category_image_dimensions(
return return
if int(width) > max_width or int(height) > max_height: # type: ignore [arg-type] if int(width) > max_width or int(height) > max_height: # type: ignore [arg-type]
raise ValidationError(_(f"image dimensions should not exceed w{max_width} x h{max_height} pixels")) raise ValidationError(
_(
f"image dimensions should not exceed w{max_width} x h{max_height} pixels"
)
)

View file

@ -28,9 +28,9 @@ from engine.core.models import (
Stock, Stock,
Vendor, Vendor,
) )
from evibes.utils.misc import LoggingError, LogLevel
from engine.payments.errors import RatesError from engine.payments.errors import RatesError
from engine.payments.utils import get_rates from engine.payments.utils import get_rates
from evibes.utils.misc import LoggingError, LogLevel
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -145,7 +145,9 @@ class AbstractVendor:
if len(json_bytes) > size_threshold: if len(json_bytes) > size_threshold:
buffer = BytesIO() buffer = BytesIO()
with gzip.GzipFile(fileobj=buffer, mode="wb", compresslevel=9) as gz_file: with gzip.GzipFile(
fileobj=buffer, mode="wb", compresslevel=9
) as gz_file:
gz_file.write(json_bytes) gz_file.write(json_bytes)
compressed_data = buffer.getvalue() compressed_data = buffer.getvalue()
@ -157,15 +159,22 @@ class AbstractVendor:
self.log(LogLevel.DEBUG, f"Saving vendor's response to {filename}") self.log(LogLevel.DEBUG, f"Saving vendor's response to {filename}")
vendor_instance.last_processing_response.save(filename, content, save=True) vendor_instance.last_processing_response.save(
filename, content, save=True
)
self.log(LogLevel.DEBUG, f"Saved vendor's response to {filename} successfuly!") self.log(
LogLevel.DEBUG,
f"Saved vendor's response to {filename} successfuly!",
)
return return
raise VendorDebuggingError("Could not save response") raise VendorDebuggingError("Could not save response")
@staticmethod @staticmethod
def chunk_data(data: list[Any] | None = None, num_chunks: int = 20) -> list[list[Any]] | list[Any]: def chunk_data(
data: list[Any] | None = None, num_chunks: int = 20
) -> list[list[Any]] | list[Any]:
if not data: if not data:
return [] return []
total = len(data) total = len(data)
@ -234,19 +243,25 @@ class AbstractVendor:
return value, "string" return value, "string"
@staticmethod @staticmethod
def auto_resolver_helper(model: type[Brand] | type[Category], resolving_name: str) -> Brand | Category | None: def auto_resolver_helper(
model: type[Brand] | type[Category], resolving_name: str
) -> Brand | Category | None:
queryset = model.objects.filter(name=resolving_name) queryset = model.objects.filter(name=resolving_name)
if not queryset.exists(): if not queryset.exists():
if len(resolving_name) > 255: if len(resolving_name) > 255:
resolving_name = resolving_name[:255] resolving_name = resolving_name[:255]
return model.objects.get_or_create(name=resolving_name, defaults={"is_active": False})[0] return model.objects.get_or_create(
name=resolving_name, defaults={"is_active": False}
)[0]
elif queryset.filter(is_active=True).count() > 1: elif queryset.filter(is_active=True).count() > 1:
queryset = queryset.filter(is_active=True) queryset = queryset.filter(is_active=True)
elif queryset.filter(is_active=False).count() > 1: elif queryset.filter(is_active=False).count() > 1:
queryset = queryset.filter(is_active=False) queryset = queryset.filter(is_active=False)
chosen = queryset.first() chosen = queryset.first()
if not chosen: if not chosen:
raise VendorError(f"No matching {model.__name__} found with name {resolving_name!r}...") raise VendorError(
f"No matching {model.__name__} found with name {resolving_name!r}..."
)
queryset = queryset.exclude(uuid=chosen.uuid) queryset = queryset.exclude(uuid=chosen.uuid)
queryset.delete() queryset.delete()
return chosen return chosen
@ -254,7 +269,9 @@ class AbstractVendor:
def auto_resolve_category(self, category_name: str = "") -> Category | None: def auto_resolve_category(self, category_name: str = "") -> Category | None:
if category_name: if category_name:
try: try:
search = process_system_query(query=category_name, indexes=("categories",)) search = process_system_query(
query=category_name, indexes=("categories",)
)
uuid = search["categories"][0]["uuid"] if search else None uuid = search["categories"][0]["uuid"] if search else None
if uuid: if uuid:
return Category.objects.get(uuid=uuid) return Category.objects.get(uuid=uuid)
@ -308,7 +325,9 @@ class AbstractVendor:
return round(price, 2) return round(price, 2)
def resolve_price_with_currency(self, price: float | int | Decimal, provider: str, currency: str = "") -> float: def resolve_price_with_currency(
self, price: float | int | Decimal, provider: str, currency: str = ""
) -> float:
if all([not currency, not self.currency]): if all([not currency, not self.currency]):
raise ValueError("Currency must be provided.") raise ValueError("Currency must be provided.")
@ -320,7 +339,9 @@ class AbstractVendor:
rate = rates.get(currency or self.currency) if rates else 1 rate = rates.get(currency or self.currency) if rates else 1
if not rate: if not rate:
raise RatesError(f"No rate found for {currency} in {rates} with probider {provider}...") raise RatesError(
f"No rate found for {currency} in {rates} with probider {provider}..."
)
return float(round(price / rate, 2)) if rate else float(round(price, 2)) # type: ignore [arg-type, operator] return float(round(price / rate, 2)) if rate else float(round(price, 2)) # type: ignore [arg-type, operator]
@ -368,16 +389,22 @@ class AbstractVendor:
except Vendor.DoesNotExist as dne: except Vendor.DoesNotExist as dne:
if safe: if safe:
return None return None
raise Exception(f"No matching vendor found with name {self.vendor_name!r}...") from dne raise Exception(
f"No matching vendor found with name {self.vendor_name!r}..."
) from dne
def get_products(self) -> None: def get_products(self) -> None:
pass pass
def get_products_queryset(self) -> QuerySet[Product]: def get_products_queryset(self) -> QuerySet[Product]:
return Product.objects.filter(stocks__vendor=self.get_vendor_instance(), orderproduct__isnull=True) return Product.objects.filter(
stocks__vendor=self.get_vendor_instance(), orderproduct__isnull=True
)
def get_stocks_queryset(self) -> QuerySet[Stock]: def get_stocks_queryset(self) -> QuerySet[Stock]:
return Stock.objects.filter(product__in=self.get_products_queryset(), product__orderproduct__isnull=True) return Stock.objects.filter(
product__in=self.get_products_queryset(), product__orderproduct__isnull=True
)
def get_attribute_values_queryset(self) -> QuerySet[AttributeValue]: def get_attribute_values_queryset(self) -> QuerySet[AttributeValue]:
return AttributeValue.objects.filter( return AttributeValue.objects.filter(
@ -400,16 +427,22 @@ class AbstractVendor:
case _: case _:
raise ValueError(f"Invalid method {method!r} for products update...") raise ValueError(f"Invalid method {method!r} for products update...")
def delete_inactives(self, inactivation_method: str = "deactivate", size: int = 5000) -> None: def delete_inactives(
self, inactivation_method: str = "deactivate", size: int = 5000
) -> None:
filter_kwargs: dict[str, Any] = dict() filter_kwargs: dict[str, Any] = dict()
match inactivation_method: match inactivation_method:
case "deactivate": case "deactivate":
filter_kwargs: dict[str, Any] = {"is_active": False} filter_kwargs: dict[str, Any] = {"is_active": False}
case "description": case "description":
filter_kwargs: dict[str, Any] = {"description__exact": "EVIBES_DELETED_PRODUCT"} filter_kwargs: dict[str, Any] = {
"description__exact": "EVIBES_DELETED_PRODUCT"
}
case _: case _:
raise ValueError(f"Invalid method {inactivation_method!r} for products cleaner...") raise ValueError(
f"Invalid method {inactivation_method!r} for products cleaner..."
)
if filter_kwargs == {}: if filter_kwargs == {}:
raise ValueError("Invalid filter kwargs...") raise ValueError("Invalid filter kwargs...")
@ -420,7 +453,9 @@ class AbstractVendor:
if products is None: if products is None:
return return
batch_ids = list(products.filter(**filter_kwargs).values_list("pk", flat=True)[:size]) batch_ids = list(
products.filter(**filter_kwargs).values_list("pk", flat=True)[:size]
)
if not batch_ids: if not batch_ids:
break break
with suppress(Exception): with suppress(Exception):
@ -433,7 +468,9 @@ class AbstractVendor:
self.get_stocks_queryset().delete() self.get_stocks_queryset().delete()
self.get_attribute_values_queryset().delete() self.get_attribute_values_queryset().delete()
def get_or_create_attribute_safe(self, *, name: str, attr_group: AttributeGroup) -> Attribute: def get_or_create_attribute_safe(
self, *, name: str, attr_group: AttributeGroup
) -> Attribute:
key = name[:255] key = name[:255]
try: try:
attr = Attribute.objects.get(name=key) attr = Attribute.objects.get(name=key)
@ -459,15 +496,22 @@ class AbstractVendor:
self, key: str, value: Any, product: Product, attr_group: AttributeGroup self, key: str, value: Any, product: Product, attr_group: AttributeGroup
) -> AttributeValue | None: ) -> AttributeValue | None:
self.log( self.log(
LogLevel.DEBUG, f"Trying to save attribute {key} with value {value} to {attr_group.name} of {product.pk}" LogLevel.DEBUG,
f"Trying to save attribute {key} with value {value} to {attr_group.name} of {product.pk}",
) )
if not value: if not value:
self.log(LogLevel.WARNING, f"No value for attribute {key!r} at {product.name!r}...") self.log(
LogLevel.WARNING,
f"No value for attribute {key!r} at {product.name!r}...",
)
return None return None
if not attr_group: if not attr_group:
self.log(LogLevel.WARNING, f"No group for attribute {key!r} at {product.name!r}...") self.log(
LogLevel.WARNING,
f"No group for attribute {key!r} at {product.name!r}...",
)
return None return None
if key in self.blocked_attributes: if key in self.blocked_attributes:
@ -488,7 +532,11 @@ class AbstractVendor:
defaults={"is_active": True}, defaults={"is_active": True},
) )
except Attribute.MultipleObjectsReturned: except Attribute.MultipleObjectsReturned:
attribute = Attribute.objects.filter(name=key, group=attr_group).order_by("uuid").first() # type: ignore [assignment] attribute = (
Attribute.objects.filter(name=key, group=attr_group)
.order_by("uuid")
.first()
) # type: ignore [assignment]
fields_to_update: list[str] = [] fields_to_update: list[str] = []
if not attribute.is_active: if not attribute.is_active:
attribute.is_active = True attribute.is_active = True
@ -507,7 +555,10 @@ class AbstractVendor:
continue continue
raise raise
except IntegrityError: except IntegrityError:
self.log(LogLevel.WARNING, f"IntegrityError while processing attribute {key!r}...") self.log(
LogLevel.WARNING,
f"IntegrityError while processing attribute {key!r}...",
)
return None return None
if not is_created: if not is_created:

View file

@ -12,8 +12,15 @@ from django.contrib.sitemaps.views import index as _sitemap_index_view
from django.contrib.sitemaps.views import sitemap as _sitemap_detail_view from django.contrib.sitemaps.views import sitemap as _sitemap_detail_view
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import BadRequest from django.core.exceptions import BadRequest
from django.db.models import Count, Sum, F from django.db.models import Count, F, Sum
from django.http import FileResponse, Http404, HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse from django.http import (
FileResponse,
Http404,
HttpRequest,
HttpResponse,
HttpResponseRedirect,
JsonResponse,
)
from django.shortcuts import redirect from django.shortcuts import redirect
from django.template import Context from django.template import Context
from django.urls import reverse from django.urls import reverse
@ -27,7 +34,11 @@ from django_ratelimit.decorators import ratelimit
from djangorestframework_camel_case.render import CamelCaseJSONRenderer from djangorestframework_camel_case.render import CamelCaseJSONRenderer
from djangorestframework_camel_case.util import camelize from djangorestframework_camel_case.util import camelize
from drf_spectacular.utils import extend_schema_view from drf_spectacular.utils import extend_schema_view
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView from drf_spectacular.views import (
SpectacularAPIView,
SpectacularRedocView,
SpectacularSwaggerView,
)
from graphene_file_upload.django import FileUploadGraphQLView from graphene_file_upload.django import FileUploadGraphQLView
from rest_framework import status from rest_framework import status
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
@ -51,7 +62,13 @@ from engine.core.docs.drf.views import (
SEARCH_SCHEMA, SEARCH_SCHEMA,
) )
from engine.core.elasticsearch import process_query from engine.core.elasticsearch import process_query
from engine.core.models import DigitalAssetDownload, Order, OrderProduct, Product, Wishlist from engine.core.models import (
DigitalAssetDownload,
Order,
OrderProduct,
Product,
Wishlist,
)
from engine.core.serializers import ( from engine.core.serializers import (
BuyAsBusinessOrderSerializer, BuyAsBusinessOrderSerializer,
CacheOperatorSerializer, CacheOperatorSerializer,
@ -138,7 +155,9 @@ class CustomRedocView(SpectacularRedocView):
@extend_schema_view(**LANGUAGE_SCHEMA) @extend_schema_view(**LANGUAGE_SCHEMA)
class SupportedLanguagesView(APIView): class SupportedLanguagesView(APIView):
__doc__ = _("Returns a list of supported languages and their corresponding information.") # type: ignore [assignment] __doc__ = _(
"Returns a list of supported languages and their corresponding information."
) # type: ignore [assignment]
serializer_class = LanguageSerializer serializer_class = LanguageSerializer
permission_classes = [ permission_classes = [
@ -184,12 +203,16 @@ class WebsiteParametersView(APIView):
] ]
def get(self, request: Request, *args, **kwargs) -> Response: def get(self, request: Request, *args, **kwargs) -> Response:
return Response(data=camelize(get_project_parameters()), status=status.HTTP_200_OK) return Response(
data=camelize(get_project_parameters()), status=status.HTTP_200_OK
)
@extend_schema_view(**CACHE_SCHEMA) @extend_schema_view(**CACHE_SCHEMA)
class CacheOperatorView(APIView): class CacheOperatorView(APIView):
__doc__ = _("Handles cache operations such as reading and setting cache data with a specified key and timeout.") # type: ignore [assignment] __doc__ = _(
"Handles cache operations such as reading and setting cache data with a specified key and timeout."
) # type: ignore [assignment]
serializer_class = CacheOperatorSerializer serializer_class = CacheOperatorSerializer
permission_classes = [ permission_classes = [
@ -237,7 +260,9 @@ class ContactUsView(APIView):
@extend_schema_view(**REQUEST_CURSED_URL_SCHEMA) @extend_schema_view(**REQUEST_CURSED_URL_SCHEMA)
class RequestCursedURLView(APIView): class RequestCursedURLView(APIView):
__doc__ = _("Handles requests for processing and validating URLs from incoming POST requests.") # type: ignore [assignment] __doc__ = _(
"Handles requests for processing and validating URLs from incoming POST requests."
) # type: ignore [assignment]
permission_classes = [ permission_classes = [
AllowAny, AllowAny,
@ -260,7 +285,9 @@ class RequestCursedURLView(APIView):
try: try:
data = cache.get(url, None) data = cache.get(url, None)
if not data: if not data:
response = requests.get(str(url), headers={"content-type": "application/json"}) response = requests.get(
str(url), headers={"content-type": "application/json"}
)
response.raise_for_status() response.raise_for_status()
data = camelize(response.json()) data = camelize(response.json())
cache.set(url, data, 86400) cache.set(url, data, 86400)
@ -287,7 +314,15 @@ class GlobalSearchView(APIView):
] ]
def get(self, request: Request, *args, **kwargs) -> Response: def get(self, request: Request, *args, **kwargs) -> Response:
return Response(camelize({"results": process_query(query=request.GET.get("q", "").strip(), request=request)})) return Response(
camelize(
{
"results": process_query(
query=request.GET.get("q", "").strip(), request=request
)
}
)
)
@extend_schema_view(**BUY_AS_BUSINESS_SCHEMA) @extend_schema_view(**BUY_AS_BUSINESS_SCHEMA)
@ -295,21 +330,32 @@ class BuyAsBusinessView(APIView):
__doc__ = _("Handles the logic of buying as a business without registration.") # type: ignore [assignment] __doc__ = _("Handles the logic of buying as a business without registration.") # type: ignore [assignment]
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
@method_decorator(ratelimit(key="ip", rate="10/h" if not settings.DEBUG else "888/h")) @method_decorator(
ratelimit(key="ip", rate="10/h" if not settings.DEBUG else "888/h")
)
def post(self, request: Request, *args, **kwargs) -> Response: def post(self, request: Request, *args, **kwargs) -> Response:
serializer = BuyAsBusinessOrderSerializer(data=request.data) serializer = BuyAsBusinessOrderSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
order = Order.objects.create(status="MOMENTAL") order = Order.objects.create(status="MOMENTAL")
products = [product.get("product_uuid") for product in serializer.validated_data.get("products")] products = [
product.get("product_uuid")
for product in serializer.validated_data.get("products")
]
try: try:
transaction = order.buy_without_registration( transaction = order.buy_without_registration(
products=products, products=products,
promocode_uuid=serializer.validated_data.get("promocode_uuid"), promocode_uuid=serializer.validated_data.get("promocode_uuid"),
customer_name=serializer.validated_data.get("business_identificator"), customer_name=serializer.validated_data.get("business_identificator"),
customer_email=serializer.validated_data.get("business_email"), customer_email=serializer.validated_data.get("business_email"),
customer_phone_number=serializer.validated_data.get("business_phone_number"), customer_phone_number=serializer.validated_data.get(
billing_customer_address=serializer.validated_data.get("billing_business_address_uuid"), "business_phone_number"
shipping_customer_address=serializer.validated_data.get("shipping_business_address_uuid"), ),
billing_customer_address=serializer.validated_data.get(
"billing_business_address_uuid"
),
shipping_customer_address=serializer.validated_data.get(
"shipping_business_address_uuid"
),
payment_method=serializer.validated_data.get("payment_method"), payment_method=serializer.validated_data.get("payment_method"),
is_business=True, is_business=True,
) )
@ -353,7 +399,9 @@ class DownloadDigitalAssetView(APIView):
raise BadRequest(_("you can only download the digital asset once")) raise BadRequest(_("you can only download the digital asset once"))
if order_product.download.order_product.status != "FINISHED": if order_product.download.order_product.status != "FINISHED":
raise BadRequest(_("the order must be paid before downloading the digital asset")) raise BadRequest(
_("the order must be paid before downloading the digital asset")
)
order_product.download.num_downloads += 1 order_product.download.num_downloads += 1
order_product.download.save() order_product.download.save()
@ -373,10 +421,15 @@ class DownloadDigitalAssetView(APIView):
return response return response
except BadRequest as e: except BadRequest as e:
return Response(data=camelize({"error": str(e)}), status=status.HTTP_400_BAD_REQUEST) return Response(
data=camelize({"error": str(e)}), status=status.HTTP_400_BAD_REQUEST
)
except DigitalAssetDownload.DoesNotExist: except DigitalAssetDownload.DoesNotExist:
return Response(data=camelize({"error": "Digital asset not found"}), status=status.HTTP_404_NOT_FOUND) return Response(
data=camelize({"error": "Digital asset not found"}),
status=status.HTTP_404_NOT_FOUND,
)
except Exception as e: except Exception as e:
capture_exception(e) capture_exception(e)
@ -457,7 +510,9 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
result = 0.0 result = 0.0
with suppress(Exception): with suppress(Exception):
qs = ( qs = (
OrderProduct.objects.filter(status__in=["FINISHED"], order__status="FINISHED") OrderProduct.objects.filter(
status__in=["FINISHED"], order__status="FINISHED"
)
.filter(order__buy_time__lt=end, order__buy_time__gte=start) .filter(order__buy_time__lt=end, order__buy_time__gte=start)
.aggregate(total=Sum(F("buy_price") * F("quantity"))) .aggregate(total=Sum(F("buy_price") * F("quantity")))
) )
@ -480,7 +535,9 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
def count_finished_orders_between(start: date | None, end: date | None) -> int: def count_finished_orders_between(start: date | None, end: date | None) -> int:
result = 0 result = 0
with suppress(Exception): with suppress(Exception):
result = Order.objects.filter(status="FINISHED", buy_time__lt=end, buy_time__gte=start).count() result = Order.objects.filter(
status="FINISHED", buy_time__lt=end, buy_time__gte=start
).count()
return result return result
revenue_gross_prev = sum_gross_between(prev_start, prev_end) revenue_gross_prev = sum_gross_between(prev_start, prev_end)
@ -498,7 +555,9 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
else: else:
if tax_included: if tax_included:
divisor = 1.0 + (tax_rate / 100.0) divisor = 1.0 + (tax_rate / 100.0)
revenue_net_prev = revenue_gross_prev / divisor if divisor > 0 else revenue_gross_prev revenue_net_prev = (
revenue_gross_prev / divisor if divisor > 0 else revenue_gross_prev
)
else: else:
revenue_net_prev = revenue_gross_prev revenue_net_prev = revenue_gross_prev
revenue_net_prev = round(float(revenue_net_prev or 0.0), 2) revenue_net_prev = round(float(revenue_net_prev or 0.0), 2)
@ -513,11 +572,27 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
result = round(((cur_f - prev_f) / prev_f) * 100.0, 1) result = round(((cur_f - prev_f) / prev_f) * 100.0, 1)
return result return result
aov_cur: float = round((revenue_gross_cur / orders_finished_cur), 2) if orders_finished_cur > 0 else 0.0 aov_cur: float = (
refund_rate_cur: float = round(((returns_cur / revenue_gross_cur) * 100.0), 1) if revenue_gross_cur > 0 else 0.0 round((revenue_gross_cur / orders_finished_cur), 2)
if orders_finished_cur > 0
else 0.0
)
refund_rate_cur: float = (
round(((returns_cur / revenue_gross_cur) * 100.0), 1)
if revenue_gross_cur > 0
else 0.0
)
aov_prev: float = round((revenue_gross_prev / orders_finished_prev), 2) if orders_finished_prev > 0 else 0.0 aov_prev: float = (
refund_rate_prev: float = round(((returns_prev / revenue_gross_prev) * 100.0), 1) if revenue_gross_prev > 0 else 0.0 round((revenue_gross_prev / orders_finished_prev), 2)
if orders_finished_prev > 0
else 0.0
)
refund_rate_prev: float = (
round(((returns_prev / revenue_gross_prev) * 100.0), 1)
if revenue_gross_prev > 0
else 0.0
)
kpi = { kpi = {
"gmv": { "gmv": {
@ -530,7 +605,11 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
"prev": orders_finished_prev, "prev": orders_finished_prev,
"delta_pct": pct_delta(orders_finished_cur, orders_finished_prev), "delta_pct": pct_delta(orders_finished_cur, orders_finished_prev),
}, },
"aov": {"value": aov_cur, "prev": aov_prev, "delta_pct": pct_delta(aov_cur, aov_prev)}, "aov": {
"value": aov_cur,
"prev": aov_prev,
"delta_pct": pct_delta(aov_cur, aov_prev),
},
"net": { "net": {
"value": revenue_net_cur, "value": revenue_net_cur,
"prev": revenue_net_prev, "prev": revenue_net_prev,
@ -550,7 +629,9 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
quick_links: list[dict[str, str]] = [] quick_links: list[dict[str, str]] = []
with suppress(Exception): with suppress(Exception):
quick_links_section = settings.UNFOLD.get("SIDEBAR", {}).get("navigation", [])[1] # type: ignore[assignment] quick_links_section = settings.UNFOLD.get("SIDEBAR", {}).get("navigation", [])[
1
] # type: ignore[assignment]
for item in quick_links_section.get("items", []): for item in quick_links_section.get("items", []):
title = item.get("title") title = item.get("title")
link = item.get("link") link = item.get("link")
@ -578,16 +659,24 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
if wished_first and wished_first.get("products"): if wished_first and wished_first.get("products"):
product = Product.objects.filter(pk=wished_first["products"]).first() product = Product.objects.filter(pk=wished_first["products"]).first()
if product: if product:
img = product.images.first().image_url if product.images.exists() else "" # type: ignore [union-attr] img = (
product.images.first().image_url if product.images.exists() else ""
) # type: ignore [union-attr]
most_wished = { most_wished = {
"name": product.name, "name": product.name,
"image": img, "image": img,
"admin_url": reverse("admin:core_product_change", args=[product.pk]), "admin_url": reverse(
"admin:core_product_change", args=[product.pk]
),
} }
wished_top10 = list(wished_qs[:10]) wished_top10 = list(wished_qs[:10])
if wished_top10: if wished_top10:
counts_map = {row["products"]: row["cnt"] for row in wished_top10 if row.get("products")} counts_map = {
row["products"]: row["cnt"]
for row in wished_top10
if row.get("products")
}
products = Product.objects.filter(pk__in=counts_map.keys()) products = Product.objects.filter(pk__in=counts_map.keys())
product_by_id = {p.pk: p for p in products} product_by_id = {p.pk: p for p in products}
for row in wished_top10: for row in wished_top10:
@ -620,7 +709,9 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
context["daily_labels"] = labels context["daily_labels"] = labels
context["daily_orders"] = orders_series context["daily_orders"] = orders_series
context["daily_gross"] = gross_series context["daily_gross"] = gross_series
context["daily_title"] = _("Revenue & Orders (last %(days)d)") % {"days": period_days} context["daily_title"] = _("Revenue & Orders (last %(days)d)") % {
"days": period_days
}
except Exception as e: except Exception as e:
logger.warning("Failed to build daily stats: %s", e) logger.warning("Failed to build daily stats: %s", e)
context["daily_labels"] = [] context["daily_labels"] = []
@ -633,7 +724,9 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
context["daily_labels"] = [d.strftime("%d %b") for d in date_axis] context["daily_labels"] = [d.strftime("%d %b") for d in date_axis]
context["daily_orders"] = [0 for _i in date_axis] context["daily_orders"] = [0 for _i in date_axis]
context["daily_gross"] = [0.0 for _j in date_axis] context["daily_gross"] = [0.0 for _j in date_axis]
context["daily_title"] = _("Revenue & Orders (last %(days)d)") % {"days": period_days} context["daily_title"] = _("Revenue & Orders (last %(days)d)") % {
"days": period_days
}
low_stock_list: list[dict[str, str | int]] = [] low_stock_list: list[dict[str, str | int]] = []
with suppress(Exception): with suppress(Exception):
@ -649,7 +742,9 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
"name": str(p.get("name") or ""), "name": str(p.get("name") or ""),
"sku": str(p.get("sku") or ""), "sku": str(p.get("sku") or ""),
"qty": qty, "qty": qty,
"admin_url": reverse("admin:core_product_change", args=[p.get("id")]), "admin_url": reverse(
"admin:core_product_change", args=[p.get("id")]
),
} }
) )
@ -675,7 +770,9 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
most_popular_list: list[dict[str, str | int | float | None]] = [] most_popular_list: list[dict[str, str | int | float | None]] = []
with suppress(Exception): with suppress(Exception):
popular_qs = ( popular_qs = (
OrderProduct.objects.filter(status="FINISHED", order__status="FINISHED", product__isnull=False) OrderProduct.objects.filter(
status="FINISHED", order__status="FINISHED", product__isnull=False
)
.values("product") .values("product")
.annotate(total_qty=Sum("quantity")) .annotate(total_qty=Sum("quantity"))
.order_by("-total_qty") .order_by("-total_qty")
@ -684,16 +781,24 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
if popular_first and popular_first.get("product"): if popular_first and popular_first.get("product"):
product = Product.objects.filter(pk=popular_first["product"]).first() product = Product.objects.filter(pk=popular_first["product"]).first()
if product: if product:
img = product.images.first().image_url if product.images.exists() else "" # type: ignore [union-attr] img = (
product.images.first().image_url if product.images.exists() else ""
) # type: ignore [union-attr]
most_popular = { most_popular = {
"name": product.name, "name": product.name,
"image": img, "image": img,
"admin_url": reverse("admin:core_product_change", args=[product.pk]), "admin_url": reverse(
"admin:core_product_change", args=[product.pk]
),
} }
popular_top10 = list(popular_qs[:10]) popular_top10 = list(popular_qs[:10])
if popular_top10: if popular_top10:
qty_map = {row["product"]: row["total_qty"] for row in popular_top10 if row.get("product")} qty_map = {
row["product"]: row["total_qty"]
for row in popular_top10
if row.get("product")
}
products = Product.objects.filter(pk__in=qty_map.keys()) products = Product.objects.filter(pk__in=qty_map.keys())
product_by_id = {p.pk: p for p in products} product_by_id = {p.pk: p for p in products}
for row in popular_top10: for row in popular_top10:
@ -711,7 +816,12 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
} }
) )
customers_mix: dict[str, int | float] = {"new": 0, "returning": 0, "new_pct": 0.0, "returning_pct": 0.0} customers_mix: dict[str, int | float] = {
"new": 0,
"returning": 0,
"new_pct": 0.0,
"returning_pct": 0.0,
}
with suppress(Exception): with suppress(Exception):
mix = get_customer_mix() mix = get_customer_mix()
n = int(mix.get("new", 0)) n = int(mix.get("new", 0))
@ -719,7 +829,13 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
t = max(n + r, 0) t = max(n + r, 0)
new_pct = round((n / t * 100.0), 1) if t > 0 else 0.0 new_pct = round((n / t * 100.0), 1) if t > 0 else 0.0
ret_pct = round((r / t * 100.0), 1) if t > 0 else 0.0 ret_pct = round((r / t * 100.0), 1) if t > 0 else 0.0
customers_mix = {"new": n, "returning": r, "new_pct": new_pct, "returning_pct": ret_pct, "total": t} customers_mix = {
"new": n,
"returning": r,
"new_pct": new_pct,
"returning_pct": ret_pct,
"total": t,
}
shipped_vs_digital: dict[str, int | float] = { shipped_vs_digital: dict[str, int | float] = {
"digital_qty": 0, "digital_qty": 0,

View file

@ -44,7 +44,14 @@ from engine.core.docs.drf.viewsets import (
VENDOR_SCHEMA, VENDOR_SCHEMA,
WISHLIST_SCHEMA, WISHLIST_SCHEMA,
) )
from engine.core.filters import AddressFilter, BrandFilter, CategoryFilter, FeedbackFilter, OrderFilter, ProductFilter from engine.core.filters import (
AddressFilter,
BrandFilter,
CategoryFilter,
FeedbackFilter,
OrderFilter,
ProductFilter,
)
from engine.core.models import ( from engine.core.models import (
Address, Address,
Attribute, Attribute,
@ -143,11 +150,18 @@ class EvibesViewSet(ModelViewSet):
action_serializer_classes: dict[str, Type[Serializer]] = {} action_serializer_classes: dict[str, Type[Serializer]] = {}
additional: dict[str, str] = {} additional: dict[str, str] = {}
permission_classes = [EvibesPermission] permission_classes = [EvibesPermission]
renderer_classes = [CamelCaseJSONRenderer, MultiPartRenderer, XMLRenderer, YAMLRenderer] renderer_classes = [
CamelCaseJSONRenderer,
MultiPartRenderer,
XMLRenderer,
YAMLRenderer,
]
def get_serializer_class(self) -> Type[Serializer]: def get_serializer_class(self) -> Type[Serializer]:
# noinspection PyTypeChecker # noinspection PyTypeChecker
return self.action_serializer_classes.get(self.action, super().get_serializer_class()) # type: ignore [arg-type] return self.action_serializer_classes.get(
self.action, super().get_serializer_class()
) # type: ignore [arg-type]
@extend_schema_view(**ATTRIBUTE_GROUP_SCHEMA) @extend_schema_view(**ATTRIBUTE_GROUP_SCHEMA)
@ -272,7 +286,11 @@ class CategoryViewSet(EvibesViewSet):
title = f"{category.name} | {settings.PROJECT_NAME}" title = f"{category.name} | {settings.PROJECT_NAME}"
description = (category.description or "")[:180] description = (category.description or "")[:180]
canonical = f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/catalog/{category.slug}" canonical = f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/catalog/{category.slug}"
og_image = request.build_absolute_uri(category.image.url) if getattr(category, "image", None) else "" og_image = (
request.build_absolute_uri(category.image.url)
if getattr(category, "image", None)
else ""
)
og = { og = {
"title": title, "title": title,
@ -286,10 +304,20 @@ class CategoryViewSet(EvibesViewSet):
crumbs = [("Home", f"https://{settings.BASE_DOMAIN}/")] crumbs = [("Home", f"https://{settings.BASE_DOMAIN}/")]
if category.get_ancestors().exists(): if category.get_ancestors().exists():
for c in category.get_ancestors(): for c in category.get_ancestors():
crumbs.append((c.name, f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/catalog/{c.slug}")) crumbs.append(
(
c.name,
f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/catalog/{c.slug}",
)
)
crumbs.append((category.name, canonical)) crumbs.append((category.name, canonical))
json_ld = [org_schema(), website_schema(), breadcrumb_schema(crumbs), category_schema(category, canonical)] json_ld = [
org_schema(),
website_schema(),
breadcrumb_schema(crumbs),
category_schema(category, canonical),
]
product_urls = [] product_urls = []
qs = ( qs = (
@ -303,7 +331,9 @@ class CategoryViewSet(EvibesViewSet):
.distinct()[:24] .distinct()[:24]
) )
for p in qs: for p in qs:
product_urls.append(f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/product/{p.slug}") product_urls.append(
f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/product/{p.slug}"
)
if product_urls: if product_urls:
json_ld.append(item_list_schema(product_urls)) json_ld.append(item_list_schema(product_urls))
@ -443,7 +473,9 @@ class ProductViewSet(EvibesViewSet):
"related feedback of a product." "related feedback of a product."
) )
queryset = Product.objects.prefetch_related("tags", "attributes", "stocks", "images").all() queryset = Product.objects.prefetch_related(
"tags", "attributes", "stocks", "images"
).all()
filter_backends = [DjangoFilterBackend] filter_backends = [DjangoFilterBackend]
filterset_class = ProductFilter filterset_class = ProductFilter
serializer_class = ProductDetailSerializer serializer_class = ProductDetailSerializer
@ -466,7 +498,9 @@ class ProductViewSet(EvibesViewSet):
if self.request.user.has_perm("core.view_product"): if self.request.user.has_perm("core.view_product"):
return qs return qs
active_stocks = Stock.objects.filter(product_id=OuterRef("pk"), vendor__is_active=True) active_stocks = Stock.objects.filter(
product_id=OuterRef("pk"), vendor__is_active=True
)
return ( return (
qs.filter( qs.filter(
@ -530,7 +564,9 @@ class ProductViewSet(EvibesViewSet):
rating = {"value": p.rating, "count": p.feedbacks_count} rating = {"value": p.rating, "count": p.feedbacks_count}
title = f"{p.name} | {settings.PROJECT_NAME}" title = f"{p.name} | {settings.PROJECT_NAME}"
description = (p.description or "")[:180] description = (p.description or "")[:180]
canonical = f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/product/{p.slug}" canonical = (
f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/product/{p.slug}"
)
og = { og = {
"title": title, "title": title,
"description": description, "description": description,
@ -543,7 +579,12 @@ class ProductViewSet(EvibesViewSet):
crumbs = [("Home", f"https://{settings.BASE_DOMAIN}/")] crumbs = [("Home", f"https://{settings.BASE_DOMAIN}/")]
if p.category: if p.category:
for c in p.category.get_ancestors(include_self=True): for c in p.category.get_ancestors(include_self=True):
crumbs.append((c.name, f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/catalog/{c.slug}")) crumbs.append(
(
c.name,
f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/catalog/{c.slug}",
)
)
crumbs.append((p.name, canonical)) crumbs.append((p.name, canonical))
json_ld = [org_schema(), website_schema()] json_ld = [org_schema(), website_schema()]
@ -642,7 +683,9 @@ class OrderViewSet(EvibesViewSet):
additional = {"retrieve": "ALLOW"} additional = {"retrieve": "ALLOW"}
def get_serializer_class(self): def get_serializer_class(self):
return self.action_serializer_classes.get(self.action, super().get_serializer_class()) return self.action_serializer_classes.get(
self.action, super().get_serializer_class()
)
def get_queryset(self): def get_queryset(self):
qs = super().get_queryset() qs = super().get_queryset()
@ -705,19 +748,34 @@ class OrderViewSet(EvibesViewSet):
) )
match str(type(instance)): match str(type(instance)):
case "<class 'engine.payments.models.Transaction'>": case "<class 'engine.payments.models.Transaction'>":
return Response(status=status.HTTP_202_ACCEPTED, data=TransactionProcessSerializer(instance).data) return Response(
status=status.HTTP_202_ACCEPTED,
data=TransactionProcessSerializer(instance).data,
)
case "<class 'engine.core.models.Order'>": case "<class 'engine.core.models.Order'>":
return Response(status=status.HTTP_200_OK, data=OrderDetailSerializer(instance).data) return Response(
status=status.HTTP_200_OK,
data=OrderDetailSerializer(instance).data,
)
case _: case _:
raise TypeError(_(f"wrong type came from order.buy() method: {type(instance)!s}")) raise TypeError(
_(
f"wrong type came from order.buy() method: {type(instance)!s}"
)
)
except Order.DoesNotExist: except Order.DoesNotExist:
name = "Order" name = "Order"
return Response(status=status.HTTP_404_NOT_FOUND, data={"detail": _(f"{name} does not exist: {uuid}")}) return Response(
status=status.HTTP_404_NOT_FOUND,
data={"detail": _(f"{name} does not exist: {uuid}")},
)
except Exception as e: except Exception as e:
return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": str(e)}) return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": str(e)})
@action(detail=False, methods=["post"], url_path="buy_unregistered") @action(detail=False, methods=["post"], url_path="buy_unregistered")
@method_decorator(ratelimit(key="ip", rate="10/h" if not settings.DEBUG else "888/h")) @method_decorator(
ratelimit(key="ip", rate="10/h" if not settings.DEBUG else "888/h")
)
def buy_unregistered(self, request: Request, *args, **kwargs) -> Response: def buy_unregistered(self, request: Request, *args, **kwargs) -> Response:
serializer = BuyUnregisteredOrderSerializer(data=request.data) serializer = BuyUnregisteredOrderSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
@ -729,12 +787,21 @@ class OrderViewSet(EvibesViewSet):
promocode_uuid=serializer.validated_data.get("promocode_uuid"), promocode_uuid=serializer.validated_data.get("promocode_uuid"),
customer_name=serializer.validated_data.get("customer_name"), customer_name=serializer.validated_data.get("customer_name"),
customer_email=serializer.validated_data.get("customer_email"), customer_email=serializer.validated_data.get("customer_email"),
customer_phone_number=serializer.validated_data.get("customer_phone_number"), customer_phone_number=serializer.validated_data.get(
billing_customer_address=serializer.validated_data.get("billing_customer_address_uuid"), "customer_phone_number"
shipping_customer_address=serializer.validated_data.get("shipping_customer_address_uuid"), ),
billing_customer_address=serializer.validated_data.get(
"billing_customer_address_uuid"
),
shipping_customer_address=serializer.validated_data.get(
"shipping_customer_address_uuid"
),
payment_method=serializer.validated_data.get("payment_method"), payment_method=serializer.validated_data.get("payment_method"),
) )
return Response(status=status.HTTP_201_CREATED, data=TransactionProcessSerializer(transaction).data) return Response(
status=status.HTTP_201_CREATED,
data=TransactionProcessSerializer(transaction).data,
)
except Exception as e: except Exception as e:
return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": str(e)}) return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": str(e)})
@ -745,18 +812,27 @@ class OrderViewSet(EvibesViewSet):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
try: try:
order = self.get_object() order = self.get_object()
if not (request.user.has_perm("core.add_orderproduct") or request.user == order.user): if not (
request.user.has_perm("core.add_orderproduct")
or request.user == order.user
):
raise PermissionDenied(permission_denied_message) raise PermissionDenied(permission_denied_message)
order = order.add_product( order = order.add_product(
product_uuid=serializer.validated_data.get("product_uuid"), product_uuid=serializer.validated_data.get("product_uuid"),
attributes=format_attributes(serializer.validated_data.get("attributes")), attributes=format_attributes(
serializer.validated_data.get("attributes")
),
)
return Response(
status=status.HTTP_200_OK, data=OrderDetailSerializer(order).data
) )
return Response(status=status.HTTP_200_OK, data=OrderDetailSerializer(order).data)
except Order.DoesNotExist as dne: except Order.DoesNotExist as dne:
return Response(status=status.HTTP_404_NOT_FOUND, data={"detail": str(dne)}) return Response(status=status.HTTP_404_NOT_FOUND, data={"detail": str(dne)})
except ValueError as ve: except ValueError as ve:
return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ve)}) return Response(
status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ve)}
)
@action(detail=True, methods=["post"], url_path="remove_order_product") @action(detail=True, methods=["post"], url_path="remove_order_product")
@method_decorator(ratelimit(key="ip", rate="1/s" if not settings.DEBUG else "44/s")) @method_decorator(ratelimit(key="ip", rate="1/s" if not settings.DEBUG else "44/s"))
@ -765,18 +841,27 @@ class OrderViewSet(EvibesViewSet):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
try: try:
order = self.get_object() order = self.get_object()
if not (request.user.has_perm("core.delete_orderproduct") or request.user == order.user): if not (
request.user.has_perm("core.delete_orderproduct")
or request.user == order.user
):
raise PermissionDenied(permission_denied_message) raise PermissionDenied(permission_denied_message)
order = order.remove_product( order = order.remove_product(
product_uuid=serializer.validated_data.get("product_uuid"), product_uuid=serializer.validated_data.get("product_uuid"),
attributes=format_attributes(serializer.validated_data.get("attributes")), attributes=format_attributes(
serializer.validated_data.get("attributes")
),
)
return Response(
status=status.HTTP_200_OK, data=OrderDetailSerializer(order).data
) )
return Response(status=status.HTTP_200_OK, data=OrderDetailSerializer(order).data)
except Order.DoesNotExist as dne: except Order.DoesNotExist as dne:
return Response(status=status.HTTP_404_NOT_FOUND, data={"detail": str(dne)}) return Response(status=status.HTTP_404_NOT_FOUND, data={"detail": str(dne)})
except ValueError as ve: except ValueError as ve:
return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ve)}) return Response(
status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ve)}
)
@action(detail=True, methods=["post"], url_path="bulk_add_order_products") @action(detail=True, methods=["post"], url_path="bulk_add_order_products")
@method_decorator(ratelimit(key="ip", rate="1/s" if not settings.DEBUG else "44/s")) @method_decorator(ratelimit(key="ip", rate="1/s" if not settings.DEBUG else "44/s"))
@ -786,17 +871,24 @@ class OrderViewSet(EvibesViewSet):
lookup_val = kwargs.get(self.lookup_field) lookup_val = kwargs.get(self.lookup_field)
try: try:
order = Order.objects.get(uuid=str(lookup_val)) order = Order.objects.get(uuid=str(lookup_val))
if not (request.user.has_perm("core.add_orderproduct") or request.user == order.user): if not (
request.user.has_perm("core.add_orderproduct")
or request.user == order.user
):
raise PermissionDenied(permission_denied_message) raise PermissionDenied(permission_denied_message)
order = order.bulk_add_products( order = order.bulk_add_products(
products=serializer.validated_data.get("products"), products=serializer.validated_data.get("products"),
) )
return Response(status=status.HTTP_200_OK, data=OrderDetailSerializer(order).data) return Response(
status=status.HTTP_200_OK, data=OrderDetailSerializer(order).data
)
except Order.DoesNotExist as dne: except Order.DoesNotExist as dne:
return Response(status=status.HTTP_404_NOT_FOUND, data={"detail": str(dne)}) return Response(status=status.HTTP_404_NOT_FOUND, data={"detail": str(dne)})
except ValueError as ve: except ValueError as ve:
return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ve)}) return Response(
status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ve)}
)
@action(detail=True, methods=["post"], url_path="bulk_remove_order_products") @action(detail=True, methods=["post"], url_path="bulk_remove_order_products")
@method_decorator(ratelimit(key="ip", rate="1/s" if not settings.DEBUG else "44/s")) @method_decorator(ratelimit(key="ip", rate="1/s" if not settings.DEBUG else "44/s"))
@ -805,17 +897,24 @@ class OrderViewSet(EvibesViewSet):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
try: try:
order = self.get_object() order = self.get_object()
if not (request.user.has_perm("core.delete_orderproduct") or request.user == order.user): if not (
request.user.has_perm("core.delete_orderproduct")
or request.user == order.user
):
raise PermissionDenied(permission_denied_message) raise PermissionDenied(permission_denied_message)
order = order.bulk_remove_products( order = order.bulk_remove_products(
products=serializer.validated_data.get("products"), products=serializer.validated_data.get("products"),
) )
return Response(status=status.HTTP_200_OK, data=OrderDetailSerializer(order).data) return Response(
status=status.HTTP_200_OK, data=OrderDetailSerializer(order).data
)
except Order.DoesNotExist as dne: except Order.DoesNotExist as dne:
return Response(status=status.HTTP_404_NOT_FOUND, data={"detail": str(dne)}) return Response(status=status.HTTP_404_NOT_FOUND, data={"detail": str(dne)})
except ValueError as ve: except ValueError as ve:
return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ve)}) return Response(
status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ve)}
)
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
@ -856,7 +955,10 @@ class OrderProductViewSet(EvibesViewSet):
order_product = OrderProduct.objects.get(uuid=str(kwargs.get("pk"))) order_product = OrderProduct.objects.get(uuid=str(kwargs.get("pk")))
if not order_product.order: if not order_product.order:
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
if not (request.user.has_perm("core.change_orderproduct") or request.user == order_product.order.user): if not (
request.user.has_perm("core.change_orderproduct")
or request.user == order_product.order.user
):
raise PermissionDenied(permission_denied_message) raise PermissionDenied(permission_denied_message)
feedback = order_product.do_feedback( feedback = order_product.do_feedback(
rating=serializer.validated_data.get("rating"), rating=serializer.validated_data.get("rating"),
@ -865,7 +967,10 @@ class OrderProductViewSet(EvibesViewSet):
) )
match serializer.validated_data.get("action"): match serializer.validated_data.get("action"):
case "add": case "add":
return Response(data=FeedbackDetailSerializer(feedback).data, status=status.HTTP_201_CREATED) return Response(
data=FeedbackDetailSerializer(feedback).data,
status=status.HTTP_201_CREATED,
)
case "remove": case "remove":
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
case _: case _:
@ -889,11 +994,21 @@ class ProductImageViewSet(EvibesViewSet):
@extend_schema_view(**PROMOCODE_SCHEMA) @extend_schema_view(**PROMOCODE_SCHEMA)
class PromoCodeViewSet(EvibesViewSet): class PromoCodeViewSet(EvibesViewSet):
__doc__ = _("Manages the retrieval and handling of PromoCode instances through various API actions.") __doc__ = _(
"Manages the retrieval and handling of PromoCode instances through various API actions."
)
queryset = PromoCode.objects.all() queryset = PromoCode.objects.all()
filter_backends = [DjangoFilterBackend] filter_backends = [DjangoFilterBackend]
filterset_fields = ["code", "discount_amount", "discount_percent", "start_time", "end_time", "used_on", "is_active"] filterset_fields = [
"code",
"discount_amount",
"discount_percent",
"start_time",
"end_time",
"used_on",
"is_active",
]
serializer_class = PromoCodeDetailSerializer serializer_class = PromoCodeDetailSerializer
action_serializer_classes = { action_serializer_classes = {
"list": PromoCodeSimpleSerializer, "list": PromoCodeSimpleSerializer,
@ -984,14 +1099,19 @@ class WishlistViewSet(EvibesViewSet):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
try: try:
wishlist = self.get_object() wishlist = self.get_object()
if not (request.user.has_perm("core.change_wishlist") or request.user == wishlist.user): if not (
request.user.has_perm("core.change_wishlist")
or request.user == wishlist.user
):
raise PermissionDenied(permission_denied_message) raise PermissionDenied(permission_denied_message)
wishlist = wishlist.add_product( wishlist = wishlist.add_product(
product_uuid=serializer.validated_data.get("product_uuid"), product_uuid=serializer.validated_data.get("product_uuid"),
) )
return Response(status=status.HTTP_200_OK, data=WishlistDetailSerializer(wishlist).data) return Response(
status=status.HTTP_200_OK, data=WishlistDetailSerializer(wishlist).data
)
except Wishlist.DoesNotExist: except Wishlist.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
@ -1002,14 +1122,19 @@ class WishlistViewSet(EvibesViewSet):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
try: try:
wishlist = self.get_object() wishlist = self.get_object()
if not (request.user.has_perm("core.change_wishlist") or request.user == wishlist.user): if not (
request.user.has_perm("core.change_wishlist")
or request.user == wishlist.user
):
raise PermissionDenied(permission_denied_message) raise PermissionDenied(permission_denied_message)
wishlist = wishlist.remove_product( wishlist = wishlist.remove_product(
product_uuid=serializer.validated_data.get("product_uuid"), product_uuid=serializer.validated_data.get("product_uuid"),
) )
return Response(status=status.HTTP_200_OK, data=WishlistDetailSerializer(wishlist).data) return Response(
status=status.HTTP_200_OK, data=WishlistDetailSerializer(wishlist).data
)
except Wishlist.DoesNotExist: except Wishlist.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
@ -1020,32 +1145,44 @@ class WishlistViewSet(EvibesViewSet):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
try: try:
wishlist = self.get_object() wishlist = self.get_object()
if not (request.user.has_perm("core.change_wishlist") or request.user == wishlist.user): if not (
request.user.has_perm("core.change_wishlist")
or request.user == wishlist.user
):
raise PermissionDenied(permission_denied_message) raise PermissionDenied(permission_denied_message)
wishlist = wishlist.bulk_add_products( wishlist = wishlist.bulk_add_products(
product_uuids=serializer.validated_data.get("product_uuids"), product_uuids=serializer.validated_data.get("product_uuids"),
) )
return Response(status=status.HTTP_200_OK, data=WishlistDetailSerializer(wishlist).data) return Response(
status=status.HTTP_200_OK, data=WishlistDetailSerializer(wishlist).data
)
except Order.DoesNotExist: except Order.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
@action(detail=True, methods=["post"], url_path="bulk_remove_wishlist_product") @action(detail=True, methods=["post"], url_path="bulk_remove_wishlist_product")
def bulk_remove_wishlist_products(self, request: Request, *args, **kwargs) -> Response: def bulk_remove_wishlist_products(
self, request: Request, *args, **kwargs
) -> Response:
serializer = BulkRemoveWishlistProductSerializer(data=request.data) serializer = BulkRemoveWishlistProductSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
try: try:
wishlist = self.get_object() wishlist = self.get_object()
if not (request.user.has_perm("core.change_wishlist") or request.user == wishlist.user): if not (
request.user.has_perm("core.change_wishlist")
or request.user == wishlist.user
):
raise PermissionDenied(permission_denied_message) raise PermissionDenied(permission_denied_message)
wishlist = wishlist.bulk_remove_products( wishlist = wishlist.bulk_remove_products(
product_uuids=serializer.validated_data.get("product_uuids"), product_uuids=serializer.validated_data.get("product_uuids"),
) )
return Response(status=status.HTTP_200_OK, data=WishlistDetailSerializer(wishlist).data) return Response(
status=status.HTTP_200_OK, data=WishlistDetailSerializer(wishlist).data
)
except Order.DoesNotExist: except Order.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
@ -1085,12 +1222,16 @@ class AddressViewSet(EvibesViewSet):
def retrieve(self, request: Request, *args, **kwargs) -> Response: def retrieve(self, request: Request, *args, **kwargs) -> Response:
try: try:
address = Address.objects.get(uuid=str(kwargs.get("pk"))) address = Address.objects.get(uuid=str(kwargs.get("pk")))
return Response(status=status.HTTP_200_OK, data=self.get_serializer(address).data) return Response(
status=status.HTTP_200_OK, data=self.get_serializer(address).data
)
except Address.DoesNotExist: except Address.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
def create(self, request: Request, *args, **kwargs) -> Response: def create(self, request: Request, *args, **kwargs) -> Response:
create_serializer = AddressCreateSerializer(data=request.data, context={"request": request}) create_serializer = AddressCreateSerializer(
data=request.data, context={"request": request}
)
create_serializer.is_valid(raise_exception=True) create_serializer.is_valid(raise_exception=True)
address_obj = create_serializer.create(create_serializer.validated_data) address_obj = create_serializer.create(create_serializer.validated_data)

View file

@ -32,7 +32,10 @@ class JSONTableWidget(forms.Widget):
return super().render(name, value, attrs, renderer) return super().render(name, value, attrs, renderer)
def value_from_datadict( def value_from_datadict(
self, data: Mapping[str, Any], files: MultiValueDict[str, UploadedFile], name: str self,
data: Mapping[str, Any],
files: MultiValueDict[str, UploadedFile],
name: str,
) -> str | None: ) -> str | None:
json_data = {} json_data = {}

View file

@ -3,7 +3,11 @@ from drf_spectacular.utils import extend_schema
from rest_framework import status from rest_framework import status
from engine.core.docs.drf import error from engine.core.docs.drf import error
from engine.payments.serializers import DepositSerializer, TransactionProcessSerializer, LimitsSerializer from engine.payments.serializers import (
DepositSerializer,
LimitsSerializer,
TransactionProcessSerializer,
)
DEPOSIT_SCHEMA = { DEPOSIT_SCHEMA = {
"post": extend_schema( "post": extend_schema(
@ -27,7 +31,9 @@ LIMITS_SCHEMA = {
"payments", "payments",
], ],
summary=_("payment limits"), summary=_("payment limits"),
description=_("retrieve minimal and maximal allowed deposit amounts across available gateways"), description=_(
"retrieve minimal and maximal allowed deposit amounts across available gateways"
),
responses={ responses={
status.HTTP_200_OK: LimitsSerializer, status.HTTP_200_OK: LimitsSerializer,
status.HTTP_401_UNAUTHORIZED: error, status.HTTP_401_UNAUTHORIZED: error,

View file

@ -1,6 +1,6 @@
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import status from rest_framework import status
from engine.core.docs.drf import BASE_ERRORS from engine.core.docs.drf import BASE_ERRORS

View file

@ -16,7 +16,9 @@ class Deposit(BaseMutation):
def mutate(self, info, amount): def mutate(self, info, amount):
if info.context.user.is_authenticated: if info.context.user.is_authenticated:
transaction = Transaction.objects.create( transaction = Transaction.objects.create(
balance=info.context.user.payments_balance, amount=amount, currency="EUR" balance=info.context.user.payments_balance,
amount=amount,
currency="EUR",
) )
# noinspection PyTypeChecker # noinspection PyTypeChecker
return Deposit(transaction=transaction) return Deposit(transaction=transaction)

View file

@ -1,4 +1,14 @@
from django.db.models import BooleanField, Case, F, Manager, Q, QuerySet, Sum, Value, When from django.db.models import (
BooleanField,
Case,
F,
Manager,
Q,
QuerySet,
Sum,
Value,
When,
)
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils.timezone import now from django.utils.timezone import now
@ -8,9 +18,18 @@ class GatewayQuerySet(QuerySet):
today = now().date() today = now().date()
current_month_start = today.replace(day=1) current_month_start = today.replace(day=1)
return self.annotate( return self.annotate(
daily_sum=Coalesce(Sum("transactions__amount", filter=Q(transactions__created__date=today)), Value(0.0)), daily_sum=Coalesce(
Sum(
"transactions__amount", filter=Q(transactions__created__date=today)
),
Value(0.0),
),
monthly_sum=Coalesce( monthly_sum=Coalesce(
Sum("transactions__amount", filter=Q(transactions__created__date__gte=current_month_start)), Value(0.0) Sum(
"transactions__amount",
filter=Q(transactions__created__date__gte=current_month_start),
),
Value(0.0),
), ),
) )

View file

@ -43,7 +43,9 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
("amount", models.FloatField(default=0)), ("amount", models.FloatField(default=0)),
@ -86,13 +88,18 @@ class Migration(migrations.Migration):
( (
"modified", "modified",
django_extensions.db.fields.ModificationDateTimeField( django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, help_text="when the object was last modified", verbose_name="modified" auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
), ),
), ),
("amount", models.FloatField()), ("amount", models.FloatField()),
("currency", models.CharField(max_length=3)), ("currency", models.CharField(max_length=3)),
("payment_method", models.CharField(max_length=20)), ("payment_method", models.CharField(max_length=20)),
("process", models.JSONField(default=dict, verbose_name="processing details")), (
"process",
models.JSONField(default=dict, verbose_name="processing details"),
),
], ],
options={ options={
"verbose_name": "transaction", "verbose_name": "transaction",

View file

@ -28,7 +28,9 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name="transaction", model_name="transaction",
name="balance", name="balance",
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="payments.balance"), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="payments.balance"
),
), ),
migrations.AddField( migrations.AddField(
model_name="transaction", model_name="transaction",
@ -44,6 +46,8 @@ class Migration(migrations.Migration):
), ),
migrations.AddIndex( migrations.AddIndex(
model_name="transaction", model_name="transaction",
index=django.contrib.postgres.indexes.GinIndex(fields=["process"], name="payments_tr_process_d5b008_gin"), index=django.contrib.postgres.indexes.GinIndex(
fields=["process"], name="payments_tr_process_d5b008_gin"
),
), ),
] ]

View file

@ -1,6 +1,7 @@
import uuid
import django.db.models.deletion import django.db.models.deletion
import django_extensions.db.fields import django_extensions.db.fields
import uuid
from django.db import migrations, models from django.db import migrations, models
@ -95,11 +96,15 @@ class Migration(migrations.Migration):
), ),
( (
"minimum_transaction_amount", "minimum_transaction_amount",
models.FloatField(default=0, verbose_name="minimum transaction amount"), models.FloatField(
default=0, verbose_name="minimum transaction amount"
),
), ),
( (
"maximum_transaction_amount", "maximum_transaction_amount",
models.FloatField(default=0, verbose_name="maximum transaction amount"), models.FloatField(
default=0, verbose_name="maximum transaction amount"
),
), ),
( (
"daily_limit", "daily_limit",
@ -119,11 +124,15 @@ class Migration(migrations.Migration):
), ),
( (
"priority", "priority",
models.PositiveIntegerField(default=10, unique=True, verbose_name="priority"), models.PositiveIntegerField(
default=10, unique=True, verbose_name="priority"
),
), ),
( (
"integration_variables", "integration_variables",
models.JSONField(default=dict, verbose_name="integration variables"), models.JSONField(
default=dict, verbose_name="integration variables"
),
), ),
], ],
options={ options={

View file

@ -10,6 +10,8 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.AddIndex( migrations.AddIndex(
model_name="transaction", model_name="transaction",
index=models.Index(fields=["created"], name="payments_tr_created_95e595_idx"), index=models.Index(
fields=["created"], name="payments_tr_created_95e595_idx"
),
), ),
] ]

View file

@ -25,7 +25,13 @@ from evibes.utils.misc import create_object
class Transaction(NiceModel): class Transaction(NiceModel):
amount = FloatField(null=False, blank=False) amount = FloatField(null=False, blank=False)
balance = ForeignKey("payments.Balance", on_delete=CASCADE, blank=True, null=True, related_name="transactions") balance = ForeignKey(
"payments.Balance",
on_delete=CASCADE,
blank=True,
null=True,
related_name="transactions",
)
currency = CharField(max_length=3, null=False, blank=False) currency = CharField(max_length=3, null=False, blank=False)
payment_method = CharField(max_length=20, null=True, blank=True) payment_method = CharField(max_length=20, null=True, blank=True)
order = ForeignKey( order = ForeignKey(
@ -37,7 +43,13 @@ class Transaction(NiceModel):
related_name="payments_transactions", related_name="payments_transactions",
) )
process = JSONField(verbose_name=_("processing details"), default=dict) process = JSONField(verbose_name=_("processing details"), default=dict)
gateway = ForeignKey("payments.Gateway", on_delete=CASCADE, blank=True, null=True, related_name="transactions") gateway = ForeignKey(
"payments.Gateway",
on_delete=CASCADE,
blank=True,
null=True,
related_name="transactions",
)
def __str__(self): def __str__(self):
return ( return (
@ -64,7 +76,11 @@ class Transaction(NiceModel):
class Balance(NiceModel): class Balance(NiceModel):
amount = FloatField(null=False, blank=False, default=0) amount = FloatField(null=False, blank=False, default=0)
user = OneToOneField( user = OneToOneField(
to=settings.AUTH_USER_MODEL, on_delete=CASCADE, blank=True, null=True, related_name="payments_balance" to=settings.AUTH_USER_MODEL,
on_delete=CASCADE,
blank=True,
null=True,
related_name="payments_balance",
) )
transactions: QuerySet["Transaction"] transactions: QuerySet["Transaction"]
@ -122,8 +138,12 @@ class Gateway(NiceModel):
verbose_name=_("monthly limit"), verbose_name=_("monthly limit"),
help_text=_("monthly sum limit of transactions' amounts. 0 means no limit"), help_text=_("monthly sum limit of transactions' amounts. 0 means no limit"),
) )
priority = PositiveIntegerField(null=False, blank=False, default=10, verbose_name=_("priority"), unique=True) priority = PositiveIntegerField(
integration_variables = JSONField(null=False, blank=False, default=dict, verbose_name=_("integration variables")) null=False, blank=False, default=10, verbose_name=_("priority"), unique=True
)
integration_variables = JSONField(
null=False, blank=False, default=dict, verbose_name=_("integration variables")
)
def __str__(self): def __str__(self):
return self.name return self.name
@ -140,18 +160,25 @@ class Gateway(NiceModel):
today = timezone.localdate() today = timezone.localdate()
tz = timezone.get_current_timezone() tz = timezone.get_current_timezone()
month_start = timezone.make_aware(datetime.combine(today.replace(day=1), time.min), tz) month_start = timezone.make_aware(
datetime.combine(today.replace(day=1), time.min), tz
)
if today.month == 12: if today.month == 12:
next_month_date = today.replace(year=today.year + 1, month=1, day=1) next_month_date = today.replace(year=today.year + 1, month=1, day=1)
else: else:
next_month_date = today.replace(month=today.month + 1, day=1) next_month_date = today.replace(month=today.month + 1, day=1)
month_end = timezone.make_aware(datetime.combine(next_month_date, time.min), tz) month_end = timezone.make_aware(datetime.combine(next_month_date, time.min), tz)
daily_sum = self.transactions.filter(created__date=today).aggregate(total=Sum("amount"))["total"] or 0 daily_sum = (
self.transactions.filter(created__date=today).aggregate(
total=Sum("amount")
)["total"]
or 0
)
monthly_sum = ( monthly_sum = (
self.transactions.filter(created__gte=month_start, created__lt=month_end).aggregate(total=Sum("amount"))[ self.transactions.filter(
"total" created__gte=month_start, created__lt=month_end
] ).aggregate(total=Sum("amount"))["total"]
or 0 or 0
) )
@ -163,7 +190,9 @@ class Gateway(NiceModel):
def can_be_used(self, value: bool): def can_be_used(self, value: bool):
self.__dict__["can_be_used"] = value self.__dict__["can_be_used"] = value
def get_integration_class_object(self, raise_exc: bool = True) -> AbstractGateway | None: def get_integration_class_object(
self, raise_exc: bool = True
) -> AbstractGateway | None:
if not self.integration_path: if not self.integration_path:
if raise_exc: if raise_exc:
raise ValueError(_("gateway integration path is not set")) raise ValueError(_("gateway integration path is not set"))
@ -171,5 +200,8 @@ class Gateway(NiceModel):
try: try:
module_name, class_name = self.integration_path.rsplit(".", 1) module_name, class_name = self.integration_path.rsplit(".", 1)
except ValueError as exc: except ValueError as exc:
raise ValueError(_("invalid integration path: %(path)s") % {"path": self.integration_path}) from exc raise ValueError(
_("invalid integration path: %(path)s")
% {"path": self.integration_path}
) from exc
return create_object(module_name, class_name) return create_object(module_name, class_name)

View file

@ -16,14 +16,18 @@ logger = logging.getLogger(__name__)
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def create_balance_on_user_creation_signal(instance: User, created: bool, **kwargs: dict[Any, Any]) -> None: def create_balance_on_user_creation_signal(
instance: User, created: bool, **kwargs: dict[Any, Any]
) -> None:
if created: if created:
Balance.objects.create(user=instance) Balance.objects.create(user=instance)
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
@receiver(post_save, sender=Transaction) @receiver(post_save, sender=Transaction)
def process_transaction_changes(instance: Transaction, created: bool, **kwargs: dict[Any, Any]) -> None: def process_transaction_changes(
instance: Transaction, created: bool, **kwargs: dict[Any, Any]
) -> None:
if created: if created:
if not instance.gateway: if not instance.gateway:
instance.gateway = Gateway.objects.can_be_used().first() instance.gateway = Gateway.objects.can_be_used().first()
@ -46,7 +50,9 @@ def process_transaction_changes(instance: Transaction, created: bool, **kwargs:
) )
except Exception as e: except Exception as e:
instance.process = {"status": "ERRORED", "error": str(e)} instance.process = {"status": "ERRORED", "error": str(e)}
logger.error(f"Error processing transaction {instance.uuid}: {e}\n{traceback.format_exc()}") logger.error(
f"Error processing transaction {instance.uuid}: {e}\n{traceback.format_exc()}"
)
if not created: if not created:
status = str(instance.process.get("status", "")).lower() status = str(instance.process.get("status", "")).lower()
success = instance.process.get("success", False) success = instance.process.get("success", False)

View file

@ -7,7 +7,9 @@ from engine.payments.viewsets import TransactionViewSet
app_name = "payments" app_name = "payments"
payment_router = DefaultRouter() payment_router = DefaultRouter()
payment_router.register(prefix=r"transactions", viewset=TransactionViewSet, basename="transactions") payment_router.register(
prefix=r"transactions", viewset=TransactionViewSet, basename="transactions"
)
urlpatterns = [ urlpatterns = [
path(r"", include(payment_router.urls)), path(r"", include(payment_router.urls)),

View file

@ -8,12 +8,16 @@ from django.core.cache import cache
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def update_currencies_to_euro(currency: str, amount: str | float | int | Decimal) -> float: def update_currencies_to_euro(
currency: str, amount: str | float | int | Decimal
) -> float:
logger.warning("update_currencies_to_euro will be deprecated soon!") logger.warning("update_currencies_to_euro will be deprecated soon!")
rates = cache.get("rates", None) rates = cache.get("rates", None)
if not rates: if not rates:
response = requests.get(f"https://rates.icoadm.in/api/v1/rates?key={config.EXCHANGE_RATE_API_KEY}") response = requests.get(
f"https://rates.icoadm.in/api/v1/rates?key={config.EXCHANGE_RATE_API_KEY}"
)
rates = response.json().get("rates") rates = response.json().get("rates")
cache.set("rates", rates, 60 * 60 * 24) cache.set("rates", rates, 60 * 60 * 24)

View file

@ -20,7 +20,10 @@ def balance_deposit_email(transaction_pk: str) -> tuple[bool, str]:
return False, f"Transaction not found with the given pk: {transaction_pk}" return False, f"Transaction not found with the given pk: {transaction_pk}"
if not transaction.balance or not transaction.balance.user: if not transaction.balance or not transaction.balance.user:
return False, f"Balance not found for the given transaction pk: {transaction_pk}" return (
False,
f"Balance not found for the given transaction pk: {transaction_pk}",
)
activate(transaction.balance.user.language) activate(transaction.balance.user.language)

View file

@ -1,13 +1,17 @@
from typing import Type from typing import Type
from evibes.utils.misc import create_object
from engine.payments.gateways import AbstractGateway from engine.payments.gateways import AbstractGateway
from engine.payments.models import Gateway from engine.payments.models import Gateway
from evibes.utils.misc import create_object
def get_gateways_integrations(name: str | None = None) -> list[Type[AbstractGateway]]: def get_gateways_integrations(name: str | None = None) -> list[Type[AbstractGateway]]:
gateways_integrations: list[Type[AbstractGateway]] = [] gateways_integrations: list[Type[AbstractGateway]] = []
gateways = Gateway.objects.filter(is_active=True, name=name) if name else Gateway.objects.filter(is_active=True) gateways = (
Gateway.objects.filter(is_active=True, name=name)
if name
else Gateway.objects.filter(is_active=True)
)
for gateway in gateways: for gateway in gateways:
if gateway.integration_path: if gateway.integration_path:
module_name = ".".join(gateway.integration_path.split(".")[:-1]) module_name = ".".join(gateway.integration_path.split(".")[:-1])
@ -17,14 +21,17 @@ def get_gateways_integrations(name: str | None = None) -> list[Type[AbstractGate
def get_limits() -> tuple[float, float]: def get_limits() -> tuple[float, float]:
from django.db.models import Min, Max from django.db.models import Max, Min
qs = Gateway.objects.can_be_used().filter(can_be_used=True) qs = Gateway.objects.can_be_used().filter(can_be_used=True)
if not qs.exists(): if not qs.exists():
return 0.0, 0.0 return 0.0, 0.0
agg = qs.aggregate(min_limit=Min("minimum_transaction_amount"), max_limit=Max("maximum_transaction_amount")) agg = qs.aggregate(
min_limit=Min("minimum_transaction_amount"),
max_limit=Max("maximum_transaction_amount"),
)
min_limit = float(agg.get("min_limit") or 0.0) min_limit = float(agg.get("min_limit") or 0.0)
max_limit = float(agg.get("max_limit") or 0.0) max_limit = float(agg.get("max_limit") or 0.0)

View file

@ -12,7 +12,11 @@ from rest_framework.views import APIView
from engine.payments.docs.drf.views import DEPOSIT_SCHEMA, LIMITS_SCHEMA from engine.payments.docs.drf.views import DEPOSIT_SCHEMA, LIMITS_SCHEMA
from engine.payments.gateways import UnknownGatewayError from engine.payments.gateways import UnknownGatewayError
from engine.payments.models import Transaction from engine.payments.models import Transaction
from engine.payments.serializers import DepositSerializer, TransactionProcessSerializer, LimitsSerializer from engine.payments.serializers import (
DepositSerializer,
LimitsSerializer,
TransactionProcessSerializer,
)
from engine.payments.utils.gateways import get_limits from engine.payments.utils.gateways import get_limits
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -28,7 +32,9 @@ class DepositView(APIView):
"with the transaction details is provided." "with the transaction details is provided."
) )
def post(self, request: Request, *args: list[Any], **kwargs: dict[Any, Any]) -> Response: def post(
self, request: Request, *args: list[Any], **kwargs: dict[Any, Any]
) -> Response:
logger.debug(request.__dict__) logger.debug(request.__dict__)
serializer = DepositSerializer(data=request.data) serializer = DepositSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
@ -38,10 +44,15 @@ class DepositView(APIView):
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
transaction = Transaction.objects.create( transaction = Transaction.objects.create(
balance=request.user.payments_balance, amount=serializer.validated_data["amount"], currency="EUR" balance=request.user.payments_balance,
amount=serializer.validated_data["amount"],
currency="EUR",
) )
return Response(TransactionProcessSerializer(transaction).data, status=status.HTTP_303_SEE_OTHER) return Response(
TransactionProcessSerializer(transaction).data,
status=status.HTTP_303_SEE_OTHER,
)
@extend_schema(exclude=True) @extend_schema(exclude=True)
@ -54,19 +65,28 @@ class CallbackAPIView(APIView):
"indicating success or failure." "indicating success or failure."
) )
def post(self, request: Request, *args: list[Any], **kwargs: dict[Any, Any]) -> Response: def post(
self, request: Request, *args: list[Any], **kwargs: dict[Any, Any]
) -> Response:
try: try:
transaction = Transaction.objects.get(uuid=str(kwargs.get("uuid"))) transaction = Transaction.objects.get(uuid=str(kwargs.get("uuid")))
if not transaction.gateway: if not transaction.gateway:
raise UnknownGatewayError(_(f"Transaction {transaction.uuid} has no gateway")) raise UnknownGatewayError(
gateway_integration = transaction.gateway.get_integration_class_object(raise_exc=True) _(f"Transaction {transaction.uuid} has no gateway")
)
gateway_integration = transaction.gateway.get_integration_class_object(
raise_exc=True
)
if not gateway_integration: if not gateway_integration:
raise UnknownGatewayError(_(f"Gateway {transaction.gateway} has no integration")) raise UnknownGatewayError(
_(f"Gateway {transaction.gateway} has no integration")
)
gateway_integration.process_callback(request.data) gateway_integration.process_callback(request.data)
return Response(status=status.HTTP_202_ACCEPTED) return Response(status=status.HTTP_202_ACCEPTED)
except Exception as e: except Exception as e:
return Response( return Response(
status=status.HTTP_500_INTERNAL_SERVER_ERROR, data={"error": str(e), "detail": traceback.format_exc()} status=status.HTTP_500_INTERNAL_SERVER_ERROR,
data={"error": str(e), "detail": traceback.format_exc()},
) )
@ -76,7 +96,9 @@ class LimitsAPIView(APIView):
"This endpoint returns minimal and maximal allowed deposit amounts across available gateways." "This endpoint returns minimal and maximal allowed deposit amounts across available gateways."
) )
def get(self, request: Request, *args: list[Any], **kwargs: dict[Any, Any]) -> Response: def get(
self, request: Request, *args: list[Any], **kwargs: dict[Any, Any]
) -> Response:
min_amount, max_amount = get_limits() min_amount, max_amount = get_limits()
data = {"min_amount": min_amount, "max_amount": max_amount} data = {"min_amount": min_amount, "max_amount": max_amount}
return Response(LimitsSerializer(data).data, status=status.HTTP_200_OK) return Response(LimitsSerializer(data).data, status=status.HTTP_200_OK)

View file

@ -3,9 +3,9 @@ from drf_spectacular.utils import extend_schema_view
from rest_framework.viewsets import ReadOnlyModelViewSet from rest_framework.viewsets import ReadOnlyModelViewSet
from engine.core.permissions import EvibesPermission, IsOwner from engine.core.permissions import EvibesPermission, IsOwner
from engine.payments.serializers import TransactionSerializer
from engine.payments.docs.drf.viewsets import TRANSACTION_SCHEMA from engine.payments.docs.drf.viewsets import TRANSACTION_SCHEMA
from engine.payments.models import Transaction from engine.payments.models import Transaction
from engine.payments.serializers import TransactionSerializer
@extend_schema_view(**TRANSACTION_SCHEMA) @extend_schema_view(**TRANSACTION_SCHEMA)

View file

@ -27,6 +27,7 @@ from rest_framework_simplejwt.token_blacklist.models import (
OutstandingToken as BaseOutstandingToken, OutstandingToken as BaseOutstandingToken,
) )
from unfold.admin import ModelAdmin, TabularInline from unfold.admin import ModelAdmin, TabularInline
from unfold.forms import AdminPasswordChangeForm, UserCreationForm
from engine.core.admin import ActivationActionsMixin from engine.core.admin import ActivationActionsMixin
from engine.core.models import Order from engine.core.models import Order
@ -41,7 +42,6 @@ from engine.vibes_auth.models import (
ThreadStatus, ThreadStatus,
User, User,
) )
from unfold.forms import AdminPasswordChangeForm, UserCreationForm
class BalanceInline(TabularInline): # type: ignore [type-arg] class BalanceInline(TabularInline): # type: ignore [type-arg]
@ -114,17 +114,24 @@ class UserAdmin(ActivationActionsMixin, BaseUserAdmin, ModelAdmin): # type: ign
def get_queryset(self, request: HttpRequest) -> QuerySet[User]: def get_queryset(self, request: HttpRequest) -> QuerySet[User]:
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.prefetch_related("groups", "payments_balance", "orders").prefetch_related( return qs.prefetch_related(
"groups", "payments_balance", "orders"
).prefetch_related(
Prefetch( Prefetch(
"user_permissions", "user_permissions",
queryset=Permission.objects.select_related("content_type"), queryset=Permission.objects.select_related("content_type"),
) )
) )
def save_model(self, request: HttpRequest, obj: Any, form: UserForm, change: Any) -> None: def save_model(
self, request: HttpRequest, obj: Any, form: UserForm, change: Any
) -> None:
if form.cleaned_data.get("attributes") is None: if form.cleaned_data.get("attributes") is None:
obj.attributes = None obj.attributes = None
if form.cleaned_data.get("is_superuser", False) and not request.user.is_superuser: if (
form.cleaned_data.get("is_superuser", False)
and not request.user.is_superuser
):
raise PermissionDenied(_("You cannot jump over your head!")) raise PermissionDenied(_("You cannot jump over your head!"))
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)

View file

@ -15,7 +15,9 @@ USER_MESSAGE_CONSUMER_SCHEMA = {
], ],
"type": "send", "type": "send",
"summary": _("User messages entrypoint"), "summary": _("User messages entrypoint"),
"description": _("Anonymous or authenticated non-staff users send messages. Also supports action=ping."), "description": _(
"Anonymous or authenticated non-staff users send messages. Also supports action=ping."
),
"request": UserMessageRequestSerializer, "request": UserMessageRequestSerializer,
"responses": UserMessageResponseSerializer, "responses": UserMessageResponseSerializer,
} }
@ -26,7 +28,9 @@ STAFF_INBOX_CONSUMER_SCHEMA = {
], ],
"type": "send", "type": "send",
"summary": _("Staff inbox control"), "summary": _("Staff inbox control"),
"description": _("Staff-only actions: list_open, assign, reply, close, ping. Unified event payloads are emitted."), "description": _(
"Staff-only actions: list_open, assign, reply, close, ping. Unified event payloads are emitted."
),
"request": StaffInboxEventSerializer, "request": StaffInboxEventSerializer,
"responses": StaffInboxEventSerializer, "responses": StaffInboxEventSerializer,
} }

View file

@ -96,7 +96,11 @@ USER_SCHEMA = {
request=ActivateEmailSerializer, request=ActivateEmailSerializer,
responses={ responses={
status.HTTP_200_OK: UserSerializer, status.HTTP_200_OK: UserSerializer,
status.HTTP_400_BAD_REQUEST: {"description": _("activation link is invalid or account already activated")}, status.HTTP_400_BAD_REQUEST: {
"description": _(
"activation link is invalid or account already activated"
)
},
**BASE_ERRORS, **BASE_ERRORS,
}, },
), ),

View file

@ -41,7 +41,12 @@ class CreateUser(BaseMutation):
phone_number = String() phone_number = String()
is_subscribed = Boolean() is_subscribed = Boolean()
language = String() language = String()
referrer = String(required=False, description=_("the user's b64-encoded uuid who referred the new user to us.")) referrer = String(
required=False,
description=_(
"the user's b64-encoded uuid who referred the new user to us."
),
)
success = Boolean() success = Boolean()
@ -71,7 +76,9 @@ class CreateUser(BaseMutation):
phone_number=phone_number, phone_number=phone_number,
is_subscribed=is_subscribed if is_subscribed else False, is_subscribed=is_subscribed if is_subscribed else False,
language=language if language else settings.LANGUAGE_CODE, language=language if language else settings.LANGUAGE_CODE,
attributes={"referrer": kwargs.get("referrer", "")} if kwargs.get("referrer", "") else {}, attributes={"referrer": kwargs.get("referrer", "")}
if kwargs.get("referrer", "")
else {},
) )
# noinspection PyTypeChecker # noinspection PyTypeChecker
return CreateUser(success=True) return CreateUser(success=True)
@ -108,20 +115,28 @@ class UpdateUser(BaseMutation):
try: try:
user = User.objects.get(uuid=uuid) user = User.objects.get(uuid=uuid)
if not (info.context.user.has_perm("vibes_auth.change_user") or info.context.user == user): if not (
info.context.user.has_perm("vibes_auth.change_user")
or info.context.user == user
):
raise PermissionDenied(permission_denied_message) raise PermissionDenied(permission_denied_message)
email = kwargs.get("email") email = kwargs.get("email")
if (email is not None and not is_valid_email(email)) or User.objects.filter(email=email).exclude( if (email is not None and not is_valid_email(email)) or User.objects.filter(
uuid=uuid email=email
).exists(): ).exclude(uuid=uuid).exists():
raise BadRequest(_("malformed email")) raise BadRequest(_("malformed email"))
phone_number = kwargs.get("phone_number") phone_number = kwargs.get("phone_number")
if (phone_number is not None and not is_valid_phone_number(phone_number)) or ( if (
User.objects.filter(phone_number=phone_number).exclude(uuid=uuid).exists() and phone_number is not None phone_number is not None and not is_valid_phone_number(phone_number)
) or (
User.objects.filter(phone_number=phone_number)
.exclude(uuid=uuid)
.exists()
and phone_number is not None
): ):
raise BadRequest(_(f"malformed phone number: {phone_number}")) raise BadRequest(_(f"malformed phone number: {phone_number}"))
@ -131,7 +146,9 @@ class UpdateUser(BaseMutation):
if password: if password:
validate_password(password=password, user=user) validate_password(password=password, user=user)
if not compare_digest(password, "") and compare_digest(password, confirm_password): if not compare_digest(password, "") and compare_digest(
password, confirm_password
):
user.set_password(password) user.set_password(password)
user.save() user.save()
@ -145,12 +162,16 @@ class UpdateUser(BaseMutation):
user.attributes = {} user.attributes = {}
user.attributes.update({attr: value}) user.attributes.update({attr: value})
else: else:
raise BadRequest(_(f"Invalid attribute format: {attribute_pair}")) raise BadRequest(
_(f"Invalid attribute format: {attribute_pair}")
)
for attr, value in kwargs.items(): for attr, value in kwargs.items():
if attr == "password" or attr == "confirm_password": if attr == "password" or attr == "confirm_password":
continue continue
if is_safe_key(attr) or info.context.user.has_perm("vibes_auth.change_user"): if is_safe_key(attr) or info.context.user.has_perm(
"vibes_auth.change_user"
):
setattr(user, attr, value) setattr(user, attr, value)
user.save() user.save()
@ -185,7 +206,9 @@ class DeleteUser(BaseMutation):
# noinspection PyTypeChecker # noinspection PyTypeChecker
return DeleteUser(success=True) return DeleteUser(success=True)
except User.DoesNotExist as dne: except User.DoesNotExist as dne:
raise Http404(f"User with the given uuid: {uuid} or email: {email} does not exist.") from dne raise Http404(
f"User with the given uuid: {uuid} or email: {email} does not exist."
) from dne
raise PermissionDenied(permission_denied_message) raise PermissionDenied(permission_denied_message)
@ -199,7 +222,9 @@ class ObtainJSONWebToken(BaseMutation):
access_token = String(required=True) access_token = String(required=True)
def mutate(self, info, email, password): def mutate(self, info, email, password):
serializer = TokenObtainPairSerializer(data={"email": email, "password": password}, retrieve_user=False) serializer = TokenObtainPairSerializer(
data={"email": email, "password": password}, retrieve_user=False
)
try: try:
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
return ObtainJSONWebToken( return ObtainJSONWebToken(
@ -220,7 +245,9 @@ class RefreshJSONWebToken(BaseMutation):
refresh_token = String() refresh_token = String()
def mutate(self, info, refresh_token): def mutate(self, info, refresh_token):
serializer = TokenRefreshSerializer(data={"refresh": refresh_token}, retrieve_user=False) serializer = TokenRefreshSerializer(
data={"refresh": refresh_token}, retrieve_user=False
)
try: try:
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
return RefreshJSONWebToken( return RefreshJSONWebToken(
@ -246,7 +273,8 @@ class VerifyJSONWebToken(BaseMutation):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
# noinspection PyTypeChecker # noinspection PyTypeChecker
return VerifyJSONWebToken( return VerifyJSONWebToken(
token_is_valid=True, user=User.objects.get(uuid=serializer.validated_data["user"]) token_is_valid=True,
user=User.objects.get(uuid=serializer.validated_data["user"]),
) )
detail = traceback.format_exc() if settings.DEBUG else "" detail = traceback.format_exc() if settings.DEBUG else ""
# noinspection PyTypeChecker # noinspection PyTypeChecker
@ -332,7 +360,13 @@ class ConfirmResetPassword(BaseMutation):
# noinspection PyTypeChecker # noinspection PyTypeChecker
return ConfirmResetPassword(success=True) return ConfirmResetPassword(success=True)
except (TypeError, ValueError, OverflowError, ValidationError, User.DoesNotExist) as e: except (
TypeError,
ValueError,
OverflowError,
ValidationError,
User.DoesNotExist,
) as e:
raise BadRequest(_(f"something went wrong: {e!s}")) from e raise BadRequest(_(f"something went wrong: {e!s}")) from e

View file

@ -6,7 +6,12 @@ from graphene.types.generic import GenericScalar
from graphene_django import DjangoObjectType from graphene_django import DjangoObjectType
from graphql_relay.connection.array_connection import connection_from_array from graphql_relay.connection.array_connection import connection_from_array
from engine.core.graphene.object_types import OrderType, ProductType, WishlistType, AddressType from engine.core.graphene.object_types import (
AddressType,
OrderType,
ProductType,
WishlistType,
)
from engine.core.models import Product, Wishlist from engine.core.models import Product, Wishlist
from engine.payments.graphene.object_types import BalanceType from engine.payments.graphene.object_types import BalanceType
from engine.payments.models import Balance from engine.payments.models import Balance
@ -37,19 +42,29 @@ class RecentProductConnection(relay.Connection):
class UserType(DjangoObjectType): class UserType(DjangoObjectType):
recently_viewed = relay.ConnectionField( recently_viewed = relay.ConnectionField(
RecentProductConnection, RecentProductConnection,
description=_("the products this user has viewed most recently (max 48), in reversechronological order"), description=_(
"the products this user has viewed most recently (max 48), in reversechronological order"
),
) )
groups = List(lambda: GroupType, description=_("groups")) groups = List(lambda: GroupType, description=_("groups"))
user_permissions = List(lambda: PermissionType, description=_("permissions")) user_permissions = List(lambda: PermissionType, description=_("permissions"))
orders = List(lambda: OrderType, description=_("orders")) orders = List(lambda: OrderType, description=_("orders"))
wishlist = Field(lambda: WishlistType, description=_("wishlist")) wishlist = Field(lambda: WishlistType, description=_("wishlist"))
balance = Field(lambda: BalanceType, source="payments_balance", description=_("balance")) balance = Field(
avatar = String(description=_("avatar")) lambda: BalanceType, source="payments_balance", description=_("balance")
attributes = GenericScalar(description=_("attributes may be used to store custom data")) )
language = String( avatar = String(description=_("avatar"))
description=_(f"language is one of the {settings.LANGUAGES} with default {settings.LANGUAGE_CODE}") attributes = GenericScalar(
description=_("attributes may be used to store custom data")
)
language = String(
description=_(
f"language is one of the {settings.LANGUAGES} with default {settings.LANGUAGE_CODE}"
)
)
addresses = Field(
lambda: AddressType, source="address_set", description=_("address set")
) )
addresses = Field(lambda: AddressType, source="address_set", description=_("address set"))
class Meta: class Meta:
model = User model = User
@ -123,7 +138,9 @@ class UserType(DjangoObjectType):
products_by_uuid = {str(p.uuid): p for p in qs} products_by_uuid = {str(p.uuid): p for p in qs}
ordered_products = [products_by_uuid[u] for u in uuid_list if u in products_by_uuid] ordered_products = [
products_by_uuid[u] for u in uuid_list if u in products_by_uuid
]
return connection_from_array(ordered_products, kwargs) return connection_from_array(ordered_products, kwargs)

View file

@ -27,7 +27,9 @@ class UserManager(BaseUserManager):
if order.attributes.get("is_business"): if order.attributes.get("is_business"):
mark_business = True mark_business = True
if user.phone_number: if user.phone_number:
for order in Order.objects.filter(attributes__icontains=user.phone_number): for order in Order.objects.filter(
attributes__icontains=user.phone_number
):
if not order.user: if not order.user:
order.user = user order.user = user
order.save() order.save()
@ -80,7 +82,9 @@ class UserManager(BaseUserManager):
return user return user
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
def with_perm(self, perm, is_active=True, include_superusers=True, backend=None, obj=None): def with_perm(
self, perm, is_active=True, include_superusers=True, backend=None, obj=None
):
if backend is None: if backend is None:
# noinspection PyCallingNonCallable # noinspection PyCallingNonCallable
backends = auth._get_backends(return_tuples=True) backends = auth._get_backends(return_tuples=True)
@ -92,7 +96,9 @@ class UserManager(BaseUserManager):
"therefore must provide the `backend` argument." "therefore must provide the `backend` argument."
) )
elif not isinstance(backend, str): elif not isinstance(backend, str):
raise TypeError(f"backend must be a dotted import path string (got {backend}).") raise TypeError(
f"backend must be a dotted import path string (got {backend})."
)
else: else:
backend = auth.load_backend(backend) backend = auth.load_backend(backend)
if hasattr(backend, "with_perm"): if hasattr(backend, "with_perm"):

View file

@ -36,14 +36,22 @@ def _get_ip(scope) -> str:
async def _is_user_support(user: Any) -> bool: async def _is_user_support(user: Any) -> bool:
if not getattr(user, "is_authenticated", False) or not getattr(user, "is_staff", False): if not getattr(user, "is_authenticated", False) or not getattr(
user, "is_staff", False
):
return False return False
return await sync_to_async(user.groups.filter(name=USER_SUPPORT_GROUP_NAME).exists)() return await sync_to_async(
user.groups.filter(name=USER_SUPPORT_GROUP_NAME).exists
)()
async def _get_or_create_ip_thread(ip: str) -> ChatThread: async def _get_or_create_ip_thread(ip: str) -> ChatThread:
def _inner() -> ChatThread: def _inner() -> ChatThread:
thread = ChatThread.objects.filter(attributes__ip=ip, status=ThreadStatus.OPEN).order_by("-modified").first() thread = (
ChatThread.objects.filter(attributes__ip=ip, status=ThreadStatus.OPEN)
.order_by("-modified")
.first()
)
if thread: if thread:
return thread return thread
return ChatThread.objects.create(email="", attributes={"ip": ip}) return ChatThread.objects.create(email="", attributes={"ip": ip})
@ -52,10 +60,18 @@ async def _get_or_create_ip_thread(ip: str) -> ChatThread:
async def _get_or_create_active_thread_for(user: User | None, ip: str) -> ChatThread: async def _get_or_create_active_thread_for(user: User | None, ip: str) -> ChatThread:
if user and getattr(user, "is_authenticated", False) and not getattr(user, "is_staff", False): if (
user
and getattr(user, "is_authenticated", False)
and not getattr(user, "is_staff", False)
):
def _inner_user() -> ChatThread: def _inner_user() -> ChatThread:
t = ChatThread.objects.filter(user=user, status=ThreadStatus.OPEN).order_by("-modified").first() t = (
ChatThread.objects.filter(user=user, status=ThreadStatus.OPEN)
.order_by("-modified")
.first()
)
if t: if t:
return t return t
return get_or_create_user_thread(user) return get_or_create_user_thread(user)
@ -95,11 +111,15 @@ class UserMessageConsumer(AsyncJsonWebsocketConsumer):
return return
user: User | None = self.scope.get("user") user: User | None = self.scope.get("user")
thread = await _get_or_create_active_thread_for(user if user and user.is_authenticated else None, ip) thread = await _get_or_create_active_thread_for(
user if user and user.is_authenticated else None, ip
)
msg = await sync_to_async(send_message)( msg = await sync_to_async(send_message)(
thread, thread,
sender_user=user if user and user.is_authenticated and not user.is_staff else None, sender_user=user
if user and user.is_authenticated and not user.is_staff
else None,
sender_type=SenderType.USER, sender_type=SenderType.USER,
text=text, text=text,
) )
@ -139,7 +159,9 @@ class StaffInboxConsumer(AsyncJsonWebsocketConsumer):
def _list(): def _list():
qs = ( qs = (
ChatThread.objects.filter(status=ThreadStatus.OPEN) ChatThread.objects.filter(status=ThreadStatus.OPEN)
.values("uuid", "user_id", "email", "assigned_to_id", "last_message_at") .values(
"uuid", "user_id", "email", "assigned_to_id", "last_message_at"
)
.order_by("-modified") .order_by("-modified")
) )
return list(qs) return list(qs)
@ -160,7 +182,13 @@ class StaffInboxConsumer(AsyncJsonWebsocketConsumer):
try: try:
t = await sync_to_async(_assign)() t = await sync_to_async(_assign)()
await self.send_json({"type": "assigned", "thread_id": str(t.uuid), "user": str(user.uuid)}) await self.send_json(
{
"type": "assigned",
"thread_id": str(t.uuid),
"user": str(user.uuid),
}
)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
await self.send_json({"error": "assign_failed", "detail": str(e)}) await self.send_json({"error": "assign_failed", "detail": str(e)})
return return
@ -168,15 +196,25 @@ class StaffInboxConsumer(AsyncJsonWebsocketConsumer):
if action == "reply": if action == "reply":
thread_id = content.get("thread_id") thread_id = content.get("thread_id")
text = content.get("text", "") text = content.get("text", "")
if not thread_id or not isinstance(text, str) or not (0 < len(text) <= MAX_MESSAGE_LENGTH): if (
not thread_id
or not isinstance(text, str)
or not (0 < len(text) <= MAX_MESSAGE_LENGTH)
):
await self.send_json({"error": "invalid_payload"}) await self.send_json({"error": "invalid_payload"})
return return
def _can_reply_and_send(): def _can_reply_and_send():
thread = ChatThread.objects.get(uuid=thread_id) thread = ChatThread.objects.get(uuid=thread_id)
if thread.assigned_to_id and thread.assigned_to_id != user.id and not user.is_superuser: if (
thread.assigned_to_id
and thread.assigned_to_id != user.id
and not user.is_superuser
):
raise PermissionError("not_assigned") raise PermissionError("not_assigned")
return send_message(thread, sender_user=user, sender_type=SenderType.STAFF, text=text) return send_message(
thread, sender_user=user, sender_type=SenderType.STAFF, text=text
)
try: try:
msg = await sync_to_async(_can_reply_and_send)() msg = await sync_to_async(_can_reply_and_send)()
@ -205,16 +243,36 @@ class StaffInboxConsumer(AsyncJsonWebsocketConsumer):
await self.send_json({"error": "unknown_action"}) await self.send_json({"error": "unknown_action"})
async def staff_thread_created(self, event): async def staff_thread_created(self, event):
await self.send_json({"type": "staff.thread.created", **{k: v for k, v in event.items() if k != "type"}}) await self.send_json(
{
"type": "staff.thread.created",
**{k: v for k, v in event.items() if k != "type"},
}
)
async def staff_thread_assigned(self, event): async def staff_thread_assigned(self, event):
await self.send_json({"type": "staff.thread.assigned", **{k: v for k, v in event.items() if k != "type"}}) await self.send_json(
{
"type": "staff.thread.assigned",
**{k: v for k, v in event.items() if k != "type"},
}
)
async def staff_thread_reassigned(self, event): async def staff_thread_reassigned(self, event):
await self.send_json({"type": "staff.thread.reassigned", **{k: v for k, v in event.items() if k != "type"}}) await self.send_json(
{
"type": "staff.thread.reassigned",
**{k: v for k, v in event.items() if k != "type"},
}
)
async def staff_thread_closed(self, event): async def staff_thread_closed(self, event):
await self.send_json({"type": "staff.thread.closed", **{k: v for k, v in event.items() if k != "type"}}) await self.send_json(
{
"type": "staff.thread.closed",
**{k: v for k, v in event.items() if k != "type"},
}
)
class ThreadConsumer(AsyncJsonWebsocketConsumer): class ThreadConsumer(AsyncJsonWebsocketConsumer):
@ -226,12 +284,16 @@ class ThreadConsumer(AsyncJsonWebsocketConsumer):
await self.close(code=4403) await self.close(code=4403)
return return
self.thread_id = self.scope["url_route"]["kwargs"].get("thread_id") self.thread_id = self.scope["url_route"]["kwargs"].get("thread_id")
await self.channel_layer.group_add(f"{THREAD_GROUP_PREFIX}{self.thread_id}", self.channel_name) await self.channel_layer.group_add(
f"{THREAD_GROUP_PREFIX}{self.thread_id}", self.channel_name
)
await self.accept() await self.accept()
async def disconnect(self, code: int) -> None: async def disconnect(self, code: int) -> None:
if self.thread_id: if self.thread_id:
await self.channel_layer.group_discard(f"{THREAD_GROUP_PREFIX}{self.thread_id}", self.channel_name) await self.channel_layer.group_discard(
f"{THREAD_GROUP_PREFIX}{self.thread_id}", self.channel_name
)
@extend_ws_schema(**THREAD_CONSUMER_SCHEMA) @extend_ws_schema(**THREAD_CONSUMER_SCHEMA)
async def receive_json(self, content: dict[str, Any], **kwargs) -> None: async def receive_json(self, content: dict[str, Any], **kwargs) -> None:
@ -239,7 +301,9 @@ class ThreadConsumer(AsyncJsonWebsocketConsumer):
user: User = self.scope.get("user") user: User = self.scope.get("user")
if action == "ping": if action == "ping":
await self.send_json({"type": "pong", "thread": getattr(self, "thread_id", None)}) await self.send_json(
{"type": "pong", "thread": getattr(self, "thread_id", None)}
)
return return
if action == "reply": if action == "reply":
@ -250,9 +314,15 @@ class ThreadConsumer(AsyncJsonWebsocketConsumer):
def _reply(): def _reply():
thread = ChatThread.objects.get(uuid=self.thread_id) thread = ChatThread.objects.get(uuid=self.thread_id)
if thread.assigned_to_id and thread.assigned_to_id != user.id and not user.is_superuser: if (
thread.assigned_to_id
and thread.assigned_to_id != user.id
and not user.is_superuser
):
raise PermissionError("not_assigned") raise PermissionError("not_assigned")
return send_message(thread, sender_user=user, sender_type=SenderType.STAFF, text=text) return send_message(
thread, sender_user=user, sender_type=SenderType.STAFF, text=text
)
try: try:
msg = await sync_to_async(_reply)() msg = await sync_to_async(_reply)()
@ -277,10 +347,17 @@ class ThreadConsumer(AsyncJsonWebsocketConsumer):
await self.send_json({"thread": getattr(self, "thread_id", None), "ok": True}) await self.send_json({"thread": getattr(self, "thread_id", None), "ok": True})
async def thread_message(self, event): async def thread_message(self, event):
await self.send_json({"type": "thread.message", **{k: v for k, v in event.items() if k != "type"}}) await self.send_json(
{
"type": "thread.message",
**{k: v for k, v in event.items() if k != "type"},
}
)
async def thread_closed(self, event): async def thread_closed(self, event):
await self.send_json({"type": "thread.closed", **{k: v for k, v in event.items() if k != "type"}}) await self.send_json(
{"type": "thread.closed", **{k: v for k, v in event.items() if k != "type"}}
)
# TODO: Add functionality so non-staff users may audio call staff-user. The call must fall into the queue where # TODO: Add functionality so non-staff users may audio call staff-user. The call must fall into the queue where

View file

@ -93,7 +93,9 @@ def build_router() -> Router | None:
@router.message() @router.message()
async def any_message(message: types.Message): # type: ignore[valid-type] async def any_message(message: types.Message): # type: ignore[valid-type]
from engine.vibes_auth.messaging.services import send_message as svc_send_message from engine.vibes_auth.messaging.services import (
send_message as svc_send_message,
)
if not message.from_user or not message.text: if not message.from_user or not message.text:
return return
@ -101,7 +103,9 @@ def build_router() -> Router | None:
def _resolve_staff_and_command(): def _resolve_staff_and_command():
try: try:
staff_user = User.objects.get(attributes__telegram_id=tid, is_staff=True, is_active=True) staff_user = User.objects.get(
attributes__telegram_id=tid, is_staff=True, is_active=True
)
except User.DoesNotExist: # type: ignore[attr-defined] except User.DoesNotExist: # type: ignore[attr-defined]
return None, None, None return None, None, None
# group check # group check
@ -130,7 +134,9 @@ def build_router() -> Router | None:
t, body = payload t, body = payload
def _send(): def _send():
return svc_send_message(t, sender_user=staff, sender_type=SenderType.STAFF, text=body) return svc_send_message(
t, sender_user=staff, sender_type=SenderType.STAFF, text=body
)
await asyncio.to_thread(_send) await asyncio.to_thread(_send)
await message.answer("Sent.") await message.answer("Sent.")
@ -178,7 +184,9 @@ async def forward_thread_message_to_assigned_staff(thread_uuid: str, text: str)
try: try:
await bot.send_message(chat_id=chat_id, text=text) await bot.send_message(chat_id=chat_id, text=text)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("Failed to forward Telegram message for thread %s: %s", _tid, exc) logger.warning(
"Failed to forward Telegram message for thread %s: %s", _tid, exc
)
def install_aiohttp_webhook(app) -> None: def install_aiohttp_webhook(app) -> None:
@ -192,5 +200,7 @@ def install_aiohttp_webhook(app) -> None:
bot = _get_bot() bot = _get_bot()
if not bot: if not bot:
return return
SimpleRequestHandler(dispatcher=dp, bot=bot).register(app, path="/telegram/webhook/" + settings.TELEGRAM_TOKEN) # type: ignore[arg-type] SimpleRequestHandler(dispatcher=dp, bot=bot).register(
app, path="/telegram/webhook/" + settings.TELEGRAM_TOKEN
) # type: ignore[arg-type]
logger.info("Telegram webhook handler installed on aiohttp app.") logger.info("Telegram webhook handler installed on aiohttp app.")

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