diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eed19bcf..3240e673 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ repos: hooks: - id: ruff name: Ruff (lint & fix) - args: ["--fix", "--exit-non-zero-on-fix", "--force-exclude"] + args: ["--fix", "--exit-non-zero-on-fix"] files: "\\.(py|pyi)$" exclude: "^storefront/" - id: ruff-format diff --git a/engine/blog/admin.py b/engine/blog/admin.py index a9d790ee..54e427b3 100644 --- a/engine/blog/admin.py +++ b/engine/blog/admin.py @@ -9,7 +9,9 @@ from engine.core.admin import ActivationActionsMixin, FieldsetsMixin @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_filter = ("author", "tags", "created", "modified") search_fields = ("title", "content", "slug") diff --git a/engine/blog/docs/drf/viewsets.py b/engine/blog/docs/drf/viewsets.py index 27fee717..e4e4a7a6 100644 --- a/engine/blog/docs/drf/viewsets.py +++ b/engine/blog/docs/drf/viewsets.py @@ -2,8 +2,8 @@ from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema from rest_framework import status -from engine.core.docs.drf import BASE_ERRORS from engine.blog.serializers import PostSerializer +from engine.core.docs.drf import BASE_ERRORS POST_SCHEMA = { "list": extend_schema( diff --git a/engine/blog/elasticsearch/documents.py b/engine/blog/elasticsearch/documents.py index 0a7b0005..779c644a 100644 --- a/engine/blog/elasticsearch/documents.py +++ b/engine/blog/elasticsearch/documents.py @@ -2,7 +2,11 @@ from django_elasticsearch_dsl import fields from django_elasticsearch_dsl.registries import registry 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 @@ -12,7 +16,9 @@ class PostDocument(ActiveOnlyMixin, BaseDocument): # type: ignore [misc] analyzer="standard", fields={ "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"), }, ) diff --git a/engine/blog/migrations/0001_initial.py b/engine/blog/migrations/0001_initial.py index 5ba1c491..311d6bd6 100644 --- a/engine/blog/migrations/0001_initial.py +++ b/engine/blog/migrations/0001_initial.py @@ -48,13 +48,17 @@ class Migration(migrations.Migration): ( "modified", 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", 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", 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()), - ("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/")), ("slug", models.SlugField(allow_unicode=True)), ( "author", 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")), diff --git a/engine/blog/migrations/0002_alter_post_slug_alter_post_title.py b/engine/blog/migrations/0002_alter_post_slug_alter_post_title.py index 16f7094f..df641a5c 100644 --- a/engine/blog/migrations/0002_alter_post_slug_alter_post_title.py +++ b/engine/blog/migrations/0002_alter_post_slug_alter_post_title.py @@ -12,12 +12,21 @@ class Migration(migrations.Migration): model_name="post", name="slug", 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( model_name="post", 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", + ), ), ] diff --git a/engine/blog/migrations/0003_alter_post_tags.py b/engine/blog/migrations/0003_alter_post_tags.py index 9c139274..aeb6a96a 100644 --- a/engine/blog/migrations/0003_alter_post_tags.py +++ b/engine/blog/migrations/0003_alter_post_tags.py @@ -10,6 +10,8 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="post", name="tags", - field=models.ManyToManyField(blank=True, related_name="posts", to="blog.posttag"), + field=models.ManyToManyField( + blank=True, related_name="posts", to="blog.posttag" + ), ), ] diff --git a/engine/blog/migrations/0004_post_content_ar_ar_post_content_cs_cz_and_more.py b/engine/blog/migrations/0004_post_content_ar_ar_post_content_cs_cz_and_more.py index 70a7b48b..3ba11dbb 100644 --- a/engine/blog/migrations/0004_post_content_ar_ar_post_content_cs_cz_and_more.py +++ b/engine/blog/migrations/0004_post_content_ar_ar_post_content_cs_cz_and_more.py @@ -11,92 +11,128 @@ class Migration(migrations.Migration): migrations.AddField( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", diff --git a/engine/blog/migrations/0005_post_content_fa_ir_post_content_he_il_and_more.py b/engine/blog/migrations/0005_post_content_fa_ir_post_content_he_il_and_more.py index 6dcbc623..715b11e1 100644 --- a/engine/blog/migrations/0005_post_content_fa_ir_post_content_he_il_and_more.py +++ b/engine/blog/migrations/0005_post_content_fa_ir_post_content_he_il_and_more.py @@ -11,52 +11,72 @@ class Migration(migrations.Migration): migrations.AddField( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", 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( model_name="post", diff --git a/engine/blog/models.py b/engine/blog/models.py index ec180145..7c9658de 100644 --- a/engine/blog/models.py +++ b/engine/blog/models.py @@ -1,5 +1,12 @@ 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_extensions.db.fields import AutoSlugField from markdown.extensions.toc import TocExtension @@ -19,9 +26,20 @@ class Post(NiceModel): # type: ignore [django-manager-missing] 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( - 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", @@ -61,13 +79,17 @@ class Post(NiceModel): # type: ignore [django-manager-missing] 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") meta_description = CharField(max_length=150, blank=True, null=True) is_static_page = BooleanField( default=False, 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): @@ -79,9 +101,15 @@ class Post(NiceModel): # type: ignore [django-manager-missing] def save(self, **kwargs): 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]): - 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) diff --git a/engine/blog/viewsets.py b/engine/blog/viewsets.py index 76a3a76d..0ca86f77 100644 --- a/engine/blog/viewsets.py +++ b/engine/blog/viewsets.py @@ -3,10 +3,10 @@ from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import extend_schema_view from rest_framework.viewsets import ReadOnlyModelViewSet +from engine.blog.docs.drf.viewsets import POST_SCHEMA from engine.blog.filters import PostFilter from engine.blog.models import Post from engine.blog.serializers import PostSerializer -from engine.blog.docs.drf.viewsets import POST_SCHEMA from engine.core.permissions import EvibesPermission diff --git a/engine/blog/widgets.py b/engine/blog/widgets.py index ae5d640b..5c9e8604 100644 --- a/engine/blog/widgets.py +++ b/engine/blog/widgets.py @@ -7,10 +7,20 @@ from django.utils.safestring import mark_safe class MarkdownEditorWidget(forms.Textarea): 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",) - 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: attrs = {} attrs["class"] = "markdown-editor" diff --git a/engine/core/abstract.py b/engine/core/abstract.py index a3c11847..9f7cf6c4 100644 --- a/engine/core/abstract.py +++ b/engine/core/abstract.py @@ -18,10 +18,16 @@ class NiceModel(Model): is_active = BooleanField( default=True, 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] - modified = ModificationDateTimeField(_("modified"), help_text=_("when the object was last modified")) # type: ignore [no-untyped-call] + created = CreationDateTimeField( + _("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] self, @@ -34,7 +40,10 @@ class NiceModel(Model): ) -> None: self.update_modified = update_modified 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: diff --git a/engine/core/admin.py b/engine/core/admin.py index 92e9c944..a4a1a2e9 100644 --- a/engine/core/admin.py +++ b/engine/core/admin.py @@ -33,7 +33,13 @@ from unfold.contrib.import_export.forms import ExportForm, ImportForm from unfold.decorators import action 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 ( Address, Attribute, @@ -64,7 +70,9 @@ class FieldsetsMixin: additional_fields: list[str] | None = [] 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: pass @@ -82,15 +90,29 @@ class FieldsetsMixin: for orig in transoptions.local_fields: translation_fields += get_translation_fields(orig) 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 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: - fieldsets.append((_("relations"), {"classes": ["tab"], "fields": self.relation_fields})) + fieldsets.append( + (_("relations"), {"classes": ["tab"], "fields": self.relation_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 meta_fields = [] @@ -108,7 +130,9 @@ class FieldsetsMixin: meta_fields.append("human_readable_id") if meta_fields: - fieldsets.append((_("metadata"), {"classes": ["tab"], "fields": meta_fields})) + fieldsets.append( + (_("metadata"), {"classes": ["tab"], "fields": meta_fields}) + ) ts = [] for name in ("created", "modified"): @@ -130,23 +154,35 @@ class ActivationActionsMixin: "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: try: queryset.update(is_active=True) 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: 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"]) - def deactivate_selected(self, request: HttpRequest, queryset: QuerySet[Any]) -> None: + @action( + description=_("deactivate selected %(verbose_name_plural)s").lower(), + permissions=["change"], + ) + def deactivate_selected( + self, request: HttpRequest, queryset: QuerySet[Any] + ) -> None: try: queryset.update(is_active=False) 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: @@ -198,7 +234,12 @@ class OrderProductInline(TabularInline): # type: ignore [type-arg] tab = True 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] @@ -212,7 +253,9 @@ class CategoryChildrenInline(TabularInline): # type: ignore [type-arg] @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 model = AttributeGroup # type: ignore [misc] list_display = ( @@ -236,7 +279,9 @@ class AttributeGroupAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActions @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 model = Attribute # type: ignore [misc] list_display = ( @@ -311,7 +356,13 @@ class AttributeValueAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin): @register(Category) -class CategoryAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, DraggableMPTTAdmin, ModelAdmin): +class CategoryAdmin( + DjangoQLSearchMixin, + FieldsetsMixin, + ActivationActionsMixin, + DraggableMPTTAdmin, + ModelAdmin, +): # noinspection PyClassVar model = Category list_display = ( @@ -360,7 +411,9 @@ class CategoryAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, @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 model = Brand # type: ignore [misc] list_display = ( @@ -392,7 +445,13 @@ class BrandAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, Mo @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 model = Product # type: ignore [misc] list_display = ( @@ -471,7 +530,9 @@ class ProductAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, @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 model = ProductTag # type: ignore [misc] list_display = ("tag_name",) @@ -489,7 +550,9 @@ class ProductTagAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixi @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 model = CategoryTag # type: ignore [misc] list_display = ( @@ -515,7 +578,9 @@ class CategoryTagAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMix @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 model = Vendor # type: ignore [misc] list_display = ( @@ -555,7 +620,9 @@ class VendorAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, M @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 model = Feedback # type: ignore [misc] list_display = ( @@ -588,7 +655,9 @@ class FeedbackAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, @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 model = Order # type: ignore [misc] list_display = ( @@ -639,7 +708,9 @@ class OrderAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, Mo @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 model = OrderProduct # type: ignore [misc] list_display = ( @@ -677,7 +748,9 @@ class OrderProductAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMi @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 model = PromoCode # type: ignore [misc] list_display = ( @@ -721,7 +794,9 @@ class PromoCodeAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin @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 model = Promotion # type: ignore [misc] list_display = ( @@ -748,7 +823,9 @@ class PromotionAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin @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 model = Stock # type: ignore [misc] form = StockForm @@ -796,7 +873,9 @@ class StockAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, Mo @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 model = Wishlist # type: ignore [misc] list_display = ( @@ -822,7 +901,9 @@ class WishlistAdmin(DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, @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 model = ProductImage # type: ignore [misc] list_display = ( @@ -908,7 +989,9 @@ class AddressAdmin(DjangoQLSearchMixin, FieldsetsMixin, GISModelAdmin): # type: @register(CustomerRelationshipManagementProvider) -class CustomerRelationshipManagementProviderAdmin(DjangoQLSearchMixin, FieldsetsMixin, ModelAdmin): # type: ignore [misc, type-arg] +class CustomerRelationshipManagementProviderAdmin( + DjangoQLSearchMixin, FieldsetsMixin, ModelAdmin +): # type: ignore [misc, type-arg] # noinspection PyClassVar model = CustomerRelationshipManagementProvider # type: ignore [misc] list_display = ( diff --git a/engine/core/b2b_urls.py b/engine/core/b2b_urls.py index 658af948..5d037105 100644 --- a/engine/core/b2b_urls.py +++ b/engine/core/b2b_urls.py @@ -9,5 +9,9 @@ app_name = "core" urlpatterns = [ 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", + ), ] diff --git a/engine/core/crm/amo/gateway.py b/engine/core/crm/amo/gateway.py index 02ad2fe9..8be15ade 100644 --- a/engine/core/crm/amo/gateway.py +++ b/engine/core/crm/amo/gateway.py @@ -6,7 +6,11 @@ from django.core.cache import cache from django.db import transaction 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 logger = logging.getLogger(__name__) @@ -17,7 +21,9 @@ class AmoCRM: def __init__(self): try: - self.instance = CustomerRelationshipManagementProvider.objects.get(name="AmoCRM") + self.instance = CustomerRelationshipManagementProvider.objects.get( + name="AmoCRM" + ) except CustomerRelationshipManagementProvider.DoesNotExist as dne: logger.warning("AMO CRM provider not found") raise CRMException("AMO CRM provider not found") from dne @@ -70,7 +76,10 @@ class AmoCRM: return self.access_token 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: name = f"Заказ #{order.human_readable_id}" @@ -104,7 +113,8 @@ class AmoCRM: ) try: 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() body = r.json() @@ -129,28 +139,43 @@ class AmoCRM: if customer_name: r = requests.get( 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, ) if r.status_code == 200: 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} 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: create_contact_payload["first_name"] = order.user.first_name or "" create_contact_payload["last_name"] = order.user.last_name or "" r = requests.post( - f"{self.base}/api/v4/contacts", json={"name": customer_name}, headers=self._headers(), timeout=15 + f"{self.base}/api/v4/contacts", + json={"name": customer_name}, + headers=self._headers(), + timeout=15, ) if r.status_code == 200: body = r.json() - return body.get("_embedded", {}).get("contacts", [{}])[0].get("id", None) + return ( + body.get("_embedded", {}) + .get("contacts", [{}])[0] + .get("id", None) + ) return None @@ -173,17 +198,27 @@ class AmoCRM: lead_id = link.crm_lead_id payload = self._build_lead_payload(order) r = requests.patch( - f"{self.base}/api/v4/leads/{lead_id}", json=payload, headers=self._headers(), timeout=15 + f"{self.base}/api/v4/leads/{lead_id}", + json=payload, + headers=self._headers(), + timeout=15, ) if r.status_code not in (200, 204): r.raise_for_status() return lead_id payload = self._build_lead_payload(order) - r = requests.post(f"{self.base}/api/v4/leads", json=[payload], headers=self._headers(), timeout=15) + r = requests.post( + f"{self.base}/api/v4/leads", + json=[payload], + headers=self._headers(), + timeout=15, + ) r.raise_for_status() body = r.json() lead_id = str(body["_embedded"]["leads"][0]["id"]) - OrderCrmLink.objects.create(order=order, crm_lead_id=lead_id, crm=self.instance) + OrderCrmLink.objects.create( + order=order, crm_lead_id=lead_id, crm=self.instance + ) return lead_id def update_order_status(self, crm_lead_id: str, new_status: str) -> None: diff --git a/engine/core/docs/drf/views.py b/engine/core/docs/drf/views.py index e1b192c7..361cc4ec 100644 --- a/engine/core/docs/drf/views.py +++ b/engine/core/docs/drf/views.py @@ -14,7 +14,6 @@ from engine.core.serializers import ( ) from engine.payments.serializers import TransactionProcessSerializer - CUSTOM_OPENAPI_SCHEMA = { "get": extend_schema( tags=[ @@ -48,7 +47,9 @@ CACHE_SCHEMA = { ), request=CacheOperatorSerializer, 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, }, ), @@ -72,7 +73,11 @@ PARAMETERS_SCHEMA = { "misc", ], 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", ], 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={ status.HTTP_200_OK: inline_serializer("data", fields={"data": JSONField()}), status.HTTP_400_BAD_REQUEST: error, @@ -121,7 +128,11 @@ SEARCH_SCHEMA = { responses={ status.HTTP_200_OK: inline_serializer( 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( name="GlobalSearchErrorResponse", fields={"error": CharField()} @@ -143,7 +154,9 @@ BUY_AS_BUSINESS_SCHEMA = { status.HTTP_400_BAD_REQUEST: error, }, 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`." + ) ), ) } diff --git a/engine/core/docs/drf/viewsets.py b/engine/core/docs/drf/viewsets.py index 0edfda81..42e254a0 100644 --- a/engine/core/docs/drf/viewsets.py +++ b/engine/core/docs/drf/viewsets.py @@ -50,7 +50,11 @@ from engine.core.serializers import ( WishlistSimpleSerializer, ) 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 ATTRIBUTE_GROUP_SCHEMA = { @@ -59,7 +63,10 @@ ATTRIBUTE_GROUP_SCHEMA = { "attributeGroups", ], 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( tags=[ @@ -73,7 +80,10 @@ ATTRIBUTE_GROUP_SCHEMA = { "attributeGroups", ], summary=_("create an attribute group"), - responses={status.HTTP_201_CREATED: AttributeGroupDetailSerializer(), **BASE_ERRORS}, + responses={ + status.HTTP_201_CREATED: AttributeGroupDetailSerializer(), + **BASE_ERRORS, + }, ), "destroy": extend_schema( tags=[ @@ -93,7 +103,9 @@ ATTRIBUTE_GROUP_SCHEMA = { tags=[ "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}, ), } @@ -104,7 +116,10 @@ ATTRIBUTE_SCHEMA = { "attributes", ], 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( tags=[ @@ -149,7 +164,10 @@ ATTRIBUTE_VALUE_SCHEMA = { "attributeValues", ], 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( tags=[ @@ -163,7 +181,10 @@ ATTRIBUTE_VALUE_SCHEMA = { "attributeValues", ], summary=_("create an attribute value"), - responses={status.HTTP_201_CREATED: AttributeValueDetailSerializer(), **BASE_ERRORS}, + responses={ + status.HTTP_201_CREATED: AttributeValueDetailSerializer(), + **BASE_ERRORS, + }, ), "destroy": extend_schema( tags=[ @@ -183,7 +204,9 @@ ATTRIBUTE_VALUE_SCHEMA = { tags=[ "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}, ), } @@ -195,7 +218,10 @@ CATEGORY_SCHEMA = { ], summary=_("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( tags=[ @@ -242,7 +268,9 @@ CATEGORY_SCHEMA = { "categories", ], 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}, ), "seo_meta": extend_schema( @@ -315,7 +343,9 @@ ORDER_SCHEMA = { OpenApiParameter( name="status", type=OpenApiTypes.STR, - description=_("Filter by order status (case-insensitive substring match)"), + description=_( + "Filter by order status (case-insensitive substring match)" + ), ), OpenApiParameter( name="order_by", @@ -418,7 +448,9 @@ ORDER_SCHEMA = { "orders", ], 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(), responses={status.HTTP_200_OK: OrderDetailSerializer(), **BASE_ERRORS}, ), @@ -427,7 +459,9 @@ ORDER_SCHEMA = { "orders", ], 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(), responses={status.HTTP_200_OK: OrderDetailSerializer(), **BASE_ERRORS}, ), @@ -436,7 +470,9 @@ ORDER_SCHEMA = { "orders", ], 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(), responses={status.HTTP_200_OK: OrderDetailSerializer(), **BASE_ERRORS}, ), @@ -445,7 +481,9 @@ ORDER_SCHEMA = { "orders", ], 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(), responses={status.HTTP_200_OK: OrderDetailSerializer(), **BASE_ERRORS}, ), @@ -458,7 +496,10 @@ WISHLIST_SCHEMA = { ], summary=_("list all wishlists (simple view)"), 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( tags=[ @@ -512,7 +553,9 @@ WISHLIST_SCHEMA = { "wishlists", ], 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(), responses={status.HTTP_200_OK: WishlistDetailSerializer(), **BASE_ERRORS}, ), @@ -521,7 +564,9 @@ WISHLIST_SCHEMA = { "wishlists", ], 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(), responses={status.HTTP_200_OK: WishlistDetailSerializer(), **BASE_ERRORS}, ), @@ -530,7 +575,9 @@ WISHLIST_SCHEMA = { "wishlists", ], 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(), responses={status.HTTP_200_OK: WishlistDetailSerializer(), **BASE_ERRORS}, ), @@ -539,7 +586,9 @@ WISHLIST_SCHEMA = { "wishlists", ], 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(), responses={status.HTTP_200_OK: WishlistDetailSerializer(), **BASE_ERRORS}, ), @@ -644,8 +693,12 @@ PRODUCT_SCHEMA = { tags=[ "products", ], - summary=_("update some fields of an existing product, preserving non-editable fields"), - description=_("update some fields of an existing product, preserving non-editable fields"), + summary=_( + "update some fields of an existing product, preserving non-editable fields" + ), + description=_( + "update some fields of an existing product, preserving non-editable fields" + ), parameters=[ OpenApiParameter( name="lookup_value", @@ -791,7 +844,9 @@ ADDRESS_SCHEMA = { OpenApiParameter( name="q", 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, ), OpenApiParameter( @@ -814,7 +869,10 @@ FEEDBACK_SCHEMA = { "feedbacks", ], 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( tags=[ @@ -1004,7 +1062,10 @@ VENDOR_SCHEMA = { "vendors", ], 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( tags=[ @@ -1049,7 +1110,10 @@ PRODUCT_IMAGE_SCHEMA = { "productImages", ], 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( tags=[ @@ -1063,7 +1127,10 @@ PRODUCT_IMAGE_SCHEMA = { "productImages", ], summary=_("create a product image"), - responses={status.HTTP_201_CREATED: ProductImageDetailSerializer(), **BASE_ERRORS}, + responses={ + status.HTTP_201_CREATED: ProductImageDetailSerializer(), + **BASE_ERRORS, + }, ), "destroy": extend_schema( tags=[ @@ -1083,7 +1150,9 @@ PRODUCT_IMAGE_SCHEMA = { tags=[ "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}, ), } @@ -1094,7 +1163,10 @@ PROMOCODE_SCHEMA = { "promocodes", ], 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( tags=[ @@ -1139,7 +1211,10 @@ PROMOTION_SCHEMA = { "promotions", ], 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( tags=[ @@ -1218,7 +1293,9 @@ STOCK_SCHEMA = { tags=[ "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}, ), } @@ -1229,7 +1306,10 @@ PRODUCT_TAG_SCHEMA = { "productTags", ], 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( tags=[ @@ -1243,7 +1323,10 @@ PRODUCT_TAG_SCHEMA = { "productTags", ], summary=_("create a product tag"), - responses={status.HTTP_201_CREATED: ProductTagDetailSerializer(), **BASE_ERRORS}, + responses={ + status.HTTP_201_CREATED: ProductTagDetailSerializer(), + **BASE_ERRORS, + }, ), "destroy": extend_schema( tags=[ @@ -1263,7 +1346,9 @@ PRODUCT_TAG_SCHEMA = { tags=[ "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}, ), } diff --git a/engine/core/elasticsearch/__init__.py b/engine/core/elasticsearch/__init__.py index 2711fb6b..7756e3ae 100644 --- a/engine/core/elasticsearch/__init__.py +++ b/engine/core/elasticsearch/__init__.py @@ -1,7 +1,6 @@ import re -from typing import Any +from typing import Any, Callable -from typing import Callable from django.conf import settings from django.db.models import QuerySet from django.http import Http404 @@ -86,7 +85,13 @@ functions = [ "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, }, { @@ -176,9 +181,15 @@ def process_query( if is_code_like: 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("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) resp_products = search_products.execute() - results: dict[str, list[dict[str, Any]]] = {"products": [], "categories": [], "brands": [], "posts": []} - uuids_by_index: dict[str, list[str]] = {"products": [], "categories": [], "brands": []} + results: dict[str, list[dict[str, Any]]] = { + "products": [], + "categories": [], + "brands": [], + "posts": [], + } + uuids_by_index: dict[str, list[str]] = { + "products": [], + "categories": [], + "brands": [], + } hit_cache: list[Any] = [] seen_keys: set[tuple[str, str]] = set() 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: for hh in hits: @@ -267,7 +289,12 @@ def process_query( ] for qx in product_exact_sequence: 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: resp_exact = None if resp_exact is not None and getattr(resp_exact, "hits", None): @@ -314,13 +341,23 @@ def process_query( .prefetch_related("images") } if uuids_by_index.get("brands"): - brands_by_uuid = {str(b.uuid): b for b in Brand.objects.filter(uuid__in=uuids_by_index["brands"])} + brands_by_uuid = { + str(b.uuid): b + for b in Brand.objects.filter(uuid__in=uuids_by_index["brands"]) + } if uuids_by_index.get("categories"): - cats_by_uuid = {str(c.uuid): c for c in Category.objects.filter(uuid__in=uuids_by_index["categories"])} + cats_by_uuid = { + str(c.uuid): c + for c in Category.objects.filter( + uuid__in=uuids_by_index["categories"] + ) + } for hit in hit_cache: obj_uuid = getattr(hit, "uuid", None) or hit.meta.id - obj_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 ( slugify(obj_name) if hit.meta.index in {"brands", "categories"} else "" ) @@ -353,8 +390,12 @@ def process_query( if idx == "products": hit_result["rating_debug"] = getattr(hit, "rating", 0) hit_result["total_orders_debug"] = getattr(hit, "total_orders", 0) - hit_result["brand_priority_debug"] = getattr(hit, "brand_priority", 0) - hit_result["category_priority_debug"] = getattr(hit, "category_priority", 0) + hit_result["brand_priority_debug"] = getattr( + hit, "brand_priority", 0 + ) + hit_result["category_priority_debug"] = getattr( + hit, "category_priority", 0 + ) if idx in ("brands", "categories"): hit_result["priority_debug"] = getattr(hit, "priority", 0) @@ -402,14 +443,22 @@ class ActiveOnlyMixin: COMMON_ANALYSIS = { "char_filter": { "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": { "edge_ngram_filter": {"type": "edge_ngram", "min_gram": 1, "max_gram": 20}, "ngram_filter": {"type": "ngram", "min_gram": 2, "max_gram": 20}, "cjk_bigram": {"type": "cjk_bigram"}, "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"}, "indic_norm": {"type": "indic_normalization"}, "icu_any_latin": {"type": "icu_transform", "id": "Any-Latin"}, @@ -520,9 +569,13 @@ def add_multilang_fields(cls: Any) -> None: copy_to="name", fields={ "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"), - "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", fields={ "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"), - "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: fields_all = [f for f in fields_all if ".phonetic" not in f] 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 ] @@ -601,7 +660,11 @@ def process_system_query( results: dict[str, list[dict[str, Any]]] = {idx: [] 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() for h in resp.hits: name = getattr(h, "name", None) or getattr(h, "title", None) or "N/A" diff --git a/engine/core/elasticsearch/documents.py b/engine/core/elasticsearch/documents.py index 7b678699..f741f8c7 100644 --- a/engine/core/elasticsearch/documents.py +++ b/engine/core/elasticsearch/documents.py @@ -5,7 +5,11 @@ from django_elasticsearch_dsl import Document, fields from django_elasticsearch_dsl.registries import registry 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 @@ -15,10 +19,16 @@ class BaseDocument(Document): # type: ignore [misc] analyzer="standard", fields={ "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"), - "auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"), - "translit": fields.TextField(analyzer="translit_index", search_analyzer="translit_query"), + "auto": fields.TextField( + 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"), }, ) @@ -27,10 +37,16 @@ class BaseDocument(Document): # type: ignore [misc] analyzer="standard", fields={ "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"), - "auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"), - "translit": fields.TextField(analyzer="translit_index", search_analyzer="translit_query"), + "auto": fields.TextField( + analyzer="autocomplete", search_analyzer="autocomplete_search" + ), + "translit": fields.TextField( + analyzer="translit_index", search_analyzer="translit_query" + ), }, ) slug = fields.KeywordField(attr="slug") @@ -70,10 +86,16 @@ class ProductDocument(ActiveOnlyMixin, BaseDocument): analyzer="standard", fields={ "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"), - "auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"), - "translit": fields.TextField(analyzer="translit_index", search_analyzer="translit_query"), + "auto": fields.TextField( + analyzer="autocomplete", search_analyzer="autocomplete_search" + ), + "translit": fields.TextField( + analyzer="translit_index", search_analyzer="translit_query" + ), }, ) category_name = fields.TextField( @@ -81,10 +103,16 @@ class ProductDocument(ActiveOnlyMixin, BaseDocument): analyzer="standard", fields={ "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"), - "auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"), - "translit": fields.TextField(analyzer="translit_index", search_analyzer="translit_query"), + "auto": fields.TextField( + 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", fields={ "raw": fields.KeywordField(normalizer="lc_norm"), - "ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"), - "auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"), + "ngram": fields.TextField( + 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", fields={ "raw": fields.KeywordField(normalizer="lc_norm"), - "ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"), - "auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"), + "ngram": fields.TextField( + analyzer="name_ngram", search_analyzer="icu_query" + ), + "auto": fields.TextField( + analyzer="autocomplete", search_analyzer="autocomplete_search" + ), }, ) diff --git a/engine/core/filters.py b/engine/core/filters.py index dbde2075..cd27fcf0 100644 --- a/engine/core/filters.py +++ b/engine/core/filters.py @@ -37,7 +37,16 @@ from graphene import Context from rest_framework.request import Request 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__) @@ -69,19 +78,31 @@ class ProductFilter(FilterSet): # type: ignore [misc] search = CharFilter(field_name="name", method="search_products", label=_("Search")) uuid = UUIDFilter(field_name="uuid", lookup_expr="exact", label=_("UUID")) 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)") - 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")) - min_price = NumberFilter(field_name="stocks__price", lookup_expr="gte", label=_("Min Price")) - max_price = NumberFilter(field_name="stocks__price", lookup_expr="lte", label=_("Max Price")) + min_price = NumberFilter( + 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")) brand = CharFilter(field_name="brand__name", lookup_expr="iexact", label=_("Brand")) 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")) is_digital = BooleanFilter(field_name="is_digital", label=_("Is Digital")) - include_subcategories = BooleanFilter(method="filter_include_flag", label=_("Include sub-categories")) + include_subcategories = BooleanFilter( + method="filter_include_flag", label=_("Include sub-categories") + ) include_personal_ordered = BooleanFilter( method="filter_include_personal_ordered", label=_("Include personal ordered"), @@ -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: return queryset @@ -173,23 +196,35 @@ class ProductFilter(FilterSet): # type: ignore [misc] # Preserve ES order using a CASE expression when_statements = [When(uuid=u, then=pos) for pos, u in enumerate(uuids)] 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 self._es_rank_applied = True 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"): - 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 - 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): - 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 - 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: return queryset @@ -251,7 +286,9 @@ class ProductFilter(FilterSet): # type: ignore [misc] 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: 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] = [] for part in requested: @@ -327,7 +368,11 @@ class ProductFilter(FilterSet): # type: ignore [misc] mapped_requested.append("?") 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 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)"), ) - min_buy_time = DateTimeFilter(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)")) + min_buy_time = DateTimeFilter( + 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") - user_email = CharFilter(field_name="user__email", lookup_expr="iexact", label=_("User email")) - user = UUIDFilter(field_name="user__uuid", lookup_expr="exact", label=_("User UUID")) + user_email = CharFilter( + 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")) human_readable_id = CharFilter( field_name="human_readable_id", @@ -404,8 +457,12 @@ class OrderFilter(FilterSet): # type: ignore [misc] class WishlistFilter(FilterSet): # type: ignore [misc] uuid = UUIDFilter(field_name="uuid", lookup_expr="exact") - user_email = CharFilter(field_name="user__email", lookup_expr="iexact", label=_("User email")) - user = UUIDFilter(field_name="user__uuid", lookup_expr="exact", label=_("User UUID")) + user_email = CharFilter( + 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( fields=( @@ -423,7 +480,9 @@ class WishlistFilter(FilterSet): # type: ignore [misc] # noinspection PyUnusedLocal 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") name = CharFilter(lookup_expr="icontains", label=_("Name")) parent_uuid = CharFilter(method="filter_parent_uuid", label=_("Parent")) @@ -451,15 +510,24 @@ class CategoryFilter(FilterSet): # type: ignore [misc] "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: 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) - 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: return queryset @@ -505,7 +573,9 @@ class CategoryFilter(FilterSet): # type: ignore [misc] if depth <= 0: 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) if nested_prefetch: @@ -521,7 +591,9 @@ class CategoryFilter(FilterSet): # type: ignore [misc] 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_desc_products = Exists( Product.objects.filter( @@ -554,7 +626,9 @@ class BrandFilter(FilterSet): # type: ignore [misc] uuid = UUIDFilter(field_name="uuid", lookup_expr="exact") name = CharFilter(lookup_expr="icontains", label=_("Name")) slug = CharFilter(field_name="slug", lookup_expr="exact", label=_("Slug")) - categories = CaseInsensitiveListFilter(field_name="categories__uuid", lookup_expr="exact", label=_("Categories")) + categories = CaseInsensitiveListFilter( + field_name="categories__uuid", lookup_expr="exact", label=_("Categories") + ) order_by = OrderingFilter( fields=( @@ -570,11 +644,16 @@ class BrandFilter(FilterSet): # type: ignore [misc] model = Brand 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: 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) @@ -610,8 +689,12 @@ class FeedbackFilter(FilterSet): # type: ignore [misc] class AddressFilter(FilterSet): # type: ignore [misc] uuid = UUIDFilter(field_name="uuid", lookup_expr="exact", label=_("UUID")) - user_uuid = UUIDFilter(field_name="user__uuid", lookup_expr="exact", label=_("User UUID")) - user_email = CharFilter(field_name="user__email", lookup_expr="iexact", label=_("User email")) + user_uuid = UUIDFilter( + 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( fields=( diff --git a/engine/core/graphene/mutations.py b/engine/core/graphene/mutations.py index 3078aaa0..e0d9fde4 100644 --- a/engine/core/graphene/mutations.py +++ b/engine/core/graphene/mutations.py @@ -37,7 +37,9 @@ class CacheOperator(BaseMutation): description = _("cache I/O") 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")) timeout = Int( required=False, @@ -68,7 +70,9 @@ class RequestCursedURL(BaseMutation): try: data = cache.get(url, None) 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() data = camelize(response.json()) cache.set(url, data, 86400) @@ -97,7 +101,9 @@ class AddOrderProduct(BaseMutation): if not (user.has_perm("core.add_orderproduct") or user == order.user): 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) except Order.DoesNotExist as dne: @@ -124,7 +130,9 @@ class RemoveOrderProduct(BaseMutation): if not (user.has_perm("core.change_orderproduct") or user == order.user): 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) except Order.DoesNotExist as dne: @@ -208,7 +216,11 @@ class BuyOrder(BaseMutation): chosen_products=None, ): # type: ignore [override] 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 try: order = None @@ -233,7 +245,11 @@ class BuyOrder(BaseMutation): case "": return BuyOrder(order=instance) 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: raise Http404(_(f"order {order_uuid} not found")) from dne @@ -262,7 +278,11 @@ class BulkOrderAction(BaseMutation): order_hr_id=None, ): # type: ignore [override] 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 try: order = None @@ -491,14 +511,20 @@ class BuyWishlist(BaseMutation): ): 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)): case "": return BuyWishlist(transaction=instance) case "": return BuyWishlist(order=instance) 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: raise Http404(_(f"wishlist {wishlist_uuid} not found")) from dne @@ -513,7 +539,9 @@ class BuyProduct(BaseMutation): product_uuid = UUID(required=True) attributes = String( 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_payment = Boolean(required=False) @@ -532,7 +560,9 @@ class BuyProduct(BaseMutation): ): # type: ignore [override] user = info.context.user 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) match str(type(instance)): case "": @@ -540,7 +570,9 @@ class BuyProduct(BaseMutation): case "": return BuyProduct(order=instance) 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 @@ -566,7 +598,9 @@ class FeedbackProductAction(BaseMutation): feedback = None match action: 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": feedback = order_product.do_feedback(action="remove") case _: @@ -579,7 +613,9 @@ class FeedbackProductAction(BaseMutation): # noinspection PyUnusedLocal,PyTypeChecker class CreateAddress(BaseMutation): 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) diff --git a/engine/core/graphene/object_types.py b/engine/core/graphene/object_types.py index aab912c2..1f6abe78 100644 --- a/engine/core/graphene/object_types.py +++ b/engine/core/graphene/object_types.py @@ -119,7 +119,15 @@ class BrandType(DjangoObjectType): # type: ignore [misc] class Meta: model = Brand 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"] description = _("brands") @@ -129,12 +137,20 @@ class BrandType(DjangoObjectType): # type: ignore [misc] return self.categories.filter(is_active=True) 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: - 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() base = f"https://{settings.BASE_DOMAIN}" canonical = f"{base}/{lang}/brand/{self.slug}" @@ -154,7 +170,12 @@ class BrandType(DjangoObjectType): # type: ignore [misc] "url": canonical, "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)] @@ -196,14 +217,22 @@ class CategoryType(DjangoObjectType): # type: ignore [misc] markup_percent = Float(required=False, description=_("markup percentage")) filterable_attributes = List( 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( 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")) 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["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 { "min_price": min_max_prices["min_price"], @@ -267,7 +298,11 @@ class CategoryType(DjangoObjectType): # type: ignore [misc] title = f"{self.name} | {settings.PROJECT_NAME}" 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 = { "title": title, @@ -276,14 +311,24 @@ class CategoryType(DjangoObjectType): # type: ignore [misc] "url": canonical, "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}/")] for c in self.get_ancestors(): crumbs.append((c.name, f"{base}/{lang}/catalog/{c.slug}")) 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 = [] qs = ( @@ -356,7 +401,9 @@ class AddressType(DjangoObjectType): # type: ignore [misc] class FeedbackType(DjangoObjectType): # type: ignore [misc] 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: model = Feedback @@ -369,7 +416,9 @@ class FeedbackType(DjangoObjectType): # type: ignore [misc] class OrderProductType(DjangoObjectType): # type: ignore [misc] attributes = GenericScalar(description=_("attributes")) 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")) class Meta: @@ -409,14 +458,18 @@ class OrderType(DjangoObjectType): # type: ignore [misc] billing_address = Field(AddressType, description=_("billing address")) shipping_address = Field( 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_quantity = Int(description=_("total quantity of products in order")) is_whole_digital = Float(description=_("are all products in the order digital")) attributes = GenericScalar(description=_("attributes")) notifications = GenericScalar(description=_("notifications")) - payments_transactions = Field(TransactionType, description=_("transactions for this order")) + payments_transactions = Field( + TransactionType, description=_("transactions for this order") + ) class Meta: model = Order @@ -474,13 +527,17 @@ class ProductType(DjangoObjectType): # type: ignore [misc] images = DjangoFilterConnectionField(ProductImageType, description=_("images")) feedbacks = DjangoFilterConnectionField(FeedbackType, description=_("feedbacks")) brand = Field(BrandType, description=_("brand")) - attribute_groups = DjangoFilterConnectionField(AttributeGroupType, description=_("attribute groups")) + attribute_groups = DjangoFilterConnectionField( + AttributeGroupType, description=_("attribute groups") + ) price = Float(description=_("price")) quantity = Float(description=_("quantity")) feedbacks_count = Int(description=_("number of feedbacks")) personal_orders_only = Boolean(description=_("only available for personal orders")) 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")) class Meta: @@ -524,7 +581,9 @@ class ProductType(DjangoObjectType): # type: ignore [misc] def resolve_attribute_groups(self: Product, info): 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: return self.quantity or 0 @@ -549,7 +608,12 @@ class ProductType(DjangoObjectType): # type: ignore [misc] "url": canonical, "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}/")] if self.category: @@ -611,14 +675,20 @@ class PromoCodeType(DjangoObjectType): # type: ignore [misc] description = _("promocodes") 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: return "percent" if self.discount_percent else "amount" class PromotionType(DjangoObjectType): # type: ignore [misc] - products = DjangoFilterConnectionField(ProductType, description=_("products on sale")) + products = DjangoFilterConnectionField( + ProductType, description=_("products on sale") + ) class Meta: model = Promotion @@ -641,7 +711,9 @@ class StockType(DjangoObjectType): # type: ignore [misc] class WishlistType(DjangoObjectType): # type: ignore [misc] - products = DjangoFilterConnectionField(ProductType, description=_("wishlisted products")) + products = DjangoFilterConnectionField( + ProductType, description=_("wishlisted products") + ) class Meta: model = Wishlist @@ -651,7 +723,9 @@ class WishlistType(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: model = ProductTag @@ -662,7 +736,9 @@ class ProductTagType(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: model = CategoryTag @@ -677,7 +753,11 @@ class ConfigType(ObjectType): # type: ignore [misc] company_name = String(description=_("company name")) company_address = String(description=_("company address")) 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")) payment_gateway_maximum = Float(description=_("maximum 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] - products = List(description=_("products search results"), of_type=SearchProductsResultsType) - categories = List(description=_("products search results"), of_type=SearchCategoriesResultsType) - brands = List(description=_("products search results"), of_type=SearchBrandsResultsType) + products = List( + description=_("products search results"), of_type=SearchProductsResultsType + ) + 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) diff --git a/engine/core/graphene/schema.py b/engine/core/graphene/schema.py index 870024eb..e87479f9 100644 --- a/engine/core/graphene/schema.py +++ b/engine/core/graphene/schema.py @@ -113,13 +113,19 @@ class Query(ObjectType): users = DjangoFilterConnectionField(UserType, filterset_class=UserFilter) addresses = DjangoFilterConnectionField(AddressType, filterset_class=AddressFilter) attribute_groups = DjangoFilterConnectionField(AttributeGroupType) - categories = DjangoFilterConnectionField(CategoryType, filterset_class=CategoryFilter) + categories = DjangoFilterConnectionField( + CategoryType, filterset_class=CategoryFilter + ) vendors = DjangoFilterConnectionField(VendorType) - feedbacks = DjangoFilterConnectionField(FeedbackType, filterset_class=FeedbackFilter) + feedbacks = DjangoFilterConnectionField( + FeedbackType, filterset_class=FeedbackFilter + ) order_products = DjangoFilterConnectionField(OrderProductType) product_images = DjangoFilterConnectionField(ProductImageType) stocks = DjangoFilterConnectionField(StockType) - wishlists = DjangoFilterConnectionField(WishlistType, filterset_class=WishlistFilter) + wishlists = DjangoFilterConnectionField( + WishlistType, filterset_class=WishlistFilter + ) product_tags = DjangoFilterConnectionField(ProductTagType) category_tags = DjangoFilterConnectionField(CategoryTagType) promotions = DjangoFilterConnectionField(PromotionType) @@ -137,7 +143,12 @@ class Query(ObjectType): if not 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) @@ -152,10 +163,16 @@ class Query(ObjectType): def resolve_products(_parent, info, **kwargs): if info.context.user.is_authenticated and kwargs.get("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) 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") else Product.objects.filter( is_active=True, @@ -318,7 +335,10 @@ class Query(ObjectType): def resolve_promocodes(_parent, info, **kwargs): promocodes = PromoCode.objects 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( is_active=True, user=info.context.user, diff --git a/engine/core/management/commands/check_translated.py b/engine/core/management/commands/check_translated.py index dee990dd..29e62927 100644 --- a/engine/core/management/commands/check_translated.py +++ b/engine/core/management/commands/check_translated.py @@ -10,7 +10,7 @@ from django.apps import apps from django.conf import settings 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 PLACEHOLDER_REGEXES = [ @@ -118,7 +118,9 @@ class Command(BaseCommand): for lang in langs: 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): continue @@ -141,7 +143,9 @@ class Command(BaseCommand): display = po_path.replace("/app/", root_path) if "\\" in root_path: 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: # Header for language with issues @@ -157,7 +161,11 @@ class Command(BaseCommand): self.stdout.write("") else: # 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.style.SUCCESS("Done scanning.")) diff --git a/engine/core/management/commands/clear_unwanted.py b/engine/core/management/commands/clear_unwanted.py index c82c8ef4..c2ed53a8 100644 --- a/engine/core/management/commands/clear_unwanted.py +++ b/engine/core/management/commands/clear_unwanted.py @@ -37,7 +37,11 @@ class Command(BaseCommand): if stock_deletions: 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) category_groups = defaultdict(list) @@ -61,7 +65,9 @@ class Command(BaseCommand): for duplicate in cat_list: if duplicate.uuid == keep_category.uuid: 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)) if categories_to_delete: @@ -78,13 +84,25 @@ class Command(BaseCommand): count_inactive = inactive_products.count() if count_inactive: 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. orphan_stocks = Stock.objects.filter(product__isnull=True) orphan_count = orphan_stocks.count() if orphan_count: 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!" + ) + ) diff --git a/engine/core/management/commands/deepl_translate.py b/engine/core/management/commands/deepl_translate.py index 345a1648..b5441ea5 100644 --- a/engine/core/management/commands/deepl_translate.py +++ b/engine/core/management/commands/deepl_translate.py @@ -10,7 +10,11 @@ import requests from django.apps import apps 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 PLACEHOLDER_REGEXES = [ @@ -60,7 +64,9 @@ def load_po_sanitized(path: str) -> polib.POFile | None: parts = text.split("\n\n", 1) header = parts[0] 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 tmp = NamedTemporaryFile( # noqa: SIM115 mode="w+", delete=False, suffix=".po", encoding="utf-8" @@ -124,13 +130,19 @@ class Command(BaseCommand): for target_lang in target_langs: api_code = DEEPL_TARGET_LANGUAGES_MAPPING.get(target_lang) 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 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 - 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()] @@ -138,9 +150,13 @@ class Command(BaseCommand): if app_conf.label not in target_apps: 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): - 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 self.stdout.write(f"• {app_conf.label}: loading English PO…") @@ -148,9 +164,13 @@ class Command(BaseCommand): if not en_po: 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: - 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: default = e.msgid if readline: @@ -193,7 +213,11 @@ class Command(BaseCommand): try: old_tgt = load_po_sanitized(str(tgt_path)) 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.metadata = en_po.metadata.copy() @@ -215,7 +239,9 @@ class Command(BaseCommand): to_trans = [e for e in new_po if not e.msgstr] 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 protected = [] @@ -239,7 +265,9 @@ class Command(BaseCommand): trans = result.get("translations", []) 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): entry.msgstr = deplaceholderize(obj["text"], pmap) diff --git a/engine/core/management/commands/delete_never_ordered_products.py b/engine/core/management/commands/delete_never_ordered_products.py index 5f6fe6ce..11a47347 100644 --- a/engine/core/management/commands/delete_never_ordered_products.py +++ b/engine/core/management/commands/delete_never_ordered_products.py @@ -21,7 +21,11 @@ class Command(BaseCommand): def handle(self, *args: list[Any], **options: dict[Any, Any]) -> None: size: int = options["size"] # type: ignore [assignment] 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: break try: @@ -29,7 +33,10 @@ class Command(BaseCommand): ProductImage.objects.filter(product_id__in=batch_ids).delete() Product.objects.filter(pk__in=batch_ids).delete() 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 self.stdout.write(f"Deleted {len(batch_ids)} products…") diff --git a/engine/core/management/commands/delete_products_by_description.py b/engine/core/management/commands/delete_products_by_description.py index 348e8781..f39e1434 100644 --- a/engine/core/management/commands/delete_products_by_description.py +++ b/engine/core/management/commands/delete_products_by_description.py @@ -22,7 +22,9 @@ class Command(BaseCommand): size: int = options["size"] # type: ignore [assignment] while True: 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: break @@ -31,7 +33,10 @@ class Command(BaseCommand): ProductImage.objects.filter(product_id__in=batch_ids).delete() Product.objects.filter(pk__in=batch_ids).delete() 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 self.stdout.write(f"Deleted {len(batch_ids)} products…") diff --git a/engine/core/management/commands/fetch_products.py b/engine/core/management/commands/fetch_products.py index 5c2c736f..27bd635a 100644 --- a/engine/core/management/commands/fetch_products.py +++ b/engine/core/management/commands/fetch_products.py @@ -7,8 +7,14 @@ from engine.core.tasks import update_products_task class Command(BaseCommand): 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] - 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!" + ) + ) diff --git a/engine/core/management/commands/fix_fuzzy.py b/engine/core/management/commands/fix_fuzzy.py index f0d5af60..6a3f3609 100644 --- a/engine/core/management/commands/fix_fuzzy.py +++ b/engine/core/management/commands/fix_fuzzy.py @@ -53,19 +53,28 @@ class Command(BaseCommand): continue 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 ): changed = True continue 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, ) 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: ent[fuzzy_idx] = "#, " + ", ".join(flags) + "\n" else: @@ -73,7 +82,9 @@ class Command(BaseCommand): 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 diff --git a/engine/core/management/commands/fix_prices.py b/engine/core/management/commands/fix_prices.py index 01b9e206..6076db78 100644 --- a/engine/core/management/commands/fix_prices.py +++ b/engine/core/management/commands/fix_prices.py @@ -16,9 +16,13 @@ class Command(BaseCommand): for product in Product.objects.filter(stocks__isnull=False): for stock in product.stocks.all(): try: - stock.price = AbstractVendor.round_price_marketologically(stock.price) + stock.price = AbstractVendor.round_price_marketologically( + stock.price + ) stock.save() 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.SUCCESS("Successfully fixed stocks' prices!")) diff --git a/engine/core/management/commands/initialize.py b/engine/core/management/commands/initialize.py index bdcda2f3..f392c64d 100644 --- a/engine/core/management/commands/initialize.py +++ b/engine/core/management/commands/initialize.py @@ -151,43 +151,65 @@ class Command(BaseCommand): stored_date = datetime.min 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 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: perms = Permission.objects.filter(codename__in=user_support_permissions) 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: perms = Permission.objects.filter(codename__in=stock_manager_permissions) 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: - 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) - 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: perms = Permission.objects.filter(codename__in=marketing_admin_permissions) 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: perms = Permission.objects.filter(codename__in=e_commerce_admin_permissions) e_commerce_admin.permissions.add(*perms) 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: 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: 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!") + ) diff --git a/engine/core/management/commands/rebuild_slugs.py b/engine/core/management/commands/rebuild_slugs.py index ccc27f10..fed02a53 100644 --- a/engine/core/management/commands/rebuild_slugs.py +++ b/engine/core/management/commands/rebuild_slugs.py @@ -14,10 +14,17 @@ class Command(BaseCommand): def reset_em(self, queryset: QuerySet[Any]) -> None: 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): 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.save() instance.slug = None diff --git a/engine/core/management/commands/translate_fields.py b/engine/core/management/commands/translate_fields.py index 2f3468bc..8ab893b8 100644 --- a/engine/core/management/commands/translate_fields.py +++ b/engine/core/management/commands/translate_fields.py @@ -52,7 +52,9 @@ class Command(BaseCommand): module = importlib.import_module(module_path) model = getattr(module, model_name) 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_field = f"{field_name}_{dest_suffix}" @@ -67,13 +69,17 @@ class Command(BaseCommand): if not auth_key: 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() if total == 0: self.stdout.write("No instances with non-empty source field found.") 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(): src_text = getattr(obj, field_name) @@ -92,7 +98,9 @@ class Command(BaseCommand): timeout=30, ) 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 data = resp.json() diff --git a/engine/core/managers.py b/engine/core/managers.py index 124de06a..36882106 100644 --- a/engine/core/managers.py +++ b/engine/core/managers.py @@ -23,7 +23,9 @@ class AddressManager(models.Manager): resp.raise_for_status() results = resp.json() 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] addr = data.get("address", {}) diff --git a/engine/core/migrations/0001_initial.py b/engine/core/migrations/0001_initial.py index 9cfc3377..25382325 100644 --- a/engine/core/migrations/0001_initial.py +++ b/engine/core/migrations/0001_initial.py @@ -47,7 +47,9 @@ class Migration(migrations.Migration): ( "modified", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", ), ), - ("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={ "verbose_name": "vendor", @@ -1556,7 +1585,9 @@ class Migration(migrations.Migration): ( "modified", 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", 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="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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", - 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", null=True, upload_to="categories/", - validators=[engine.core.validators.validate_category_image_dimensions], + validators=[ + engine.core.validators.validate_category_image_dimensions + ], verbose_name="category image", ), ), @@ -2194,7 +2332,9 @@ class Migration(migrations.Migration): ( "name", 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", 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", models.ForeignKey( @@ -2650,7 +2799,9 @@ class Migration(migrations.Migration): ( "modified", 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", ), ), ( diff --git a/engine/core/migrations/0002_initial.py b/engine/core/migrations/0002_initial.py index 12143cdb..1f7e90b2 100644 --- a/engine/core/migrations/0002_initial.py +++ b/engine/core/migrations/0002_initial.py @@ -203,7 +203,8 @@ class Migration(migrations.Migration): migrations.AddIndex( model_name="orderproduct", index=django.contrib.postgres.indexes.GinIndex( - fields=["notifications", "attributes"], name="core_orderp_notific_cd27e9_gin" + fields=["notifications", "attributes"], + name="core_orderp_notific_cd27e9_gin", ), ), ] diff --git a/engine/core/migrations/0003_alter_attribute_name_alter_attribute_name_ar_ar_and_more.py b/engine/core/migrations/0003_alter_attribute_name_alter_attribute_name_ar_ar_and_more.py index faf28dd7..b45b2f7f 100644 --- a/engine/core/migrations/0003_alter_attribute_name_alter_attribute_name_ar_ar_and_more.py +++ b/engine/core/migrations/0003_alter_attribute_name_alter_attribute_name_ar_ar_and_more.py @@ -11,7 +11,10 @@ class Migration(migrations.Migration): model_name="attribute", name="name", 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( @@ -216,7 +219,10 @@ class Migration(migrations.Migration): model_name="attributegroup", name="name", 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( @@ -421,14 +427,20 @@ class Migration(migrations.Migration): model_name="brand", name="name", 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( model_name="category", name="name", 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( @@ -1049,7 +1061,10 @@ class Migration(migrations.Migration): model_name="vendor", name="name", 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", ), ), ] diff --git a/engine/core/migrations/0008_digitalassetdownload.py b/engine/core/migrations/0008_digitalassetdownload.py index 3f5ca620..409ab472 100644 --- a/engine/core/migrations/0008_digitalassetdownload.py +++ b/engine/core/migrations/0008_digitalassetdownload.py @@ -44,14 +44,18 @@ class Migration(migrations.Migration): ( "modified", 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)), ( "order_product", 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", ), ), ], diff --git a/engine/core/migrations/0009_documentary.py b/engine/core/migrations/0009_documentary.py index a744267a..3d987631 100644 --- a/engine/core/migrations/0009_documentary.py +++ b/engine/core/migrations/0009_documentary.py @@ -46,14 +46,23 @@ class Migration(migrations.Migration): ( "modified", 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", 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", ), ), ], diff --git a/engine/core/migrations/0018_alter_order_human_readable_id.py b/engine/core/migrations/0018_alter_order_human_readable_id.py index e608c7e8..51158e85 100644 --- a/engine/core/migrations/0018_alter_order_human_readable_id.py +++ b/engine/core/migrations/0018_alter_order_human_readable_id.py @@ -8,7 +8,11 @@ def fix_duplicates(apps, schema_editor): if schema_editor: pass 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: h_id = duplicate["human_readable_id"] orders = Order.objects.filter(human_readable_id=h_id).order_by("uuid") diff --git a/engine/core/migrations/0019_address.py b/engine/core/migrations/0019_address.py index 5effb319..5375bd42 100644 --- a/engine/core/migrations/0019_address.py +++ b/engine/core/migrations/0019_address.py @@ -47,15 +47,39 @@ class Migration(migrations.Migration): ( "modified", 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")), - ("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")), + ( + "street", + models.CharField(max_length=255, null=True, verbose_name="street"), + ), + ( + "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", django.contrib.gis.db.models.fields.PointField( @@ -69,26 +93,37 @@ class Migration(migrations.Migration): ( "raw_data", 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", 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", 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={ "verbose_name": "address", "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" + ) + ], }, ), ] diff --git a/engine/core/migrations/0022_category_slug.py b/engine/core/migrations/0022_category_slug.py index b649857a..f725d04f 100644 --- a/engine/core/migrations/0022_category_slug.py +++ b/engine/core/migrations/0022_category_slug.py @@ -24,7 +24,12 @@ class Migration(migrations.Migration): model_name="category", name="slug", 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), diff --git a/engine/core/migrations/0023_address_address_line.py b/engine/core/migrations/0023_address_address_line.py index 522a4d52..0f72d399 100644 --- a/engine/core/migrations/0023_address_address_line.py +++ b/engine/core/migrations/0023_address_address_line.py @@ -11,7 +11,10 @@ class Migration(migrations.Migration): model_name="address", name="address_line", 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", ), ), ] diff --git a/engine/core/migrations/0024_categorytag_category_tags.py b/engine/core/migrations/0024_categorytag_category_tags.py index 00550a97..3de05258 100644 --- a/engine/core/migrations/0024_categorytag_category_tags.py +++ b/engine/core/migrations/0024_categorytag_category_tags.py @@ -44,13 +44,17 @@ class Migration(migrations.Migration): ( "modified", 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", 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_plural": "category tags", }, - bases=(django_prometheus.models.ExportModelOperationsMixin("category_tag"), models.Model), + bases=( + django_prometheus.models.ExportModelOperationsMixin("category_tag"), + models.Model, + ), ), migrations.AddField( model_name="category", diff --git a/engine/core/migrations/0035_alter_brand_slug_alter_category_slug_and_more.py b/engine/core/migrations/0035_alter_brand_slug_alter_category_slug_and_more.py index 6cd21d99..253d0c43 100644 --- a/engine/core/migrations/0035_alter_brand_slug_alter_category_slug_and_more.py +++ b/engine/core/migrations/0035_alter_brand_slug_alter_category_slug_and_more.py @@ -1,7 +1,8 @@ -import engine.core.utils.db import django_extensions.db.fields from django.db import migrations +import engine.core.utils.db + class Migration(migrations.Migration): dependencies = [ diff --git a/engine/core/migrations/0036_vendor_b2b_auth_token_vendor_users.py b/engine/core/migrations/0036_vendor_b2b_auth_token_vendor_users.py index 5db6217f..27fb62ef 100644 --- a/engine/core/migrations/0036_vendor_b2b_auth_token_vendor_users.py +++ b/engine/core/migrations/0036_vendor_b2b_auth_token_vendor_users.py @@ -1,7 +1,8 @@ -import engine.core.utils from django.conf import settings from django.db import migrations, models +import engine.core.utils + class Migration(migrations.Migration): dependencies = [ @@ -23,6 +24,8 @@ class Migration(migrations.Migration): migrations.AddField( model_name="vendor", 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 + ), ), ] diff --git a/engine/core/migrations/0038_backfill_product_sku.py b/engine/core/migrations/0038_backfill_product_sku.py index be467a8b..7983fc0f 100644 --- a/engine/core/migrations/0038_backfill_product_sku.py +++ b/engine/core/migrations/0038_backfill_product_sku.py @@ -21,14 +21,18 @@ def backfill_sku(apps, schema_editor): while True: 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: break updates = [] 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(): Product.objects.bulk_update(updates, ["sku"], batch_size=BATCH) diff --git a/engine/core/migrations/0039_alter_product_sku.py b/engine/core/migrations/0039_alter_product_sku.py index 4497eb8d..03182151 100644 --- a/engine/core/migrations/0039_alter_product_sku.py +++ b/engine/core/migrations/0039_alter_product_sku.py @@ -1,6 +1,7 @@ -import engine.core.utils from django.db import migrations, models +import engine.core.utils + class Migration(migrations.Migration): dependencies = [ diff --git a/engine/core/migrations/0040_customerrelationshipmanagementprovider_ordercrmlink.py b/engine/core/migrations/0040_customerrelationshipmanagementprovider_ordercrmlink.py index 168ae5b0..a69e8f2d 100644 --- a/engine/core/migrations/0040_customerrelationshipmanagementprovider_ordercrmlink.py +++ b/engine/core/migrations/0040_customerrelationshipmanagementprovider_ordercrmlink.py @@ -1,7 +1,8 @@ +import uuid + import django.db.models.deletion import django_extensions.db.fields import django_prometheus.models -import uuid from django.db import migrations, models @@ -55,11 +56,15 @@ class Migration(migrations.Migration): ), ( "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", - models.JSONField(blank=True, help_text="authentication credentials", null=True), + models.JSONField( + blank=True, help_text="authentication credentials", null=True + ), ), ( "attributes", diff --git a/engine/core/migrations/0044_vendor_last_processing_response.py b/engine/core/migrations/0044_vendor_last_processing_response.py index b8d470ff..d380c814 100644 --- a/engine/core/migrations/0044_vendor_last_processing_response.py +++ b/engine/core/migrations/0044_vendor_last_processing_response.py @@ -1,6 +1,7 @@ -import engine.core.utils from django.db import migrations, models +import engine.core.utils + class Migration(migrations.Migration): dependencies = [ diff --git a/engine/core/migrations/0045_alter_product_name_alter_product_name_ar_ar_and_more.py b/engine/core/migrations/0045_alter_product_name_alter_product_name_ar_ar_and_more.py index 088c7c5b..22f05aec 100644 --- a/engine/core/migrations/0045_alter_product_name_alter_product_name_ar_ar_and_more.py +++ b/engine/core/migrations/0045_alter_product_name_alter_product_name_ar_ar_and_more.py @@ -329,19 +329,27 @@ class Migration(migrations.Migration): ), migrations.AddIndex( 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( 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( 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( 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( model_name="product", diff --git a/engine/core/migrations/0052_alter_stock_system_attributes.py b/engine/core/migrations/0052_alter_stock_system_attributes.py index 2cb1ed12..289404ea 100644 --- a/engine/core/migrations/0052_alter_stock_system_attributes.py +++ b/engine/core/migrations/0052_alter_stock_system_attributes.py @@ -10,6 +10,8 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="stock", 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" + ), ), ] diff --git a/engine/core/models.py b/engine/core/models.py index 57aa654b..63d8aaa8 100644 --- a/engine/core/models.py +++ b/engine/core/models.py @@ -121,7 +121,9 @@ class Vendor(ExportModelOperationsMixin("vendor"), NiceModel): # type: ignore [ authentication = JSONField( blank=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"), ) markup_percent = IntegerField( @@ -138,8 +140,12 @@ class Vendor(ExportModelOperationsMixin("vendor"), NiceModel): # type: ignore [ null=False, unique=True, ) - users = ManyToManyField(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) + users = ManyToManyField( + 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( upload_to=get_vendor_name_as_path, blank=True, @@ -339,10 +345,15 @@ class Category(ExportModelOperationsMixin("category"), NiceModel, MPTTModel): # def get_tree_depth(self): if self.is_leaf_node(): 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 - 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] if not cat_list: return @@ -383,7 +394,10 @@ class Category(ExportModelOperationsMixin("category"), NiceModel, MPTTModel): # "value_type": value_type, } 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) for c in cat_list: @@ -401,7 +415,9 @@ class Category(ExportModelOperationsMixin("category"), NiceModel, MPTTModel): # attribute__is_filterable=True, 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() ) @@ -415,7 +431,10 @@ class Category(ExportModelOperationsMixin("category"), NiceModel, MPTTModel): # "value_type": value_type, } 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) 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"), 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: return f"{self.vendor.name} - {self.product!s}" @@ -756,7 +777,9 @@ class Attribute(ExportModelOperationsMixin("attribute"), NiceModel): # type: ig is_filterable = BooleanField( default=True, 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): @@ -991,7 +1014,9 @@ class Documentary(ExportModelOperationsMixin("attribute_group"), NiceModel): # 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) class Meta: @@ -1044,7 +1069,11 @@ class Address(ExportModelOperationsMixin("address"), NiceModel): # type: ignore 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( blank=True, @@ -1052,7 +1081,9 @@ class Address(ExportModelOperationsMixin("address"), NiceModel): # type: ignore 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() @@ -1147,7 +1178,9 @@ class PromoCode(ExportModelOperationsMixin("promocode"), NiceModel): # type: ig self.discount_amount is None and self.discount_percent is None ): 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( force_insert=force_insert, @@ -1176,12 +1209,18 @@ class PromoCode(ExportModelOperationsMixin("promocode"), NiceModel): # type: ig promo_amount = order.total_price if self.discount_type == "percent": - promo_amount -= round(promo_amount * (float(self.discount_percent) / 100), 2) # type: ignore [arg-type] - order.attributes.update({"promocode_uuid": str(self.uuid), "final_price": promo_amount}) + promo_amount -= round( + 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() elif self.discount_type == "amount": 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() else: 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() return False with suppress(Exception): - return (self.attributes.get("is_business", False) if self.attributes else False) or ( - (self.user.attributes.get("is_business", False) and self.user.attributes.get("business_identificator")) # type: ignore [union-attr] + return ( + self.attributes.get("is_business", False) if self.attributes else False + ) or ( + ( + self.user.attributes.get("is_business", False) + and self.user.attributes.get("business_identificator") + ) # type: ignore [union-attr] if self.user else False ) @@ -1348,7 +1392,9 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi attributes = [] 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: product = Product.objects.get(uuid=product_uuid) @@ -1357,10 +1403,14 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi 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(): - 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( product=product, @@ -1370,7 +1420,9 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi ) if not is_created and update_quantity: 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.buy_price = product.price order_product.save() @@ -1392,7 +1444,9 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi attributes = {} 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: product = Product.objects.get(uuid=product_uuid) 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 except OrderProduct.DoesNotExist as dne: 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 def remove_all_products(self): 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(): self.order_products.remove(order_product) order_product.delete() @@ -1425,7 +1483,9 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi def remove_products_of_a_kind(self, product_uuid): 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: product = Product.objects.get(uuid=product_uuid) order_product = self.order_products.get(product=product, order=self) @@ -1439,7 +1499,10 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi @property 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): try: @@ -1448,10 +1511,21 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi raise Http404(_("promocode does not exist")) from dne 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: - if 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 ( + 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: 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) 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")) 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 - 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")) 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")) if type(order.user.attributes) is dict: - if order.user.attributes.get("is_business", False) or order.user.attributes.get( - "business_identificator", "" - ): + if order.user.attributes.get( + "is_business", False + ) or order.user.attributes.get("business_identificator", ""): if type(order.attributes) is not dict: order.attributes = {} order.attributes.update({"is_business": True}) @@ -1538,7 +1618,9 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi match force: case "balance": 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(): order.status = "CREATED" order.buy_time = timezone.now() @@ -1558,14 +1640,20 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi case _: 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: - 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: 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_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") 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") 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: 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" @@ -1635,7 +1731,9 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi def update_order_products_statuses(self, status: str = "PENDING"): 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: self.add_product( 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) if crm_links.exists(): 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: crm_integration.process_order_changes(self) return True @@ -1678,19 +1778,25 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi return True except Exception as e: 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 @property def business_identificator(self) -> str | None: 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.attributes: - return self.user.attributes.get("business_identificator") or self.user.attributes.get( - "businessIdentificator" - ) + return self.user.attributes.get( + "business_identificator" + ) or self.user.attributes.get("businessIdentificator") return None @@ -1716,7 +1822,9 @@ class Feedback(ExportModelOperationsMixin("feedback"), NiceModel): # type: igno on_delete=CASCADE, blank=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"), ) rating = FloatField( @@ -1728,7 +1836,11 @@ class Feedback(ExportModelOperationsMixin("feedback"), NiceModel): # type: igno ) 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} | {self.uuid}" @@ -1838,7 +1950,9 @@ class OrderProduct(ExportModelOperationsMixin("order_product"), NiceModel): # t "errors": [ { "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 not feedback_exists: 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: - raise ValueError(_("you cannot feedback an order which is not received")) + raise ValueError( + _("you cannot feedback an order which is not received") + ) 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")) - integration_url = URLField(blank=True, null=True, help_text=_("URL of the integration")) - authentication = JSONField(blank=True, null=True, help_text=_("authentication credentials")) + integration_url = URLField( + blank=True, null=True, help_text=_("URL of the integration") + ) + authentication = JSONField( + blank=True, null=True, help_text=_("authentication credentials") + ) attributes = JSONField(blank=True, null=True, verbose_name=_("attributes")) integration_location = CharField(max_length=128, blank=True, null=True) default = BooleanField(default=False) @@ -1931,7 +2055,11 @@ class CustomerRelationshipManagementProvider(ExportModelOperationsMixin("crm_pro class OrderCrmLink(ExportModelOperationsMixin("order_crm_link"), NiceModel): # type: ignore 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) def __str__(self) -> str: @@ -1954,7 +2082,9 @@ class DigitalAssetDownload(ExportModelOperationsMixin("attribute_group"), NiceMo 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) class Meta: @@ -1966,6 +2096,4 @@ class DigitalAssetDownload(ExportModelOperationsMixin("attribute_group"), NiceMo @property def url(self): - return ( - f"https://api.{settings.BASE_DOMAIN}/download/{urlsafe_base64_encode(force_bytes(self.order_product.uuid))}" - ) + return f"https://api.{settings.BASE_DOMAIN}/download/{urlsafe_base64_encode(force_bytes(self.order_product.uuid))}" diff --git a/engine/core/permissions.py b/engine/core/permissions.py index ff1f9c92..22c123cb 100644 --- a/engine/core/permissions.py +++ b/engine/core/permissions.py @@ -60,10 +60,15 @@ class EvibesPermission(permissions.BasePermission): return True 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 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): if request.method in permissions.SAFE_METHODS: @@ -76,7 +81,10 @@ class EvibesPermission(permissions.BasePermission): model_name = obj._meta.model_name action = view.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) return bool( @@ -100,7 +108,9 @@ class EvibesPermission(permissions.BasePermission): return queryset.none() 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 base diff --git a/engine/core/serializers/detail.py b/engine/core/serializers/detail.py index b92d4b98..834c5bec 100644 --- a/engine/core/serializers/detail.py +++ b/engine/core/serializers/detail.py @@ -25,7 +25,10 @@ from engine.core.models import ( Vendor, 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.typing import FilterableAttribute @@ -85,7 +88,11 @@ class CategoryDetailSerializer(ModelSerializer): else: 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): diff --git a/engine/core/serializers/simple.py b/engine/core/serializers/simple.py index 0d77925a..7f88badc 100644 --- a/engine/core/serializers/simple.py +++ b/engine/core/serializers/simple.py @@ -59,7 +59,11 @@ class CategorySimpleSerializer(ModelSerializer): # type: ignore [type-arg] else: 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] diff --git a/engine/core/serializers/utility.py b/engine/core/serializers/utility.py index 94dcb8d5..4b2cd298 100644 --- a/engine/core/serializers/utility.py +++ b/engine/core/serializers/utility.py @@ -86,7 +86,11 @@ class DoFeedbackSerializer(Serializer): # type: ignore [type-arg] def validate(self, data: dict[str, Any]) -> dict[str, Any]: 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 @@ -150,11 +154,15 @@ class RemoveWishlistProductSerializer(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] - 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] diff --git a/engine/core/signals.py b/engine/core/signals.py index 70ea6b72..fb15c6d6 100644 --- a/engine/core/signals.py +++ b/engine/core/signals.py @@ -13,21 +13,34 @@ from sentry_sdk import capture_exception from engine.core.crm import any_crm_integrations 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 ( generate_human_readable_id, resolve_translations_for_elasticsearch, ) -from engine.core.utils.emailing import send_order_created_email, send_order_finished_email, send_promocode_created_email -from evibes.utils.misc import create_object +from engine.core.utils.emailing import ( + send_order_created_email, + send_order_finished_email, + send_promocode_created_email, +) from engine.vibes_auth.models import User +from evibes.utils.misc import create_object logger = logging.getLogger(__name__) # noinspection PyUnusedLocal @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: try: 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(): human_readable_id = generate_human_readable_id() 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 # noinspection PyUnusedLocal @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: Wishlist.objects.create(user=instance) # noinspection PyUnusedLocal @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: if type(instance.attributes) is not dict: instance.attributes = {} instance.save() 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) code = f"WELCOME-{get_random_string(6)}" 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() while True: 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() continue 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: 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: 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() @@ -124,8 +151,12 @@ def process_order_changes(instance: Order, created: bool, **kwargs: dict[Any, An if has_file: order_product.status = "FINISHED" - DigitalAssetDownload.objects.get_or_create(order_product=order_product) - order_product.order.user.payments_balance.amount -= order_product.buy_price # type: ignore [union-attr, operator] + DigitalAssetDownload.objects.get_or_create( + 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.save() continue @@ -134,24 +165,37 @@ def process_order_changes(instance: Order, created: bool, **kwargs: dict[Any, An try: 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] 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: 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.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.save() 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 @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: pass resolve_translations_for_elasticsearch(instance, "name") @@ -168,7 +214,9 @@ def update_product_name_lang(instance: Product, created: bool, **kwargs: dict[An # noinspection PyUnusedLocal @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: pass resolve_translations_for_elasticsearch(instance, "name") @@ -177,6 +225,8 @@ def update_category_name_lang(instance: Category, created: bool, **kwargs: dict[ # noinspection PyUnusedLocal @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: send_promocode_created_email.delay(str(instance.uuid)) diff --git a/engine/core/sitemaps.py b/engine/core/sitemaps.py index 2af42a70..ce30bfe6 100644 --- a/engine/core/sitemaps.py +++ b/engine/core/sitemaps.py @@ -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( - "title", "slug", "modified" - ): + for static_post_page in Post.objects.filter( + is_static_page=True, is_active=True + ).only("title", "slug", "modified"): pages.append( { "name": static_post_page.title, diff --git a/engine/core/tasks.py b/engine/core/tasks.py index 6cc69cd1..f45eae79 100644 --- a/engine/core/tasks.py +++ b/engine/core/tasks.py @@ -134,7 +134,9 @@ def remove_stale_product_images() -> tuple[bool, str]: # Load all current product UUIDs into a set. # This query returns all product UUIDs (as strings or UUID objects). 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. 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: try: 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: logger.error("Error removing directory %s: %s", entry_path, e) return True, "Successfully removed stale product images." @@ -192,7 +196,9 @@ def process_promotions() -> tuple[bool, str]: ) response.raise_for_status() 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}" holidays = response.json() if holidays: @@ -224,7 +230,8 @@ def process_promotions() -> tuple[bool, str]: selected_products.append(product) 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] for product in selected_products: diff --git a/engine/core/tests/test_drf.py b/engine/core/tests/test_drf.py index bc2388cb..4ad8bd96 100644 --- a/engine/core/tests/test_drf.py +++ b/engine/core/tests/test_drf.py @@ -20,12 +20,20 @@ class DRFCoreViewsTests(TestCase): ) self.user_password = "Str0ngPass!word2" 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): 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) return serializer.validated_data["access_token"] diff --git a/engine/core/urls.py b/engine/core/urls.py index 3e4828f2..697d1150 100644 --- a/engine/core/urls.py +++ b/engine/core/urls.py @@ -1,7 +1,12 @@ from django.urls import include, path 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 ( CacheOperatorView, ContactUsView, diff --git a/engine/core/utils/__init__.py b/engine/core/utils/__init__.py index c2d54f6e..5a23312a 100644 --- a/engine/core/utils/__init__.py +++ b/engine/core/utils/__init__.py @@ -15,7 +15,6 @@ from django.utils.translation import get_language from graphene import Context from rest_framework.request import Request - 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)] - 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, "-") return "".join(chars) diff --git a/engine/core/utils/caching.py b/engine/core/utils/caching.py index 53a88b3c..a51754a5 100644 --- a/engine/core/utils/caching.py +++ b/engine/core/utils/caching.py @@ -29,7 +29,9 @@ def get_cached_value(user: Type[User], key: str, default: Any = None) -> Any: 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: cache.set(key, value, timeout) return value @@ -37,13 +39,17 @@ def set_cached_value(user: Type[User], key: str, value: object, timeout: int = 3 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: return {"data": get_cached_value(request.user, key)} # type: ignore [assignment, arg-type] if (data and not timeout) or (timeout and not data): raise BadRequest(_("both data and timeout are required")) 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] diff --git a/engine/core/utils/commerce.py b/engine/core/utils/commerce.py index 7afa27b5..f6035e6e 100644 --- a/engine/core/utils/commerce.py +++ b/engine/core/utils/commerce.py @@ -18,14 +18,19 @@ def get_period_order_products( statuses = ["FINISHED"] current = now() 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) def get_revenue(clear: bool = True, period: timedelta = timedelta(days=30)) -> float: order_products = get_period_order_products(period) 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: 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: order_products = get_period_order_products(period, ["RETURNED"]) 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: 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() -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() period_start = current - period 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")) .values("day") .annotate(cnt=Count("pk")) @@ -93,7 +105,9 @@ def get_daily_finished_orders_count(period: timedelta = timedelta(days=30)) -> d 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 = ( get_period_order_products(period, ["FINISHED"]) # OrderProduct queryset .annotate(day=TruncDate("order__buy_time")) @@ -114,7 +128,9 @@ def get_daily_gross_revenue(period: timedelta = timedelta(days=30)) -> dict[date 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() period_start = current - period qs = ( @@ -161,7 +177,12 @@ def get_customer_mix(period: timedelta = timedelta(days=30)) -> dict[str, int]: current = now() period_start = current - period 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) .distinct() ) @@ -169,7 +190,9 @@ def get_customer_mix(period: timedelta = timedelta(days=30)) -> dict[str, int]: return {"new": 0, "returning": 0} 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 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} -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() period_start = current - period qs = ( @@ -222,7 +247,9 @@ def get_top_categories_by_qty(period: timedelta = timedelta(days=30), limit: int 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() period_start = current - period qs = ( diff --git a/engine/core/utils/db.py b/engine/core/utils/db.py index e3f3b364..af50feea 100644 --- a/engine/core/utils/db.py +++ b/engine/core/utils/db.py @@ -22,7 +22,9 @@ def unicode_slugify_function(content: Any) -> str: 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): return f"{lookup_value(model_instance)}" diff --git a/engine/core/utils/emailing.py b/engine/core/utils/emailing.py index 9e28ad28..c570ba1c 100644 --- a/engine/core/utils/emailing.py +++ b/engine/core/utils/emailing.py @@ -31,7 +31,9 @@ def contact_us_email(contact_info) -> tuple[bool, str]: "email": contact_info.get("email"), "name": contact_info.get("name"), "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"), "config": config, }, @@ -56,7 +58,13 @@ def send_order_created_email(order_pk: str) -> tuple[bool, str]: if type(order.attributes) is not dict: 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}" language = settings.LANGUAGE_CODE @@ -72,7 +80,9 @@ def send_order_created_email(order_pk: str) -> tuple[bool, str]: email = EmailMessage( _(f"{settings.PROJECT_NAME} | order confirmation"), 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, "today": datetime.today(), @@ -122,7 +132,12 @@ def send_order_finished_email(order_pk: str) -> tuple[bool, str]: ) email.content_subtype = "html" 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: if ops: @@ -204,7 +219,10 @@ def send_promocode_created_email(promocode_pk: str) -> tuple[bool, str]: email.content_subtype = "html" result = email.send() 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) diff --git a/engine/core/utils/seo_builders.py b/engine/core/utils/seo_builders.py index 94a769b8..58f59b9f 100644 --- a/engine/core/utils/seo_builders.py +++ b/engine/core/utils/seo_builders.py @@ -33,7 +33,8 @@ def breadcrumb_schema(items): "@context": "https://schema.org", "@type": "BreadcrumbList", "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 { "@context": "https://schema.org", "@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", "price": round(stock.price, 2), "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, "url": f"https://{settings.BASE_DOMAIN}/product/{product.slug}", } @@ -65,7 +71,9 @@ def product_schema(product, images, rating=None): "name": product.name, "description": product.description 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 [], "offers": offers[:1] if offers else None, } diff --git a/engine/core/utils/vendors.py b/engine/core/utils/vendors.py index 720daea3..efd18be0 100644 --- a/engine/core/utils/vendors.py +++ b/engine/core/utils/vendors.py @@ -20,7 +20,10 @@ def get_vendors_integrations(name: str | None = None) -> list[AbstractVendor]: vendors_integrations.append(create_object(module_name, class_name)) except Exception as e: 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 diff --git a/engine/core/validators.py b/engine/core/validators.py index d0bfbf4f..525bb927 100644 --- a/engine/core/validators.py +++ b/engine/core/validators.py @@ -16,4 +16,8 @@ def validate_category_image_dimensions( return 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" + ) + ) diff --git a/engine/core/vendors/__init__.py b/engine/core/vendors/__init__.py index 78813d42..b2c2f249 100644 --- a/engine/core/vendors/__init__.py +++ b/engine/core/vendors/__init__.py @@ -28,9 +28,9 @@ from engine.core.models import ( Stock, Vendor, ) -from evibes.utils.misc import LoggingError, LogLevel from engine.payments.errors import RatesError from engine.payments.utils import get_rates +from evibes.utils.misc import LoggingError, LogLevel logger = logging.getLogger(__name__) @@ -145,7 +145,9 @@ class AbstractVendor: if len(json_bytes) > size_threshold: 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) compressed_data = buffer.getvalue() @@ -157,15 +159,22 @@ class AbstractVendor: 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 raise VendorDebuggingError("Could not save response") @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: return [] total = len(data) @@ -234,19 +243,25 @@ class AbstractVendor: return value, "string" @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) if not queryset.exists(): if len(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: queryset = queryset.filter(is_active=True) elif queryset.filter(is_active=False).count() > 1: queryset = queryset.filter(is_active=False) chosen = queryset.first() 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.delete() return chosen @@ -254,7 +269,9 @@ class AbstractVendor: def auto_resolve_category(self, category_name: str = "") -> Category | None: if category_name: 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 if uuid: return Category.objects.get(uuid=uuid) @@ -308,7 +325,9 @@ class AbstractVendor: 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]): raise ValueError("Currency must be provided.") @@ -320,7 +339,9 @@ class AbstractVendor: rate = rates.get(currency or self.currency) if rates else 1 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] @@ -368,16 +389,22 @@ class AbstractVendor: except Vendor.DoesNotExist as dne: if safe: 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: pass 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]: - 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]: return AttributeValue.objects.filter( @@ -400,16 +427,22 @@ class AbstractVendor: case _: 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() match inactivation_method: case "deactivate": filter_kwargs: dict[str, Any] = {"is_active": False} case "description": - filter_kwargs: dict[str, Any] = {"description__exact": "EVIBES_DELETED_PRODUCT"} + filter_kwargs: dict[str, Any] = { + "description__exact": "EVIBES_DELETED_PRODUCT" + } 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 == {}: raise ValueError("Invalid filter kwargs...") @@ -420,7 +453,9 @@ class AbstractVendor: if products is None: 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: break with suppress(Exception): @@ -433,7 +468,9 @@ class AbstractVendor: self.get_stocks_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] try: attr = Attribute.objects.get(name=key) @@ -459,15 +496,22 @@ class AbstractVendor: self, key: str, value: Any, product: Product, attr_group: AttributeGroup ) -> AttributeValue | None: 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: - 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 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 if key in self.blocked_attributes: @@ -488,7 +532,11 @@ class AbstractVendor: defaults={"is_active": True}, ) 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] = [] if not attribute.is_active: attribute.is_active = True @@ -507,7 +555,10 @@ class AbstractVendor: continue raise 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 if not is_created: diff --git a/engine/core/views.py b/engine/core/views.py index 1d71c9e0..8ca22bf2 100644 --- a/engine/core/views.py +++ b/engine/core/views.py @@ -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.core.cache import cache from django.core.exceptions import BadRequest -from django.db.models import Count, Sum, F -from django.http import FileResponse, Http404, HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse +from django.db.models import Count, F, Sum +from django.http import ( + FileResponse, + Http404, + HttpRequest, + HttpResponse, + HttpResponseRedirect, + JsonResponse, +) from django.shortcuts import redirect from django.template import Context 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.util import camelize 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 rest_framework import status from rest_framework.permissions import AllowAny @@ -51,7 +62,13 @@ from engine.core.docs.drf.views import ( SEARCH_SCHEMA, ) 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 ( BuyAsBusinessOrderSerializer, CacheOperatorSerializer, @@ -138,7 +155,9 @@ class CustomRedocView(SpectacularRedocView): @extend_schema_view(**LANGUAGE_SCHEMA) 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 permission_classes = [ @@ -184,12 +203,16 @@ class WebsiteParametersView(APIView): ] 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) 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 permission_classes = [ @@ -237,7 +260,9 @@ class ContactUsView(APIView): @extend_schema_view(**REQUEST_CURSED_URL_SCHEMA) 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 = [ AllowAny, @@ -260,7 +285,9 @@ class RequestCursedURLView(APIView): try: data = cache.get(url, None) 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() data = camelize(response.json()) cache.set(url, data, 86400) @@ -287,7 +314,15 @@ class GlobalSearchView(APIView): ] 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) @@ -295,21 +330,32 @@ class BuyAsBusinessView(APIView): __doc__ = _("Handles the logic of buying as a business without registration.") # type: ignore [assignment] # 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: serializer = BuyAsBusinessOrderSerializer(data=request.data) serializer.is_valid(raise_exception=True) 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: transaction = order.buy_without_registration( products=products, promocode_uuid=serializer.validated_data.get("promocode_uuid"), customer_name=serializer.validated_data.get("business_identificator"), customer_email=serializer.validated_data.get("business_email"), - customer_phone_number=serializer.validated_data.get("business_phone_number"), - billing_customer_address=serializer.validated_data.get("billing_business_address_uuid"), - shipping_customer_address=serializer.validated_data.get("shipping_business_address_uuid"), + customer_phone_number=serializer.validated_data.get( + "business_phone_number" + ), + 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"), is_business=True, ) @@ -353,7 +399,9 @@ class DownloadDigitalAssetView(APIView): raise BadRequest(_("you can only download the digital asset once")) 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.save() @@ -373,10 +421,15 @@ class DownloadDigitalAssetView(APIView): return response 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: - 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: capture_exception(e) @@ -457,7 +510,9 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context: result = 0.0 with suppress(Exception): 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) .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: result = 0 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 revenue_gross_prev = sum_gross_between(prev_start, prev_end) @@ -498,7 +555,9 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context: else: if tax_included: 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: revenue_net_prev = revenue_gross_prev 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) return result - aov_cur: float = 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_cur: float = ( + 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 - refund_rate_prev: float = round(((returns_prev / revenue_gross_prev) * 100.0), 1) if revenue_gross_prev > 0 else 0.0 + aov_prev: float = ( + 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 = { "gmv": { @@ -530,7 +605,11 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context: "prev": 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": { "value": revenue_net_cur, "prev": revenue_net_prev, @@ -550,7 +629,9 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context: quick_links: list[dict[str, str]] = [] 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", []): title = item.get("title") link = item.get("link") @@ -578,16 +659,24 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context: if wished_first and wished_first.get("products"): product = Product.objects.filter(pk=wished_first["products"]).first() 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 = { "name": product.name, "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]) 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()) product_by_id = {p.pk: p for p in products} for row in wished_top10: @@ -620,7 +709,9 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context: context["daily_labels"] = labels context["daily_orders"] = orders_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: logger.warning("Failed to build daily stats: %s", e) 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_orders"] = [0 for _i 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]] = [] with suppress(Exception): @@ -649,7 +742,9 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context: "name": str(p.get("name") or ""), "sku": str(p.get("sku") or ""), "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]] = [] with suppress(Exception): 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") .annotate(total_qty=Sum("quantity")) .order_by("-total_qty") @@ -684,16 +781,24 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context: if popular_first and popular_first.get("product"): product = Product.objects.filter(pk=popular_first["product"]).first() 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 = { "name": product.name, "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]) 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()) product_by_id = {p.pk: p for p in products} 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): mix = get_customer_mix() n = int(mix.get("new", 0)) @@ -719,7 +829,13 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context: t = max(n + r, 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 - 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] = { "digital_qty": 0, diff --git a/engine/core/viewsets.py b/engine/core/viewsets.py index 11e25493..367d436f 100644 --- a/engine/core/viewsets.py +++ b/engine/core/viewsets.py @@ -44,7 +44,14 @@ from engine.core.docs.drf.viewsets import ( VENDOR_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 ( Address, Attribute, @@ -143,11 +150,18 @@ class EvibesViewSet(ModelViewSet): action_serializer_classes: dict[str, Type[Serializer]] = {} additional: dict[str, str] = {} permission_classes = [EvibesPermission] - renderer_classes = [CamelCaseJSONRenderer, MultiPartRenderer, XMLRenderer, YAMLRenderer] + renderer_classes = [ + CamelCaseJSONRenderer, + MultiPartRenderer, + XMLRenderer, + YAMLRenderer, + ] def get_serializer_class(self) -> Type[Serializer]: # 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) @@ -272,7 +286,11 @@ class CategoryViewSet(EvibesViewSet): title = f"{category.name} | {settings.PROJECT_NAME}" description = (category.description or "")[:180] 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 = { "title": title, @@ -286,10 +304,20 @@ class CategoryViewSet(EvibesViewSet): crumbs = [("Home", f"https://{settings.BASE_DOMAIN}/")] if category.get_ancestors().exists(): 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)) - 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 = [] qs = ( @@ -303,7 +331,9 @@ class CategoryViewSet(EvibesViewSet): .distinct()[:24] ) 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: json_ld.append(item_list_schema(product_urls)) @@ -443,7 +473,9 @@ class ProductViewSet(EvibesViewSet): "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] filterset_class = ProductFilter serializer_class = ProductDetailSerializer @@ -466,7 +498,9 @@ class ProductViewSet(EvibesViewSet): if self.request.user.has_perm("core.view_product"): 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 ( qs.filter( @@ -530,7 +564,9 @@ class ProductViewSet(EvibesViewSet): rating = {"value": p.rating, "count": p.feedbacks_count} title = f"{p.name} | {settings.PROJECT_NAME}" 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 = { "title": title, "description": description, @@ -543,7 +579,12 @@ class ProductViewSet(EvibesViewSet): crumbs = [("Home", f"https://{settings.BASE_DOMAIN}/")] if p.category: 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)) json_ld = [org_schema(), website_schema()] @@ -642,7 +683,9 @@ class OrderViewSet(EvibesViewSet): additional = {"retrieve": "ALLOW"} 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): qs = super().get_queryset() @@ -705,19 +748,34 @@ class OrderViewSet(EvibesViewSet): ) match str(type(instance)): case "": - return Response(status=status.HTTP_202_ACCEPTED, data=TransactionProcessSerializer(instance).data) + return Response( + status=status.HTTP_202_ACCEPTED, + data=TransactionProcessSerializer(instance).data, + ) case "": - return Response(status=status.HTTP_200_OK, data=OrderDetailSerializer(instance).data) + return Response( + status=status.HTTP_200_OK, + data=OrderDetailSerializer(instance).data, + ) 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: 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: return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": str(e)}) @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: serializer = BuyUnregisteredOrderSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -729,12 +787,21 @@ class OrderViewSet(EvibesViewSet): promocode_uuid=serializer.validated_data.get("promocode_uuid"), customer_name=serializer.validated_data.get("customer_name"), customer_email=serializer.validated_data.get("customer_email"), - customer_phone_number=serializer.validated_data.get("customer_phone_number"), - billing_customer_address=serializer.validated_data.get("billing_customer_address_uuid"), - shipping_customer_address=serializer.validated_data.get("shipping_customer_address_uuid"), + customer_phone_number=serializer.validated_data.get( + "customer_phone_number" + ), + 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"), ) - 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: 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) try: 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) order = order.add_product( 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: return Response(status=status.HTTP_404_NOT_FOUND, data={"detail": str(dne)}) 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") @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) try: 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) order = order.remove_product( 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: return Response(status=status.HTTP_404_NOT_FOUND, data={"detail": str(dne)}) 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") @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) try: 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) order = order.bulk_add_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: return Response(status=status.HTTP_404_NOT_FOUND, data={"detail": str(dne)}) 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") @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) try: 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) order = order.bulk_remove_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: return Response(status=status.HTTP_404_NOT_FOUND, data={"detail": str(dne)}) 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 @@ -856,7 +955,10 @@ class OrderProductViewSet(EvibesViewSet): order_product = OrderProduct.objects.get(uuid=str(kwargs.get("pk"))) if not order_product.order: 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) feedback = order_product.do_feedback( rating=serializer.validated_data.get("rating"), @@ -865,7 +967,10 @@ class OrderProductViewSet(EvibesViewSet): ) match serializer.validated_data.get("action"): 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": return Response(status=status.HTTP_204_NO_CONTENT) case _: @@ -889,11 +994,21 @@ class ProductImageViewSet(EvibesViewSet): @extend_schema_view(**PROMOCODE_SCHEMA) 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() 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 action_serializer_classes = { "list": PromoCodeSimpleSerializer, @@ -984,14 +1099,19 @@ class WishlistViewSet(EvibesViewSet): serializer.is_valid(raise_exception=True) try: 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) wishlist = wishlist.add_product( 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: return Response(status=status.HTTP_404_NOT_FOUND) @@ -1002,14 +1122,19 @@ class WishlistViewSet(EvibesViewSet): serializer.is_valid(raise_exception=True) try: 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) wishlist = wishlist.remove_product( 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: return Response(status=status.HTTP_404_NOT_FOUND) @@ -1020,32 +1145,44 @@ class WishlistViewSet(EvibesViewSet): serializer.is_valid(raise_exception=True) try: 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) wishlist = wishlist.bulk_add_products( 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: return Response(status=status.HTTP_404_NOT_FOUND) # noinspection PyUnusedLocal @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.is_valid(raise_exception=True) try: 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) wishlist = wishlist.bulk_remove_products( 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: return Response(status=status.HTTP_404_NOT_FOUND) @@ -1085,12 +1222,16 @@ class AddressViewSet(EvibesViewSet): def retrieve(self, request: Request, *args, **kwargs) -> Response: try: 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: return Response(status=status.HTTP_404_NOT_FOUND) 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) address_obj = create_serializer.create(create_serializer.validated_data) diff --git a/engine/core/widgets.py b/engine/core/widgets.py index f2359f48..eb484863 100644 --- a/engine/core/widgets.py +++ b/engine/core/widgets.py @@ -32,7 +32,10 @@ class JSONTableWidget(forms.Widget): return super().render(name, value, attrs, renderer) 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: json_data = {} diff --git a/engine/payments/docs/drf/views.py b/engine/payments/docs/drf/views.py index 4a8b3799..0dcaa2e6 100644 --- a/engine/payments/docs/drf/views.py +++ b/engine/payments/docs/drf/views.py @@ -3,7 +3,11 @@ from drf_spectacular.utils import extend_schema from rest_framework import status 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 = { "post": extend_schema( @@ -27,7 +31,9 @@ LIMITS_SCHEMA = { "payments", ], 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={ status.HTTP_200_OK: LimitsSerializer, status.HTTP_401_UNAUTHORIZED: error, diff --git a/engine/payments/docs/drf/viewsets.py b/engine/payments/docs/drf/viewsets.py index f52f89ee..a1abfe26 100644 --- a/engine/payments/docs/drf/viewsets.py +++ b/engine/payments/docs/drf/viewsets.py @@ -1,6 +1,6 @@ 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.utils import OpenApiParameter, extend_schema from rest_framework import status from engine.core.docs.drf import BASE_ERRORS diff --git a/engine/payments/graphene/mutations.py b/engine/payments/graphene/mutations.py index 23c540ce..42d71fb7 100644 --- a/engine/payments/graphene/mutations.py +++ b/engine/payments/graphene/mutations.py @@ -16,7 +16,9 @@ class Deposit(BaseMutation): def mutate(self, info, amount): if info.context.user.is_authenticated: 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 return Deposit(transaction=transaction) diff --git a/engine/payments/managers.py b/engine/payments/managers.py index 14e2ffbb..80148a1e 100644 --- a/engine/payments/managers.py +++ b/engine/payments/managers.py @@ -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.utils.timezone import now @@ -8,9 +18,18 @@ class GatewayQuerySet(QuerySet): today = now().date() current_month_start = today.replace(day=1) 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( - 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), ), ) diff --git a/engine/payments/migrations/0001_initial.py b/engine/payments/migrations/0001_initial.py index 388b1f81..db0d32e5 100644 --- a/engine/payments/migrations/0001_initial.py +++ b/engine/payments/migrations/0001_initial.py @@ -43,7 +43,9 @@ class Migration(migrations.Migration): ( "modified", 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)), @@ -86,13 +88,18 @@ class Migration(migrations.Migration): ( "modified", 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()), ("currency", models.CharField(max_length=3)), ("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={ "verbose_name": "transaction", diff --git a/engine/payments/migrations/0002_initial.py b/engine/payments/migrations/0002_initial.py index 434a1fcf..3e0412bb 100644 --- a/engine/payments/migrations/0002_initial.py +++ b/engine/payments/migrations/0002_initial.py @@ -28,7 +28,9 @@ class Migration(migrations.Migration): migrations.AddField( model_name="transaction", 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( model_name="transaction", @@ -44,6 +46,8 @@ class Migration(migrations.Migration): ), migrations.AddIndex( 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" + ), ), ] diff --git a/engine/payments/migrations/0005_gateway_transaction_gateway.py b/engine/payments/migrations/0005_gateway_transaction_gateway.py index ae4f7775..207fb723 100644 --- a/engine/payments/migrations/0005_gateway_transaction_gateway.py +++ b/engine/payments/migrations/0005_gateway_transaction_gateway.py @@ -1,6 +1,7 @@ +import uuid + import django.db.models.deletion import django_extensions.db.fields -import uuid from django.db import migrations, models @@ -95,11 +96,15 @@ class Migration(migrations.Migration): ), ( "minimum_transaction_amount", - models.FloatField(default=0, verbose_name="minimum transaction amount"), + models.FloatField( + default=0, verbose_name="minimum 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", @@ -119,11 +124,15 @@ class Migration(migrations.Migration): ), ( "priority", - models.PositiveIntegerField(default=10, unique=True, verbose_name="priority"), + models.PositiveIntegerField( + default=10, unique=True, verbose_name="priority" + ), ), ( "integration_variables", - models.JSONField(default=dict, verbose_name="integration variables"), + models.JSONField( + default=dict, verbose_name="integration variables" + ), ), ], options={ diff --git a/engine/payments/migrations/0006_transaction_payments_tr_created_95e595_idx.py b/engine/payments/migrations/0006_transaction_payments_tr_created_95e595_idx.py index 97213d3d..dbda82af 100644 --- a/engine/payments/migrations/0006_transaction_payments_tr_created_95e595_idx.py +++ b/engine/payments/migrations/0006_transaction_payments_tr_created_95e595_idx.py @@ -10,6 +10,8 @@ class Migration(migrations.Migration): operations = [ migrations.AddIndex( 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" + ), ), ] diff --git a/engine/payments/models.py b/engine/payments/models.py index e7ba8c6b..aafed268 100644 --- a/engine/payments/models.py +++ b/engine/payments/models.py @@ -25,7 +25,13 @@ from evibes.utils.misc import create_object class Transaction(NiceModel): 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) payment_method = CharField(max_length=20, null=True, blank=True) order = ForeignKey( @@ -37,7 +43,13 @@ class Transaction(NiceModel): related_name="payments_transactions", ) 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): return ( @@ -64,7 +76,11 @@ class Transaction(NiceModel): class Balance(NiceModel): amount = FloatField(null=False, blank=False, default=0) 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"] @@ -122,8 +138,12 @@ class Gateway(NiceModel): verbose_name=_("monthly 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) - integration_variables = JSONField(null=False, blank=False, default=dict, verbose_name=_("integration variables")) + priority = PositiveIntegerField( + 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): return self.name @@ -140,18 +160,25 @@ class Gateway(NiceModel): today = timezone.localdate() 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: next_month_date = today.replace(year=today.year + 1, month=1, day=1) else: next_month_date = today.replace(month=today.month + 1, day=1) 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 = ( - self.transactions.filter(created__gte=month_start, created__lt=month_end).aggregate(total=Sum("amount"))[ - "total" - ] + self.transactions.filter( + created__gte=month_start, created__lt=month_end + ).aggregate(total=Sum("amount"))["total"] or 0 ) @@ -163,7 +190,9 @@ class Gateway(NiceModel): def can_be_used(self, value: bool): 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 raise_exc: raise ValueError(_("gateway integration path is not set")) @@ -171,5 +200,8 @@ class Gateway(NiceModel): try: module_name, class_name = self.integration_path.rsplit(".", 1) 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) diff --git a/engine/payments/signals.py b/engine/payments/signals.py index 45644bc5..32a55b8a 100644 --- a/engine/payments/signals.py +++ b/engine/payments/signals.py @@ -16,14 +16,18 @@ logger = logging.getLogger(__name__) # noinspection PyUnusedLocal @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: Balance.objects.create(user=instance) # noinspection PyUnusedLocal @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 not instance.gateway: 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: 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: status = str(instance.process.get("status", "")).lower() success = instance.process.get("success", False) diff --git a/engine/payments/urls.py b/engine/payments/urls.py index d4db799d..4879e9b8 100644 --- a/engine/payments/urls.py +++ b/engine/payments/urls.py @@ -7,7 +7,9 @@ from engine.payments.viewsets import TransactionViewSet app_name = "payments" payment_router = DefaultRouter() -payment_router.register(prefix=r"transactions", viewset=TransactionViewSet, basename="transactions") +payment_router.register( + prefix=r"transactions", viewset=TransactionViewSet, basename="transactions" +) urlpatterns = [ path(r"", include(payment_router.urls)), diff --git a/engine/payments/utils/currencies.py b/engine/payments/utils/currencies.py index f5f907f1..7f836357 100644 --- a/engine/payments/utils/currencies.py +++ b/engine/payments/utils/currencies.py @@ -8,12 +8,16 @@ from django.core.cache import cache 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!") rates = cache.get("rates", None) 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") cache.set("rates", rates, 60 * 60 * 24) diff --git a/engine/payments/utils/emailing.py b/engine/payments/utils/emailing.py index 08e35089..a75c7c1d 100644 --- a/engine/payments/utils/emailing.py +++ b/engine/payments/utils/emailing.py @@ -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}" 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) diff --git a/engine/payments/utils/gateways.py b/engine/payments/utils/gateways.py index 60278e74..65ccfd54 100644 --- a/engine/payments/utils/gateways.py +++ b/engine/payments/utils/gateways.py @@ -1,13 +1,17 @@ from typing import Type -from evibes.utils.misc import create_object from engine.payments.gateways import AbstractGateway from engine.payments.models import Gateway +from evibes.utils.misc import create_object def get_gateways_integrations(name: str | None = None) -> 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: if gateway.integration_path: 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]: - 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) if not qs.exists(): 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) max_limit = float(agg.get("max_limit") or 0.0) diff --git a/engine/payments/views.py b/engine/payments/views.py index f1843b28..d63fff22 100644 --- a/engine/payments/views.py +++ b/engine/payments/views.py @@ -12,7 +12,11 @@ from rest_framework.views import APIView from engine.payments.docs.drf.views import DEPOSIT_SCHEMA, LIMITS_SCHEMA from engine.payments.gateways import UnknownGatewayError 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 logger = logging.getLogger(__name__) @@ -28,7 +32,9 @@ class DepositView(APIView): "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__) serializer = DepositSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -38,10 +44,15 @@ class DepositView(APIView): # noinspection PyUnresolvedReferences 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) @@ -54,19 +65,28 @@ class CallbackAPIView(APIView): "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: transaction = Transaction.objects.get(uuid=str(kwargs.get("uuid"))) if not transaction.gateway: - raise UnknownGatewayError(_(f"Transaction {transaction.uuid} has no gateway")) - gateway_integration = transaction.gateway.get_integration_class_object(raise_exc=True) + raise UnknownGatewayError( + _(f"Transaction {transaction.uuid} has no gateway") + ) + gateway_integration = transaction.gateway.get_integration_class_object( + raise_exc=True + ) 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) return Response(status=status.HTTP_202_ACCEPTED) except Exception as e: 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." ) - 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() data = {"min_amount": min_amount, "max_amount": max_amount} return Response(LimitsSerializer(data).data, status=status.HTTP_200_OK) diff --git a/engine/payments/viewsets.py b/engine/payments/viewsets.py index 61a2f0bb..585ee39e 100644 --- a/engine/payments/viewsets.py +++ b/engine/payments/viewsets.py @@ -3,9 +3,9 @@ from drf_spectacular.utils import extend_schema_view from rest_framework.viewsets import ReadOnlyModelViewSet 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.models import Transaction +from engine.payments.serializers import TransactionSerializer @extend_schema_view(**TRANSACTION_SCHEMA) diff --git a/engine/vibes_auth/admin.py b/engine/vibes_auth/admin.py index 2900440f..8e393a30 100644 --- a/engine/vibes_auth/admin.py +++ b/engine/vibes_auth/admin.py @@ -27,6 +27,7 @@ from rest_framework_simplejwt.token_blacklist.models import ( OutstandingToken as BaseOutstandingToken, ) from unfold.admin import ModelAdmin, TabularInline +from unfold.forms import AdminPasswordChangeForm, UserCreationForm from engine.core.admin import ActivationActionsMixin from engine.core.models import Order @@ -41,7 +42,6 @@ from engine.vibes_auth.models import ( ThreadStatus, User, ) -from unfold.forms import AdminPasswordChangeForm, UserCreationForm 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]: 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( "user_permissions", 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: 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!")) super().save_model(request, obj, form, change) diff --git a/engine/vibes_auth/docs/drf/messaging.py b/engine/vibes_auth/docs/drf/messaging.py index 8fe89f4f..fcaf156f 100644 --- a/engine/vibes_auth/docs/drf/messaging.py +++ b/engine/vibes_auth/docs/drf/messaging.py @@ -15,7 +15,9 @@ USER_MESSAGE_CONSUMER_SCHEMA = { ], "type": "send", "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, "responses": UserMessageResponseSerializer, } @@ -26,7 +28,9 @@ STAFF_INBOX_CONSUMER_SCHEMA = { ], "type": "send", "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, "responses": StaffInboxEventSerializer, } diff --git a/engine/vibes_auth/docs/drf/viewsets.py b/engine/vibes_auth/docs/drf/viewsets.py index 79240f00..2362fafa 100644 --- a/engine/vibes_auth/docs/drf/viewsets.py +++ b/engine/vibes_auth/docs/drf/viewsets.py @@ -96,7 +96,11 @@ USER_SCHEMA = { request=ActivateEmailSerializer, responses={ 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, }, ), diff --git a/engine/vibes_auth/graphene/mutations.py b/engine/vibes_auth/graphene/mutations.py index eac24e6d..9b914c71 100644 --- a/engine/vibes_auth/graphene/mutations.py +++ b/engine/vibes_auth/graphene/mutations.py @@ -41,7 +41,12 @@ class CreateUser(BaseMutation): phone_number = String() is_subscribed = Boolean() 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() @@ -71,7 +76,9 @@ class CreateUser(BaseMutation): phone_number=phone_number, is_subscribed=is_subscribed if is_subscribed else False, 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 return CreateUser(success=True) @@ -108,20 +115,28 @@ class UpdateUser(BaseMutation): try: 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) email = kwargs.get("email") - if (email is not None and not is_valid_email(email)) or User.objects.filter(email=email).exclude( - uuid=uuid - ).exists(): + if (email is not None and not is_valid_email(email)) or User.objects.filter( + email=email + ).exclude(uuid=uuid).exists(): raise BadRequest(_("malformed email")) phone_number = kwargs.get("phone_number") - if (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 + if ( + 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}")) @@ -131,7 +146,9 @@ class UpdateUser(BaseMutation): if password: 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.save() @@ -145,12 +162,16 @@ class UpdateUser(BaseMutation): user.attributes = {} user.attributes.update({attr: value}) else: - raise BadRequest(_(f"Invalid attribute format: {attribute_pair}")) + raise BadRequest( + _(f"Invalid attribute format: {attribute_pair}") + ) for attr, value in kwargs.items(): if attr == "password" or attr == "confirm_password": 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) user.save() @@ -185,7 +206,9 @@ class DeleteUser(BaseMutation): # noinspection PyTypeChecker return DeleteUser(success=True) 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) @@ -199,7 +222,9 @@ class ObtainJSONWebToken(BaseMutation): access_token = String(required=True) 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: serializer.is_valid(raise_exception=True) return ObtainJSONWebToken( @@ -220,7 +245,9 @@ class RefreshJSONWebToken(BaseMutation): refresh_token = String() 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: serializer.is_valid(raise_exception=True) return RefreshJSONWebToken( @@ -246,7 +273,8 @@ class VerifyJSONWebToken(BaseMutation): serializer.is_valid(raise_exception=True) # noinspection PyTypeChecker 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 "" # noinspection PyTypeChecker @@ -332,7 +360,13 @@ class ConfirmResetPassword(BaseMutation): # noinspection PyTypeChecker 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 diff --git a/engine/vibes_auth/graphene/object_types.py b/engine/vibes_auth/graphene/object_types.py index 2916df54..5c80c50d 100644 --- a/engine/vibes_auth/graphene/object_types.py +++ b/engine/vibes_auth/graphene/object_types.py @@ -6,7 +6,12 @@ from graphene.types.generic import GenericScalar from graphene_django import DjangoObjectType 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.payments.graphene.object_types import BalanceType from engine.payments.models import Balance @@ -37,19 +42,29 @@ class RecentProductConnection(relay.Connection): class UserType(DjangoObjectType): recently_viewed = relay.ConnectionField( RecentProductConnection, - description=_("the products this user has viewed most recently (max 48), in reverse‐chronological order"), + description=_( + "the products this user has viewed most recently (max 48), in reverse‐chronological order" + ), ) groups = List(lambda: GroupType, description=_("groups")) user_permissions = List(lambda: PermissionType, description=_("permissions")) orders = List(lambda: OrderType, description=_("orders")) wishlist = Field(lambda: WishlistType, description=_("wishlist")) - balance = Field(lambda: BalanceType, source="payments_balance", description=_("balance")) - avatar = String(description=_("avatar")) - 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}") + balance = Field( + lambda: BalanceType, source="payments_balance", description=_("balance") + ) + avatar = String(description=_("avatar")) + 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: model = User @@ -123,7 +138,9 @@ class UserType(DjangoObjectType): 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) diff --git a/engine/vibes_auth/managers.py b/engine/vibes_auth/managers.py index 279157c4..d9803225 100644 --- a/engine/vibes_auth/managers.py +++ b/engine/vibes_auth/managers.py @@ -27,7 +27,9 @@ class UserManager(BaseUserManager): if order.attributes.get("is_business"): mark_business = True 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: order.user = user order.save() @@ -80,7 +82,9 @@ class UserManager(BaseUserManager): return user # 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: # noinspection PyCallingNonCallable backends = auth._get_backends(return_tuples=True) @@ -92,7 +96,9 @@ class UserManager(BaseUserManager): "therefore must provide the `backend` argument." ) 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: backend = auth.load_backend(backend) if hasattr(backend, "with_perm"): diff --git a/engine/vibes_auth/messaging/consumers.py b/engine/vibes_auth/messaging/consumers.py index 1b9ac9f1..a04bb0fc 100644 --- a/engine/vibes_auth/messaging/consumers.py +++ b/engine/vibes_auth/messaging/consumers.py @@ -36,14 +36,22 @@ def _get_ip(scope) -> str: 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 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: 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: return thread 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: - 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: - 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: return t return get_or_create_user_thread(user) @@ -95,11 +111,15 @@ class UserMessageConsumer(AsyncJsonWebsocketConsumer): return 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)( 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, text=text, ) @@ -139,7 +159,9 @@ class StaffInboxConsumer(AsyncJsonWebsocketConsumer): def _list(): qs = ( 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") ) return list(qs) @@ -160,7 +182,13 @@ class StaffInboxConsumer(AsyncJsonWebsocketConsumer): try: 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 await self.send_json({"error": "assign_failed", "detail": str(e)}) return @@ -168,15 +196,25 @@ class StaffInboxConsumer(AsyncJsonWebsocketConsumer): if action == "reply": thread_id = content.get("thread_id") 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"}) return def _can_reply_and_send(): 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") - 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: msg = await sync_to_async(_can_reply_and_send)() @@ -205,16 +243,36 @@ class StaffInboxConsumer(AsyncJsonWebsocketConsumer): await self.send_json({"error": "unknown_action"}) 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): - 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): - 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): - 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): @@ -226,12 +284,16 @@ class ThreadConsumer(AsyncJsonWebsocketConsumer): await self.close(code=4403) return 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() async def disconnect(self, code: int) -> None: 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) async def receive_json(self, content: dict[str, Any], **kwargs) -> None: @@ -239,7 +301,9 @@ class ThreadConsumer(AsyncJsonWebsocketConsumer): user: User = self.scope.get("user") 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 if action == "reply": @@ -250,9 +314,15 @@ class ThreadConsumer(AsyncJsonWebsocketConsumer): def _reply(): 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") - 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: 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}) 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): - 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 diff --git a/engine/vibes_auth/messaging/forwarders/telegram.py b/engine/vibes_auth/messaging/forwarders/telegram.py index 84f91c14..a9f1ae41 100644 --- a/engine/vibes_auth/messaging/forwarders/telegram.py +++ b/engine/vibes_auth/messaging/forwarders/telegram.py @@ -93,7 +93,9 @@ def build_router() -> Router | None: @router.message() 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: return @@ -101,7 +103,9 @@ def build_router() -> Router | None: def _resolve_staff_and_command(): 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] return None, None, None # group check @@ -130,7 +134,9 @@ def build_router() -> Router | None: t, body = payload 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 message.answer("Sent.") @@ -178,7 +184,9 @@ async def forward_thread_message_to_assigned_staff(thread_uuid: str, text: str) try: await bot.send_message(chat_id=chat_id, text=text) 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: @@ -192,5 +200,7 @@ def install_aiohttp_webhook(app) -> None: bot = _get_bot() if not bot: 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.") diff --git a/engine/vibes_auth/messaging/serializers.py b/engine/vibes_auth/messaging/serializers.py index 543b4393..92f27a43 100644 --- a/engine/vibes_auth/messaging/serializers.py +++ b/engine/vibes_auth/messaging/serializers.py @@ -1,4 +1,4 @@ -from rest_framework.fields import CharField, BooleanField, IntegerField, ListField +from rest_framework.fields import BooleanField, CharField, IntegerField, ListField from rest_framework.serializers import Serializer diff --git a/engine/vibes_auth/messaging/services.py b/engine/vibes_auth/messaging/services.py index 3711161d..91427d9c 100644 --- a/engine/vibes_auth/messaging/services.py +++ b/engine/vibes_auth/messaging/services.py @@ -40,19 +40,27 @@ def _broadcast_staff_inbox(event: dict) -> None: def get_or_create_user_thread(user: User) -> ChatThread: - thread, _ = ChatThread.objects.get_or_create(user=user, defaults={"email": user.email or ""}) + thread, _ = ChatThread.objects.get_or_create( + user=user, defaults={"email": user.email or ""} + ) return thread def create_anon_thread(email: str) -> ChatThread: if not email or "@" not in email: - raise ValidationError({"email": _("Valid email is required for anonymous chats.")}) + raise ValidationError( + {"email": _("Valid email is required for anonymous chats.")} + ) thread = ChatThread.objects.create(email=email) - _broadcast_staff_inbox({"type": "staff.thread.created", "thread_id": str(thread.uuid)}) + _broadcast_staff_inbox( + {"type": "staff.thread.created", "thread_id": str(thread.uuid)} + ) return thread -def send_message(thread: ChatThread, *, sender_user: User | None, sender_type: SenderType, text: str) -> ChatMessage: +def send_message( + thread: ChatThread, *, sender_user: User | None, sender_type: SenderType, text: str +) -> ChatMessage: if not text or len(text) > 1028: raise ValidationError({"text": _("Message must be 1..1028 characters.")}) if sender_user and not sender_user.is_staff: @@ -61,7 +69,9 @@ def send_message(thread: ChatThread, *, sender_user: User | None, sender_type: S msg = ChatMessage.objects.create( thread=thread, sender_type=sender_type, - sender_user=sender_user if sender_user and sender_user.is_authenticated else None, + sender_user=sender_user + if sender_user and sender_user.is_authenticated + else None, text=text, sent_at=timezone.now(), ) @@ -84,7 +94,9 @@ def send_message(thread: ChatThread, *, sender_user: User | None, sender_type: S ) if sender_type != SenderType.STAFF: if is_telegram_enabled(): - async_to_sync(forward_thread_message_to_assigned_staff)(str(thread.uuid), text) + async_to_sync(forward_thread_message_to_assigned_staff)( + str(thread.uuid), text + ) auto_reply(thread) return msg @@ -124,7 +136,11 @@ def claim_thread(thread: ChatThread, staff_user: User) -> ChatThread: thread.assigned_to = staff_user thread.save(update_fields=["assigned_to", "modified"]) _broadcast_staff_inbox( - {"type": "staff.thread.assigned", "thread_id": str(thread.uuid), "user": str(staff_user.uuid)} + { + "type": "staff.thread.assigned", + "thread_id": str(thread.uuid), + "user": str(staff_user.uuid), + } ) return thread @@ -137,7 +153,11 @@ def reassign_thread(thread: ChatThread, superuser: User, new_staff: User) -> Cha thread.assigned_to = new_staff thread.save(update_fields=["assigned_to", "modified"]) _broadcast_staff_inbox( - {"type": "staff.thread.reassigned", "thread_id": str(thread.uuid), "user": str(new_staff.uuid)} + { + "type": "staff.thread.reassigned", + "thread_id": str(thread.uuid), + "user": str(new_staff.uuid), + } ) return thread @@ -149,8 +169,12 @@ def close_thread(thread: ChatThread, actor: User | None) -> ChatThread: raise PermissionDenied thread.status = ThreadStatus.CLOSED thread.save(update_fields=["status", "modified"]) - _broadcast_staff_inbox({"type": "staff.thread.closed", "thread_id": str(thread.uuid)}) - _broadcast_thread_event(thread, {"type": "thread.closed", "thread_id": str(thread.uuid)}) + _broadcast_staff_inbox( + {"type": "staff.thread.closed", "thread_id": str(thread.uuid)} + ) + _broadcast_thread_event( + thread, {"type": "thread.closed", "thread_id": str(thread.uuid)} + ) return thread @@ -159,7 +183,8 @@ def find_least_load_staff() -> User | None: User.objects.filter(is_staff=True, is_active=True) .annotate( open_threads=Count( - "assigned_chat_threads", filter=models.Q(assigned_chat_threads__status=ThreadStatus.OPEN) + "assigned_chat_threads", + filter=models.Q(assigned_chat_threads__status=ThreadStatus.OPEN), ) ) .order_by("open_threads", "date_joined") diff --git a/engine/vibes_auth/messaging/urls.py b/engine/vibes_auth/messaging/urls.py index 428d18c3..5fadbaad 100644 --- a/engine/vibes_auth/messaging/urls.py +++ b/engine/vibes_auth/messaging/urls.py @@ -1,13 +1,20 @@ from django.conf import settings from django.urls import re_path -from engine.vibes_auth.messaging.consumers import StaffInboxConsumer, ThreadConsumer, UserMessageConsumer +from engine.vibes_auth.messaging.consumers import ( + StaffInboxConsumer, + ThreadConsumer, + UserMessageConsumer, +) messaging_urlpatters = ( [ re_path(r"^ws/chat/message$", UserMessageConsumer.as_asgi()), re_path(r"^ws/chat/staff/$", StaffInboxConsumer.as_asgi()), - re_path(r"^ws/chat/thread/(?P[0-9a-fA-F-]{36})/$", ThreadConsumer.as_asgi()), + re_path( + r"^ws/chat/thread/(?P[0-9a-fA-F-]{36})/$", + ThreadConsumer.as_asgi(), + ), ] if settings.ALLOW_MESSAGING else [] diff --git a/engine/vibes_auth/migrations/0001_initial.py b/engine/vibes_auth/migrations/0001_initial.py index a746b524..5a2de515 100644 --- a/engine/vibes_auth/migrations/0001_initial.py +++ b/engine/vibes_auth/migrations/0001_initial.py @@ -38,7 +38,12 @@ class Migration(migrations.Migration): name="User", fields=[ ("password", models.CharField(max_length=128, verbose_name="password")), - ("last_login", models.DateTimeField(blank=True, null=True, verbose_name="last login")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), ( "is_superuser", models.BooleanField( @@ -55,7 +60,12 @@ class Migration(migrations.Migration): verbose_name="staff status", ), ), - ("date_joined", models.DateTimeField(default=django.utils.timezone.now, verbose_name="date joined")), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), ( "uuid", models.UUIDField( @@ -78,13 +88,18 @@ class Migration(migrations.Migration): ( "modified", 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", ), ), ( "email", models.EmailField( - help_text="user email address", max_length=254, unique=True, verbose_name="email" + help_text="user email address", + max_length=254, + unique=True, + verbose_name="email", ), ), ( @@ -99,8 +114,18 @@ class Migration(migrations.Migration): verbose_name="phone_number", ), ), - ("first_name", models.CharField(blank=True, max_length=150, null=True, verbose_name="first_name")), - ("last_name", models.CharField(blank=True, max_length=150, null=True, verbose_name="last_name")), + ( + "first_name", + models.CharField( + blank=True, max_length=150, null=True, verbose_name="first_name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, null=True, verbose_name="last_name" + ), + ), ( "avatar", models.ImageField( @@ -114,22 +139,33 @@ class Migration(migrations.Migration): ( "is_verified", models.BooleanField( - default=False, help_text="user verification status", verbose_name="is verified" + default=False, + help_text="user verification status", + verbose_name="is verified", ), ), ( "is_active", models.BooleanField( - default=False, help_text="unselect this instead of deleting accounts", verbose_name="is_active" + default=False, + help_text="unselect this instead of deleting accounts", + verbose_name="is_active", ), ), ( "is_subscribed", models.BooleanField( - default=False, help_text="user's newsletter subscription status", verbose_name="is_subscribed" + default=False, + help_text="user's newsletter subscription status", + verbose_name="is_subscribed", + ), + ), + ( + "activation_token", + models.UUIDField( + default=uuid.uuid4, verbose_name="activation token" ), ), - ("activation_token", models.UUIDField(default=uuid.uuid4, verbose_name="activation token")), ( "language", models.CharField( @@ -157,7 +193,12 @@ class Migration(migrations.Migration): max_length=7, ), ), - ("attributes", models.JSONField(blank=True, default=dict, null=True, verbose_name="attributes")), + ( + "attributes", + models.JSONField( + blank=True, default=dict, null=True, verbose_name="attributes" + ), + ), ( "groups", models.ManyToManyField( diff --git a/engine/vibes_auth/migrations/0006_chatthread_chatmessage_and_more.py b/engine/vibes_auth/migrations/0006_chatthread_chatmessage_and_more.py index c5d3adb2..fc09ed4f 100644 --- a/engine/vibes_auth/migrations/0006_chatthread_chatmessage_and_more.py +++ b/engine/vibes_auth/migrations/0006_chatthread_chatmessage_and_more.py @@ -1,9 +1,10 @@ # Generated by Django 5.2.8 on 2025-11-11 15:28 +import uuid + import django.db.models.deletion import django.utils.timezone import django_extensions.db.fields -import uuid from django.conf import settings from django.db import migrations, models @@ -47,15 +48,32 @@ class Migration(migrations.Migration): ( "modified", 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", + ), + ), + ( + "email", + models.EmailField( + blank=True, + default="", + help_text="For anonymous threads", + max_length=254, ), ), - ("email", models.EmailField(blank=True, default="", help_text="For anonymous threads", max_length=254)), ( "status", - models.CharField(choices=[("open", "Open"), ("closed", "Closed")], default="open", max_length=16), + models.CharField( + choices=[("open", "Open"), ("closed", "Closed")], + default="open", + max_length=16, + ), + ), + ( + "last_message_at", + models.DateTimeField(default=django.utils.timezone.now), ), - ("last_message_at", models.DateTimeField(default=django.utils.timezone.now)), ("attributes", models.JSONField(blank=True, default=dict)), ( "assigned_to", @@ -117,13 +135,20 @@ class Migration(migrations.Migration): ( "modified", 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", ), ), ( "sender_type", models.CharField( - choices=[("user", "User"), ("staff", "Staff"), ("system", "System")], max_length=16 + choices=[ + ("user", "User"), + ("staff", "Staff"), + ("system", "System"), + ], + max_length=16, ), ), ("text", models.TextField()), @@ -142,7 +167,9 @@ class Migration(migrations.Migration): ( "thread", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, related_name="messages", to="vibes_auth.chatthread" + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + to="vibes_auth.chatthread", ), ), ], @@ -154,11 +181,15 @@ class Migration(migrations.Migration): ), migrations.AddIndex( model_name="chatthread", - index=models.Index(fields=["status", "modified"], name="chatthread_status_mod_idx"), + index=models.Index( + fields=["status", "modified"], name="chatthread_status_mod_idx" + ), ), migrations.AddIndex( model_name="chatthread", - index=models.Index(fields=["assigned_to", "status"], name="chatthread_assigned_status_idx"), + index=models.Index( + fields=["assigned_to", "status"], name="chatthread_assigned_status_idx" + ), ), migrations.AddIndex( model_name="chatthread", @@ -170,6 +201,8 @@ class Migration(migrations.Migration): ), migrations.AddIndex( model_name="chatmessage", - index=models.Index(fields=["thread", "sent_at"], name="chatmessage_thread_sent_idx"), + index=models.Index( + fields=["thread", "sent_at"], name="chatmessage_thread_sent_idx" + ), ), ] diff --git a/engine/vibes_auth/models.py b/engine/vibes_auth/models.py index 315cbacd..7156b0c6 100644 --- a/engine/vibes_auth/models.py +++ b/engine/vibes_auth/models.py @@ -84,14 +84,22 @@ class User(AbstractUser, NiceModel): # type: ignore [django-manager-missing] help_text=_("unselect this instead of deleting accounts"), ) is_subscribed = BooleanField( - verbose_name=_("is_subscribed"), help_text=_("user's newsletter subscription status"), default=False + verbose_name=_("is_subscribed"), + help_text=_("user's newsletter subscription status"), + default=False, ) activation_token = UUIDField(default=uuid4, verbose_name=_("activation token")) language = CharField( - choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, null=False, blank=False, max_length=7 + choices=settings.LANGUAGES, + default=settings.LANGUAGE_CODE, + null=False, + blank=False, + max_length=7, + ) + attributes = JSONField( + verbose_name=_("attributes"), default=dict, blank=True, null=True ) - attributes = JSONField(verbose_name=_("attributes"), default=dict, blank=True, null=True) payments_balance: "Balance" @@ -135,17 +143,29 @@ class User(AbstractUser, NiceModel): # type: ignore [django-manager-missing] class ChatThread(NiceModel): - user = ForeignKey(User, null=True, blank=True, on_delete=SET_NULL, related_name="chat_threads") + user = ForeignKey( + User, null=True, blank=True, on_delete=SET_NULL, related_name="chat_threads" + ) email = EmailField(blank=True, default="", help_text=_("For anonymous threads")) - assigned_to = ForeignKey(User, null=True, blank=True, on_delete=SET_NULL, related_name="assigned_chat_threads") - status = CharField(max_length=16, choices=ThreadStatus.choices, default=ThreadStatus.OPEN) + assigned_to = ForeignKey( + User, + null=True, + blank=True, + on_delete=SET_NULL, + related_name="assigned_chat_threads", + ) + status = CharField( + max_length=16, choices=ThreadStatus.choices, default=ThreadStatus.OPEN + ) last_message_at = DateTimeField(default=timezone.now) attributes = JSONField(default=dict, blank=True) class Meta: indexes = [ Index(fields=["status", "modified"], name="chatthread_status_mod_idx"), - Index(fields=["assigned_to", "status"], name="chatthread_assigned_status_idx"), + Index( + fields=["assigned_to", "status"], name="chatthread_assigned_status_idx" + ), Index(fields=["user"], name="chatthread_user_idx"), Index(fields=["email"], name="chatthread_email_idx"), ] @@ -156,7 +176,9 @@ class ChatThread(NiceModel): def clean(self) -> None: super().clean() if not self.user and not self.email: - raise ValidationError({"email": _("provide user or email for anonymous thread.")}) + raise ValidationError( + {"email": _("provide user or email for anonymous thread.")} + ) if self.assigned_to and not self.assigned_to.is_staff: raise ValidationError({"assigned_to": _("assignee must be a staff user.")}) @@ -164,7 +186,9 @@ class ChatThread(NiceModel): class ChatMessage(NiceModel): thread = ForeignKey(ChatThread, on_delete=CASCADE, related_name="messages") sender_type = CharField(max_length=16, choices=SenderType.choices) - sender_user = ForeignKey(User, null=True, blank=True, on_delete=SET_NULL, related_name="chat_messages") + sender_user = ForeignKey( + User, null=True, blank=True, on_delete=SET_NULL, related_name="chat_messages" + ) text = TextField() sent_at = DateTimeField(default=timezone.now) attributes = JSONField(default=dict, blank=True) diff --git a/engine/vibes_auth/serializers.py b/engine/vibes_auth/serializers.py index b2f9a0c7..1db4cc02 100644 --- a/engine/vibes_auth/serializers.py +++ b/engine/vibes_auth/serializers.py @@ -89,8 +89,14 @@ class UserSerializer(ModelSerializer): if "attributes" in attrs: if not isinstance(attrs["attributes"], dict): raise ValidationError(_("attributes must be a dictionary")) - if attrs["attributes"].get("is_business") and not attrs["attributes"].get("business_identificator"): - raise ValidationError(_("business identificator is required when registering as a business")) + if attrs["attributes"].get("is_business") and not attrs["attributes"].get( + "business_identificator" + ): + raise ValidationError( + _( + "business identificator is required when registering as a business" + ) + ) if "password" in attrs: validate_password(attrs["password"]) if not compare_digest(attrs["password"], attrs["confirm_password"]): @@ -102,13 +108,21 @@ class UserSerializer(ModelSerializer): if "phone_number" in attrs: validate_phone_number(attrs["phone_number"]) if type(self.instance) is User: - if User.objects.filter(phone_number=attrs["phone_number"]).exclude(uuid=self.instance.uuid).exists(): + if ( + User.objects.filter(phone_number=attrs["phone_number"]) + .exclude(uuid=self.instance.uuid) + .exists() + ): phone_number = attrs["phone_number"] raise ValidationError(_(f"malformed phone number: {phone_number}")) if "email" in attrs: validate_email(attrs["email"]) if type(self.instance) is User: - if User.objects.filter(email=attrs["email"]).exclude(uuid=self.instance.uuid).exists(): + if ( + User.objects.filter(email=attrs["email"]) + .exclude(uuid=self.instance.uuid) + .exists() + ): email = attrs["email"] raise ValidationError(_(f"malformed email: {email}")) @@ -122,7 +136,9 @@ class UserSerializer(ModelSerializer): """ # noinspection PyTypeChecker return ProductSimpleSerializer( - instance=Product.objects.filter(uuid__in=obj.recently_viewed, is_active=True), + instance=Product.objects.filter( + uuid__in=obj.recently_viewed, is_active=True + ), many=True, ).data @@ -185,7 +201,9 @@ class TokenObtainPairSerializer(TokenObtainSerializer): data["refresh"] = str(refresh) # noinspection PyUnresolvedReferences data["access"] = str(refresh.access_token) # type: ignore [attr-defined] - data["user"] = UserSerializer(self.user).data if self.retrieve_user else self.user.pk + data["user"] = ( + UserSerializer(self.user).data if self.retrieve_user else self.user.pk + ) if api_settings.UPDATE_LAST_LOGIN: if not self.user: diff --git a/engine/vibes_auth/tests/test_drf.py b/engine/vibes_auth/tests/test_drf.py index e015ce88..74dc7495 100644 --- a/engine/vibes_auth/tests/test_drf.py +++ b/engine/vibes_auth/tests/test_drf.py @@ -19,9 +19,13 @@ class DRFAuthViewsTests(TestCase): self.client = APIClient() def test_token_obtain_pair_success(self): - user = User.objects.create_user(email="user@example.com", password="Str0ngPass!word", is_active=True) + user = User.objects.create_user( + email="user@example.com", password="Str0ngPass!word", is_active=True + ) url = reverse("vibes_auth:token_create") - resp = self.client.post(url, {"email": user.email, "password": "Str0ngPass!word"}, format="json") + resp = self.client.post( + url, {"email": user.email, "password": "Str0ngPass!word"}, format="json" + ) self.assertEqual(resp.status_code, status.HTTP_200_OK) data = resp.json() self.assertIn("access", data, data) @@ -31,24 +35,36 @@ class DRFAuthViewsTests(TestCase): self.assertEqual(data["user"]["email"], user.email, data) def test_token_obtain_pair_invalid_credentials(self): - User.objects.create_user(email="user@example.com", password="Str0ngPass!word", is_active=True) + User.objects.create_user( + email="user@example.com", password="Str0ngPass!word", is_active=True + ) url = reverse("vibes_auth:token_create") - resp = self.client.post(url, {"email": "user@example.com", "password": "wrong"}, format="json") + resp = self.client.post( + url, {"email": "user@example.com", "password": "wrong"}, format="json" + ) self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) def test_token_obtain_ratelimited(self): url = reverse("vibes_auth:token_create") for _ in range(0, 10): - self.client.post(url, {"email": "user@example.com", "password": "wrong"}, format="json") - resp = self.client.post(url, {"email": "user@example.com", "password": "wrong"}, format="json") + self.client.post( + url, {"email": "user@example.com", "password": "wrong"}, format="json" + ) + resp = self.client.post( + url, {"email": "user@example.com", "password": "wrong"}, format="json" + ) self.assertEqual(resp.status_code, status.HTTP_429_TOO_MANY_REQUESTS) def test_token_refresh_and_verify_flow(self): - user = User.objects.create_user(email="user@example.com", password="Str0ngPass!word", is_active=True) + user = User.objects.create_user( + email="user@example.com", password="Str0ngPass!word", is_active=True + ) tokens = RefreshToken.for_user(user) refresh_url = reverse("vibes_auth:token_refresh") - resp_refresh = self.client.post(refresh_url, {"refresh": str(tokens)}, format="json") + resp_refresh = self.client.post( + refresh_url, {"refresh": str(tokens)}, format="json" + ) self.assertEqual(resp_refresh.status_code, status.HTTP_200_OK) access = resp_refresh.json()["access"] @@ -82,22 +98,30 @@ class DRFAuthViewsTests(TestCase): activate_url = reverse("vibes_auth:users-activate") uidb64 = urlsafe_b64encode(str(user.uuid).encode()).decode() token_b64 = urlsafe_b64encode(str(user.activation_token).encode()).decode() - resp_act = self.client.post(activate_url, {"uidb_64": uidb64, "token": token_b64}, format="json") + resp_act = self.client.post( + activate_url, {"uidb_64": uidb64, "token": token_b64}, format="json" + ) self.assertEqual(resp_act.status_code, status.HTTP_200_OK) user.refresh_from_db() self.assertTrue(user.is_active and user.is_verified) def test_reset_password_triggers_task(self): - user = User.objects.create_user(email="user@example.com", password="Str0ngPass!word", is_active=True) + user = User.objects.create_user( + email="user@example.com", password="Str0ngPass!word", is_active=True + ) - with patch("engine.vibes_auth.viewsets.send_reset_password_email_task.delay") as mocked_delay: + with patch( + "engine.vibes_auth.viewsets.send_reset_password_email_task.delay" + ) as mocked_delay: url = reverse("vibes_auth:users-reset-password") resp = self.client.post(url, {"email": user.email}, format="json") self.assertEqual(resp.status_code, status.HTTP_200_OK) mocked_delay.assert_called_once() def test_confirm_password_reset_success(self): - user = User.objects.create_user(email="user@example.com", password="OldPass!123", is_active=True) + user = User.objects.create_user( + email="user@example.com", password="OldPass!123", is_active=True + ) gen = PasswordResetTokenGenerator() token = gen.make_token(user) uidb64 = urlsafe_b64encode(str(user.uuid).encode()).decode() @@ -116,12 +140,18 @@ class DRFAuthViewsTests(TestCase): ) self.assertEqual(resp.status_code, status.HTTP_200_OK, resp.json()) obtain_url = reverse("vibes_auth:token_create") - r2 = self.client.post(obtain_url, {"email": user.email, "password": new_pass}, format="json") + r2 = self.client.post( + obtain_url, {"email": user.email, "password": new_pass}, format="json" + ) self.assertEqual(r2.status_code, status.HTTP_200_OK, resp.json()) def test_upload_avatar_permission_enforced(self): - owner = User.objects.create_user(email="owner@example.com", password="Str0ngPass!word", is_active=True) - stranger = User.objects.create_user(email="stranger@example.com", password="Str0ngPass!word", is_active=True) + owner = User.objects.create_user( + email="owner@example.com", password="Str0ngPass!word", is_active=True + ) + stranger = User.objects.create_user( + email="stranger@example.com", password="Str0ngPass!word", is_active=True + ) access = str(RefreshToken.for_user(stranger).access_token) # noinspection PyUnresolvedReferences @@ -129,13 +159,19 @@ class DRFAuthViewsTests(TestCase): url = reverse("vibes_auth:users-upload-avatar", kwargs={"pk": owner.pk}) file_content = BytesIO(b"fake image content") - file = SimpleUploadedFile("avatar.png", file_content.getvalue(), content_type="image/png") + file = SimpleUploadedFile( + "avatar.png", file_content.getvalue(), content_type="image/png" + ) resp = self.client.put(url, {"avatar": file}) self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) def test_merge_recently_viewed_permission_enforced(self): - owner = User.objects.create_user(email="owner@example.com", password="Str0ngPass!word", is_active=True) - stranger = User.objects.create_user(email="stranger@example.com", password="Str0ngPass!word", is_active=True) + owner = User.objects.create_user( + email="owner@example.com", password="Str0ngPass!word", is_active=True + ) + stranger = User.objects.create_user( + email="stranger@example.com", password="Str0ngPass!word", is_active=True + ) access = str(RefreshToken.for_user(stranger).access_token) # noinspection PyUnresolvedReferences diff --git a/engine/vibes_auth/tests/test_graphene.py b/engine/vibes_auth/tests/test_graphene.py index a25b7db6..98f12667 100644 --- a/engine/vibes_auth/tests/test_graphene.py +++ b/engine/vibes_auth/tests/test_graphene.py @@ -18,7 +18,9 @@ class GraphQLAuthTests(TestCase): return response.json() def test_obtain_refresh_verify_jwt_via_graphql(self): - user = User.objects.create_user(email="user@example.com", password="Str0ngPass!word", is_active=True) + user = User.objects.create_user( + email="user@example.com", password="Str0ngPass!word", is_active=True + ) data = self.graphql( """ diff --git a/engine/vibes_auth/tests/test_messaging.py b/engine/vibes_auth/tests/test_messaging.py index 3b450fce..27263bf3 100644 --- a/engine/vibes_auth/tests/test_messaging.py +++ b/engine/vibes_auth/tests/test_messaging.py @@ -4,8 +4,8 @@ from unittest.mock import patch from django.contrib.auth.models import AnonymousUser from django.test import TestCase -from engine.vibes_auth.models import User from engine.vibes_auth.messaging import auth as auth_module +from engine.vibes_auth.models import User class MessagingTests(TestCase): @@ -23,7 +23,10 @@ class MessagingTests(TestCase): captured = {} async def inner_app(scope_dict, _receive, _send): - captured["is_anon"] = isinstance(scope_dict["user"], AnonymousUser) or scope_dict["user"].is_anonymous + captured["is_anon"] = ( + isinstance(scope_dict["user"], AnonymousUser) + or scope_dict["user"].is_anonymous + ) middleware = auth_module.JWTAuthMiddleware(inner_app) @@ -39,7 +42,9 @@ class MessagingTests(TestCase): self.assertTrue(captured.get("is_anon")) def test_jwt_middleware_sets_user_with_valid_token(self): - user = User.objects.create_user(email="user@example.com", password="Str0ngPass!word") + user = User.objects.create_user( + email="user@example.com", password="Str0ngPass!word" + ) class FakeAuth: def authenticate(self, _request): @@ -71,7 +76,10 @@ class MessagingTests(TestCase): captured = {} async def inner_app(scope_dict, _receive, _send): - captured["is_anon"] = isinstance(scope_dict["user"], AnonymousUser) or scope_dict["user"].is_anonymous + captured["is_anon"] = ( + isinstance(scope_dict["user"], AnonymousUser) + or scope_dict["user"].is_anonymous + ) middleware = auth_module.JWTAuthMiddleware(inner_app) scope = {"type": "websocket", "subprotocols": ["bearer", "bad.token"]} diff --git a/engine/vibes_auth/urls.py b/engine/vibes_auth/urls.py index 9cae352f..fda031f4 100644 --- a/engine/vibes_auth/urls.py +++ b/engine/vibes_auth/urls.py @@ -2,7 +2,11 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter from engine.vibes_auth.messaging.urls import messaging_urlpatters -from engine.vibes_auth.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView +from engine.vibes_auth.views import ( + TokenObtainPairView, + TokenRefreshView, + TokenVerifyView, +) from engine.vibes_auth.viewsets import UserViewSet app_name = "vibes_auth" diff --git a/engine/vibes_auth/validators.py b/engine/vibes_auth/validators.py index 300cf8ca..ee018325 100644 --- a/engine/vibes_auth/validators.py +++ b/engine/vibes_auth/validators.py @@ -6,7 +6,9 @@ from django.utils.translation import gettext_lazy as _ def validate_phone_number(value: str) -> None: - phone_regex = re.compile(r"^\+?1?\d{9,15}$") # The regex pattern to match valid phone numbers + phone_regex = re.compile( + r"^\+?1?\d{9,15}$" + ) # The regex pattern to match valid phone numbers if not phone_regex.match(value): raise ValidationError( _( diff --git a/engine/vibes_auth/views.py b/engine/vibes_auth/views.py index 71631b7f..b6dc60b8 100644 --- a/engine/vibes_auth/views.py +++ b/engine/vibes_auth/views.py @@ -14,7 +14,11 @@ from rest_framework.response import Response from rest_framework_simplejwt.exceptions import TokenError from rest_framework_simplejwt.views import TokenViewBase -from engine.vibes_auth.docs.drf.views import TOKEN_OBTAIN_SCHEMA, TOKEN_REFRESH_SCHEMA, TOKEN_VERIFY_SCHEMA +from engine.vibes_auth.docs.drf.views import ( + TOKEN_OBTAIN_SCHEMA, + TOKEN_REFRESH_SCHEMA, + TOKEN_VERIFY_SCHEMA, +) from engine.vibes_auth.serializers import ( TokenObtainPairSerializer, TokenRefreshSerializer, @@ -42,7 +46,9 @@ class TokenObtainPairView(TokenViewBase): authentication_classes: list[str] = [] # type: ignore [assignment] @method_decorator(ratelimit(key="ip", rate="10/h")) - 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: return super().post(request, *args, **kwargs) @@ -65,7 +71,9 @@ class TokenRefreshView(TokenViewBase): authentication_classes: list[str] = [] # type: ignore [assignment] @method_decorator(ratelimit(key="ip", rate="10/h")) - 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: return super().post(request, *args, **kwargs) @@ -82,11 +90,16 @@ class TokenVerifyView(TokenViewBase): ] authentication_classes: list[str] = [] # type: ignore [assignment] - 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: serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user_data = serializer.validated_data.pop("user", None) return Response({"token": _("the token is valid"), "user": user_data}) except TokenError: - return Response({"detail": _("the token is invalid")}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"detail": _("the token is invalid")}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/engine/vibes_auth/viewsets.py b/engine/vibes_auth/viewsets.py index c0322a06..6e71ddc4 100644 --- a/engine/vibes_auth/viewsets.py +++ b/engine/vibes_auth/viewsets.py @@ -54,7 +54,9 @@ class UserViewSet( permission_classes = [AllowAny] @action(detail=False, methods=["post"]) - @method_decorator(ratelimit(key="ip", rate="4/h" if not settings.DEBUG else "888/h")) + @method_decorator( + ratelimit(key="ip", rate="4/h" if not settings.DEBUG else "888/h") + ) def reset_password(self, request: Request) -> Response: user = None with suppress(User.DoesNotExist): @@ -64,7 +66,9 @@ class UserViewSet( return Response(status=status.HTTP_200_OK) @action(detail=True, methods=["put"], permission_classes=[IsAuthenticated]) - @method_decorator(ratelimit(key="ip", rate="3/h" if not settings.DEBUG else "888/h")) + @method_decorator( + ratelimit(key="ip", rate="3/h" if not settings.DEBUG else "888/h") + ) def upload_avatar(self, request: Request, *args, **kwargs) -> Response: user = self.get_object() if request.user != user: @@ -72,14 +76,20 @@ class UserViewSet( if "avatar" in request.FILES: user.avatar = request.FILES["avatar"] user.save() - return Response(status=status.HTTP_200_OK, data=self.serializer_class(user).data) + return Response( + status=status.HTTP_200_OK, data=self.serializer_class(user).data + ) return Response(status=status.HTTP_400_BAD_REQUEST) @action(detail=False, methods=["post"]) - @method_decorator(ratelimit(key="ip", rate="5/h" if not settings.DEBUG else "888/h")) + @method_decorator( + ratelimit(key="ip", rate="5/h" if not settings.DEBUG else "888/h") + ) def confirm_password_reset(self, request: Request, *args, **kwargs) -> Response: try: - if not compare_digest(request.data.get("password"), request.data.get("confirm_password")): # type: ignore [arg-type] + if not compare_digest( + request.data.get("password"), request.data.get("confirm_password") + ): # type: ignore [arg-type] return Response( {"error": _("passwords do not match")}, status=status.HTTP_400_BAD_REQUEST, @@ -92,37 +102,56 @@ class UserViewSet( password_reset_token = PasswordResetTokenGenerator() if not password_reset_token.check_token(user, request.data.get("token")): - return Response({"error": _("token is invalid!")}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": _("token is invalid!")}, + status=status.HTTP_400_BAD_REQUEST, + ) user.set_password(request.data.get("password")) user.save() - return Response({"message": _("password reset successfully")}, status=status.HTTP_200_OK) + return Response( + {"message": _("password reset successfully")}, status=status.HTTP_200_OK + ) - except (TypeError, ValueError, OverflowError, ValidationError, User.DoesNotExist) as e: + except ( + TypeError, + ValueError, + OverflowError, + ValidationError, + User.DoesNotExist, + ) as e: data = {"error": str(e)} if settings.DEBUG: data["detail"] = str(traceback.format_exc()) data["received"] = str(request.data) return Response(data, status=status.HTTP_400_BAD_REQUEST) - @method_decorator(ratelimit(key="ip", rate="5/h" if not settings.DEBUG else "888/h")) + @method_decorator( + ratelimit(key="ip", rate="5/h" if not settings.DEBUG else "888/h") + ) def create(self, request: Request, *args, **kwargs) -> Response: serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.save() user.save() headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) @action(detail=False, methods=["post"]) - @method_decorator(ratelimit(key="ip", rate="5/h" if not settings.DEBUG else "888/h")) + @method_decorator( + ratelimit(key="ip", rate="5/h" if not settings.DEBUG else "888/h") + ) def activate(self, request: Request) -> Response: detail = "" activation_error: Type[Exception] | None = None try: uuid = urlsafe_base64_decode(request.data.get("uidb_64")).decode() # type: ignore [arg-type] user = User.objects.get(pk=uuid) - if not user.check_token(urlsafe_base64_decode(request.data.get("token")).decode()): # type: ignore [arg-type] + if not user.check_token( + urlsafe_base64_decode(request.data.get("token")).decode() + ): # type: ignore [arg-type] return Response( {"error": _("activation link is invalid!")}, status=status.HTTP_400_BAD_REQUEST, @@ -135,7 +164,12 @@ class UserViewSet( user.is_active = True user.is_verified = True user.save() - except (TypeError, ValueError, OverflowError, User.DoesNotExist) as activation_error: + except ( + TypeError, + ValueError, + OverflowError, + User.DoesNotExist, + ) as activation_error: user = None activation_error = activation_error detail = str(traceback.format_exc()) @@ -158,11 +192,15 @@ class UserViewSet( user = self.get_object() if request.user != user: return Response(status=status.HTTP_403_FORBIDDEN) - serializer = MergeRecentlyViewedSerializer(data=request.data, context=self.get_serializer_context()) + serializer = MergeRecentlyViewedSerializer( + data=request.data, context=self.get_serializer_context() + ) serializer.is_valid(raise_exception=True) for product_uuid in serializer.validated_data["product_uuids"]: user.add_to_recently_viewed(product_uuid) - return Response(status=status.HTTP_202_ACCEPTED, data=self.serializer_class(user).data) + return Response( + status=status.HTTP_202_ACCEPTED, data=self.serializer_class(user).data + ) def retrieve(self, request: Request, *args, **kwargs) -> Response: instance = self.get_object() @@ -172,5 +210,7 @@ class UserViewSet( def update(self, request: Request, *args, **kwargs) -> Response: instance = self.get_object() serializer = self.get_serializer(instance) - instance = serializer.update(instance=self.get_object(), validated_data=request.data) + instance = serializer.update( + instance=self.get_object(), validated_data=request.data + ) return Response(self.get_serializer(instance).data) diff --git a/evibes/i18n.py b/evibes/i18n.py index ee803531..0f491b67 100644 --- a/evibes/i18n.py +++ b/evibes/i18n.py @@ -32,7 +32,12 @@ def _normalize_language_code(lang: str | None) -> str: def _safe_next_url(request: HttpRequest) -> str: - next_url = request.POST.get("next") or request.GET.get("next") or request.META.get("HTTP_REFERER") or "/" + next_url = ( + request.POST.get("next") + or request.GET.get("next") + or request.META.get("HTTP_REFERER") + or "/" + ) if not url_has_allowed_host_and_scheme( url=next_url, allowed_hosts={request.get_host()}, @@ -68,7 +73,11 @@ def set_language(request: HttpRequest): supported = {code.lower() for code, _ in getattr(settings, "LANGUAGES", [])} if parts and parts[0].lower() in supported: parts[0] = normalized - path = "/" + "/".join(parts) + "/" if parsed.path.endswith("/") else "/" + "/".join(parts) + path = ( + "/" + "/".join(parts) + "/" + if parsed.path.endswith("/") + else "/" + "/".join(parts) + ) next_url = parsed._replace(path=path).geturl() response["Location"] = next_url diff --git a/evibes/middleware.py b/evibes/middleware.py index 2064ad89..cf782985 100644 --- a/evibes/middleware.py +++ b/evibes/middleware.py @@ -3,7 +3,12 @@ import traceback from os import getenv from django.contrib.auth.models import AnonymousUser -from django.core.exceptions import BadRequest, DisallowedHost, PermissionDenied, ValidationError +from django.core.exceptions import ( + BadRequest, + DisallowedHost, + PermissionDenied, + ValidationError, +) from django.http import HttpResponseForbidden, JsonResponse from django.middleware.common import CommonMiddleware from django.middleware.locale import LocaleMiddleware @@ -87,7 +92,10 @@ class BlockInvalidHostMiddleware: allowed_hosts += getenv("ALLOWED_HOSTS").split(" ") if not hasattr(request, "META"): return BadRequest("Invalid Request") - if request.META.get("HTTP_HOST") not in allowed_hosts and "*" not in allowed_hosts: + if ( + request.META.get("HTTP_HOST") not in allowed_hosts + and "*" not in allowed_hosts + ): return HttpResponseForbidden("Invalid Host Header") return self.get_response(request) @@ -105,7 +113,9 @@ class GrapheneLoggingErrorsDebugMiddleware: try: return next(root, info, **args) except Exception as e: - if any(isinstance(e, error_type) for error_type in self.WARNING_ONLY_ERRORS): + if any( + isinstance(e, error_type) for error_type in self.WARNING_ONLY_ERRORS + ): logger.warning(str(e)) else: logger.error(str(e), exc_info=True) @@ -124,6 +134,10 @@ class RateLimitMiddleware: def process_exception(self, request, exception): if isinstance(exception, RatelimitedError): return JsonResponse( - {"error": str(exception), "code": getattr(exception, "code", "rate_limited")}, status=429 + { + "error": str(exception), + "code": getattr(exception, "code", "rate_limited"), + }, + status=429, ) return None diff --git a/evibes/pagination.py b/evibes/pagination.py index 5cd24bd5..0a509595 100644 --- a/evibes/pagination.py +++ b/evibes/pagination.py @@ -5,12 +5,17 @@ from rest_framework.response import Response class CustomPagination(PageNumberPagination): - page_size_query_param: str = "page_size" # name of the query parameter, you can use any + page_size_query_param: str = ( + "page_size" # name of the query parameter, you can use any + ) def get_paginated_response(self, data: dict[str, Any]) -> Response: return Response( { - "links": {"forward": self.get_next_link(), "backward": self.get_previous_link()}, + "links": { + "forward": self.get_next_link(), + "backward": self.get_previous_link(), + }, "counts": { "total_pages": None or self.page.paginator.num_pages, # type: ignore [union-attr] "page_size": None or self.page_size, @@ -20,7 +25,9 @@ class CustomPagination(PageNumberPagination): } ) - def get_paginated_response_schema(self, data_schema: dict[str, Any]) -> dict[str, Any]: + def get_paginated_response_schema( + self, data_schema: dict[str, Any] + ) -> dict[str, Any]: return { "type": "object", "properties": { diff --git a/evibes/settings/__init__.py b/evibes/settings/__init__.py index 08a3c856..a99caa76 100644 --- a/evibes/settings/__init__.py +++ b/evibes/settings/__init__.py @@ -9,6 +9,6 @@ from .elasticsearch import * # noqa: F403 from .emailing import * # noqa: F403 from .extensions import * # noqa: F403 from .graphene import * # noqa: F403 -from .unfold import * # noqa: F403 from .logconfig import * # noqa: F403 from .summernote import * # noqa: F403 +from .unfold import * # noqa: F403 diff --git a/evibes/settings/base.py b/evibes/settings/base.py index 5255b6cd..15c94f21 100644 --- a/evibes/settings/base.py +++ b/evibes/settings/base.py @@ -11,7 +11,8 @@ RELEASE_DATE = datetime(2025, 12, 5) PROJECT_NAME = getenv("EVIBES_PROJECT_NAME", "eVibes") TASKBOARD_URL = getenv( - "EVIBES_TASKBOARD_URL", "https://plane.wiseless.xyz/spaces/issues/dd33cb0ab9b04ef08a10f7eefae6d90c/?board=kanban" + "EVIBES_TASKBOARD_URL", + "https://plane.wiseless.xyz/spaces/issues/dd33cb0ab9b04ef08a10f7eefae6d90c/?board=kanban", ) SUPPORT_CONTACT = getenv("EVIBES_SUPPORT_CONTACT", "https://t.me/fureunoir") @@ -190,7 +191,9 @@ MIDDLEWARE: list[str] = [ "django_prometheus.middleware.PrometheusAfterMiddleware", ] -TEMPLATES: list[dict[str, str | list[str | Path] | dict[str, str | list[str]] | Path | bool]] = [ +TEMPLATES: list[ + dict[str, str | list[str | Path] | dict[str, str | list[str]] | Path | bool] +] = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [ @@ -341,7 +344,9 @@ CURRENCIES_WITH_SYMBOLS: tuple[tuple[str, str], ...] = ( ("VND", "₫"), ) -LANGUAGE_URL_OVERRIDES: dict[str, str] = {code.split("-")[0]: code for code, _ in LANGUAGES if "-" in code} +LANGUAGE_URL_OVERRIDES: dict[str, str] = { + code.split("-")[0]: code for code, _ in LANGUAGES if "-" in code +} CURRENCY_CODE: str = dict(CURRENCIES_BY_LANGUAGES).get(LANGUAGE_CODE) # type: ignore[assignment] @@ -408,7 +413,9 @@ if getenv("SENTRY_DSN"): from sentry_sdk.integrations.redis import RedisIntegration from sentry_sdk.types import Event, Hint - def scrub_sensitive(data: dict[str, Any] | list[Any] | str) -> dict[str, Any] | list[Any] | str | None: + def scrub_sensitive( + data: dict[str, Any] | list[Any] | str, + ) -> dict[str, Any] | list[Any] | str | None: if isinstance(data, dict): # noinspection PyShadowingNames cleaned: dict[str, Any] = {} @@ -475,7 +482,9 @@ STORAGES: dict[str, Any] = { if getenv("DBBACKUP_HOST") and getenv("DBBACKUP_USER") and getenv("DBBACKUP_PASS"): dbbackup_server_type = getenv("DBBACKUP_TYPE", "sftp") - project_name = getenv("EVIBES_PROJECT_NAME", "evibes_common").lower().replace(" ", "_") + project_name = ( + getenv("EVIBES_PROJECT_NAME", "evibes_common").lower().replace(" ", "_") + ) raw_path = getenv("DBBACKUP_PATH", f"/backups/{project_name}/") cleaned = raw_path.strip("/") diff --git a/evibes/settings/caches.py b/evibes/settings/caches.py index d2b2d3e9..2d3409c3 100644 --- a/evibes/settings/caches.py +++ b/evibes/settings/caches.py @@ -6,7 +6,9 @@ from evibes.settings.base import REDIS_PASSWORD CACHES = { "default": { "BACKEND": "django_prometheus.cache.backends.redis.RedisCache", - "LOCATION": getenv("CELERY_BROKER_URL", f"redis://:{REDIS_PASSWORD}@redis:6379/0"), + "LOCATION": getenv( + "CELERY_BROKER_URL", f"redis://:{REDIS_PASSWORD}@redis:6379/0" + ), "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "SOCKET_CONNECT_TIMEOUT": 5, diff --git a/evibes/settings/constance.py b/evibes/settings/constance.py index 7e3bf5ec..d0de3e53 100644 --- a/evibes/settings/constance.py +++ b/evibes/settings/constance.py @@ -23,30 +23,104 @@ CONSTANCE_CONFIG = OrderedDict( ### Legal Options ### ("COMPANY_NAME", (getenv("COMPANY_NAME"), _("Name of the company"))), ("COMPANY_ADDRESS", (getenv("COMPANY_ADDRESS"), _("Address of the company"))), - ("COMPANY_PHONE_NUMBER", (getenv("COMPANY_PHONE_NUMBER"), _("Phone number of the company"))), - ("TAX_RATE", (0, _("Tax rate in jurisdiction of your company. Leave 0 if you don't want to process taxes."))), - ("TAX_INCLUDED", (True, _("Shows if the taxes are already included in product's selling prices"))), - ("EXCHANGE_RATE_API_KEY", (getenv("EXCHANGE_RATE_API_KEY", "example token"), _("Exchange rate API key"))), + ( + "COMPANY_PHONE_NUMBER", + (getenv("COMPANY_PHONE_NUMBER"), _("Phone number of the company")), + ), + ( + "TAX_RATE", + ( + 0, + _( + "Tax rate in jurisdiction of your company. Leave 0 if you don't want to process taxes." + ), + ), + ), + ( + "TAX_INCLUDED", + ( + True, + _( + "Shows if the taxes are already included in product's selling prices" + ), + ), + ), + ( + "EXCHANGE_RATE_API_KEY", + ( + getenv("EXCHANGE_RATE_API_KEY", "example token"), + _("Exchange rate API key"), + ), + ), ### Email Options ### - ("EMAIL_BACKEND", ("django.core.mail.backends.smtp.EmailBackend", _("!!!DO NOT CHANGE!!!"))), + ( + "EMAIL_BACKEND", + ("django.core.mail.backends.smtp.EmailBackend", _("!!!DO NOT CHANGE!!!")), + ), ("EMAIL_HOST", (getenv("EMAIL_HOST", "smtp.404.org"), _("SMTP host"))), ("EMAIL_PORT", (int(getenv("EMAIL_PORT", "465")), _("SMTP port"))), ("EMAIL_USE_TLS", (bool(int(getenv("EMAIL_USE_TLS", 0))), _("Use TLS"))), ("EMAIL_USE_SSL", (bool(int(getenv("EMAIL_USE_SSL", 1))), _("Use SSL"))), - ("EMAIL_HOST_USER", (getenv("EMAIL_HOST_USER", "no-user@fix.this"), _("SMTP username"))), - ("EMAIL_HOST_PASSWORD", (getenv("EMAIL_HOST_PASSWORD", "SUPERsecretPASSWORD"), _("SMTP password"))), + ( + "EMAIL_HOST_USER", + (getenv("EMAIL_HOST_USER", "no-user@fix.this"), _("SMTP username")), + ), + ( + "EMAIL_HOST_PASSWORD", + (getenv("EMAIL_HOST_PASSWORD", "SUPERsecretPASSWORD"), _("SMTP password")), + ), ("EMAIL_FROM", (getenv("EMAIL_FROM", "eVibes"), _("Mail from option"))), ### Features Options ### - ("DAYS_TO_STORE_ANON_MSGS", (1, _("How many days we store messages from anonymous users"))), - ("DAYS_TO_STORE_AUTH_MSGS", (365, _("How many days we store messages from authenticated users"))), - ("DISABLED_COMMERCE", (getenv("DISABLED_COMMERCE", False), _("Disable buy functionality"))), - ("NOMINATIM_URL", (getenv("NOMINATIM_URL", ""), _("OpenStreetMap Nominatim API URL"))), - ("OPENAI_API_KEY", (getenv("OPENAI_API_KEY", "example key"), _("OpenAI API Key"))), - ("ABSTRACT_API_KEY", (getenv("ABSTRACT_API_KEY", "example key"), _("Abstract API Key"))), - ("HTTP_PROXY", (getenv("DJANGO_HTTP_PROXY", "http://username:password@proxy_address:port"), _("HTTP Proxy"))), + ( + "DAYS_TO_STORE_ANON_MSGS", + (1, _("How many days we store messages from anonymous users")), + ), + ( + "DAYS_TO_STORE_AUTH_MSGS", + (365, _("How many days we store messages from authenticated users")), + ), + ( + "DISABLED_COMMERCE", + (getenv("DISABLED_COMMERCE", False), _("Disable buy functionality")), + ), + ( + "NOMINATIM_URL", + (getenv("NOMINATIM_URL", ""), _("OpenStreetMap Nominatim API URL")), + ), + ( + "OPENAI_API_KEY", + (getenv("OPENAI_API_KEY", "example key"), _("OpenAI API Key")), + ), + ( + "ABSTRACT_API_KEY", + (getenv("ABSTRACT_API_KEY", "example key"), _("Abstract API Key")), + ), + ( + "HTTP_PROXY", + ( + getenv( + "DJANGO_HTTP_PROXY", "http://username:password@proxy_address:port" + ), + _("HTTP Proxy"), + ), + ), ### SEO Options ### - ("ADVERTSIMENT", (getenv("EVIBES_ADVERTISIMENT", ""), _("An entity for storing advertisiment data"), "json")), - ("ANALYTICS", (getenv("EVIBES_ANALYTICS", ""), _("An entity for storing analytics data"), "json")), + ( + "ADVERTSIMENT", + ( + getenv("EVIBES_ADVERTISIMENT", ""), + _("An entity for storing advertisiment data"), + "json", + ), + ), + ( + "ANALYTICS", + ( + getenv("EVIBES_ANALYTICS", ""), + _("An entity for storing analytics data"), + "json", + ), + ), ### System Options ### ("SAVE_VENDORS_RESPONSES", (False, _("Save responses from vendors' APIs"))), ("BACKUP_DATABASE", (True, _("Backup database"))), diff --git a/evibes/settings/drf.py b/evibes/settings/drf.py index 6b445fb4..9bfcdec7 100644 --- a/evibes/settings/drf.py +++ b/evibes/settings/drf.py @@ -3,7 +3,13 @@ from os import getenv from django.utils.translation import gettext_lazy as _ -from evibes.settings.base import BASE_DOMAIN, DEBUG, EVIBES_VERSION, PROJECT_NAME, SECRET_KEY +from evibes.settings.base import ( + BASE_DOMAIN, + DEBUG, + EVIBES_VERSION, + PROJECT_NAME, + SECRET_KEY, +) REST_FRAMEWORK: dict[str, str | int | list[str] | tuple[str, ...] | dict[str, bool]] = { "DEFAULT_PAGINATION_CLASS": "evibes.pagination.CustomPagination", diff --git a/evibes/settings/elasticsearch.py b/evibes/settings/elasticsearch.py index dd2e0ebb..eb842c8c 100644 --- a/evibes/settings/elasticsearch.py +++ b/evibes/settings/elasticsearch.py @@ -16,4 +16,6 @@ ELASTICSEARCH_DSL = { ELASTICSEARCH_DSL_AUTOSYNC = DEBUG ELASTICSEARCH_DSL_PARALLEL = True -ELASTICSEARCH_DSL_SIGNAL_PROCESSOR = "django_elasticsearch_dsl.signals.CelerySignalProcessor" +ELASTICSEARCH_DSL_SIGNAL_PROCESSOR = ( + "django_elasticsearch_dsl.signals.CelerySignalProcessor" +) diff --git a/evibes/settings/logconfig.py b/evibes/settings/logconfig.py index 549916a7..feeeb738 100644 --- a/evibes/settings/logconfig.py +++ b/evibes/settings/logconfig.py @@ -1,6 +1,6 @@ import logging -from evibes.settings.base import DEBUG, DEBUG_DATABASE, DEBUG_CELERY +from evibes.settings.base import DEBUG, DEBUG_CELERY, DEBUG_DATABASE class SkipVariableDoesNotExistFilter(logging.Filter): @@ -39,7 +39,9 @@ LOGGING = { "filters": { "require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}, "require_debug_true": {"()": "django.utils.log.RequireDebugTrue"}, - "skip_variable_doesnotexist": {"()": "evibes.settings.logconfig.SkipVariableDoesNotExistFilter"}, + "skip_variable_doesnotexist": { + "()": "evibes.settings.logconfig.SkipVariableDoesNotExistFilter" + }, }, "handlers": { "console": { diff --git a/evibes/settings/unfold.py b/evibes/settings/unfold.py index 8bb37309..21ba4e1b 100644 --- a/evibes/settings/unfold.py +++ b/evibes/settings/unfold.py @@ -91,7 +91,9 @@ UNFOLD: dict[str, Any] = { { "title": _("Periodic Tasks"), "icon": "event_list", - "link": reverse_lazy("admin:django_celery_beat_periodictask_changelist"), + "link": reverse_lazy( + "admin:django_celery_beat_periodictask_changelist" + ), }, { "title": "Sitemap", diff --git a/evibes/signal_processors.py b/evibes/signal_processors.py index aac16aa1..c35f0f38 100644 --- a/evibes/signal_processors.py +++ b/evibes/signal_processors.py @@ -8,9 +8,15 @@ class SelectiveSignalProcessor(CelerySignalProcessor): for doc in registry.get_documents(): model = doc.django.model models.signals.post_save.connect(self.handle_save, sender=model, weak=False) - models.signals.post_delete.connect(self.handle_delete, sender=model, weak=False) - models.signals.pre_delete.connect(self.handle_pre_delete, sender=model, weak=False) - models.signals.m2m_changed.connect(self.handle_m2m_changed, sender=model, weak=False) + models.signals.post_delete.connect( + self.handle_delete, sender=model, weak=False + ) + models.signals.pre_delete.connect( + self.handle_pre_delete, sender=model, weak=False + ) + models.signals.m2m_changed.connect( + self.handle_m2m_changed, sender=model, weak=False + ) def teardown(self): for doc in registry.get_documents(): diff --git a/evibes/utils/misc.py b/evibes/utils/misc.py index 2a1351db..31201022 100644 --- a/evibes/utils/misc.py +++ b/evibes/utils/misc.py @@ -3,7 +3,9 @@ from importlib import import_module from typing import Any -def create_object(module_name: str, class_name: str, *args: list[Any], **kwargs: dict[Any, Any]) -> Any: +def create_object( + module_name: str, class_name: str, *args: list[Any], **kwargs: dict[Any, Any] +) -> Any: module = import_module(module_name) cls = getattr(module, class_name) diff --git a/monitoring/generate_prometheus_password.py b/monitoring/generate_prometheus_password.py index c5643fe1..57824c3f 100644 --- a/monitoring/generate_prometheus_password.py +++ b/monitoring/generate_prometheus_password.py @@ -2,4 +2,8 @@ import getpass import bcrypt -print(bcrypt.hashpw(getpass.getpass("Password: ").encode("utf-8"), bcrypt.gensalt()).decode()) +print( + bcrypt.hashpw( + getpass.getpass("Password: ").encode("utf-8"), bcrypt.gensalt() + ).decode() +)