Merge branch 'main' into storefront-next

This commit is contained in:
Egor Pavlovich Gorbunov 2025-12-15 20:30:21 +03:00
commit 63a824d78d
132 changed files with 3497 additions and 1184 deletions

53
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,53 @@
image: ghcr.io/astral-sh/uv:python3.12-bookworm
stages:
- lint
- typecheck
- test
variables:
UV_PYTHON: "3.12"
PIP_DISABLE_PIP_VERSION_CHECK: "1"
PYTHONDONTWRITEBYTECODE: "1"
before_script:
- uv sync --frozen --extra linting
lint:
stage: lint
script:
- uv run ruff format --check .
- uv run ruff check --force-exclude .
rules:
- changes:
- "**/*.py"
- "pyproject.toml"
- ".pre-commit-config.yaml"
- "pyrightconfig.json"
when: on_success
- when: never
typecheck:
stage: typecheck
script:
- uv run pyright
rules:
- changes:
- "**/*.py"
- "pyproject.toml"
- "pyrightconfig.json"
when: on_success
- when: never
test:
stage: test
script:
- uv run pytest -q
rules:
- changes:
- "**/*.py"
- "pyproject.toml"
- "pytest.ini"
- "pyproject.toml"
when: on_success
- when: never

18
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,18 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff
name: Ruff (lint & fix)
args: ["--fix", "--exit-non-zero-on-fix"]
files: "\\.(py|pyi)$"
exclude: "^storefront/"
- id: ruff-format
name: Ruff (format)
files: "\\.(py|pyi)$"
exclude: "^storefront/"
ci:
autofix_commit_msg: "chore(pre-commit): auto-fix issues"
autofix_prs: true
autoupdate_commit_msg: "chore(pre-commit): autoupdate hooks"

View file

@ -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")

View file

@ -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(

View file

@ -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"),
},
)

View file

@ -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")),

View file

@ -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",
),
),
]

View file

@ -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"
),
),
]

View file

@ -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",

View file

@ -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",

View file

@ -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)

View file

@ -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

View file

@ -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"

View file

@ -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:

View file

@ -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 = (

View file

@ -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",
),
]

View file

@ -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:

View file

@ -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`."
)
),
)
}

View file

@ -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},
),
}

View file

@ -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"

View file

@ -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"
),
},
)

View file

@ -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=(

View file

@ -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 "<class 'engine.core.models.Order'>":
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 "<class 'engine.payments.models.Transaction'>":
return BuyWishlist(transaction=instance)
case "<class 'engine.core.models.Order'>":
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 "<class 'engine.payments.models.Transaction'>":
@ -540,7 +570,9 @@ class BuyProduct(BaseMutation):
case "<class 'engine.core.models.Order'>":
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)

View file

@ -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)

View file

@ -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,

View file

@ -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."))

View file

@ -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!"
)
)

View file

@ -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)

View file

@ -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…")

View file

@ -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…")

View file

@ -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!"
)
)

View file

@ -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

View file

@ -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!"))

View file

@ -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!")
)

View file

@ -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

View file

@ -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()

View file

@ -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", {})

View file

@ -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",
),
),
(

View file

@ -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",
),
),
]

View file

@ -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",
),
),
]

View file

@ -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",
),
),
],

View file

@ -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",
),
),
],

View file

@ -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")

View file

@ -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"
)
],
},
),
]

View file

@ -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),

View file

@ -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",
),
),
]

View file

@ -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",

View file

@ -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 = [

View file

@ -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
),
),
]

View file

@ -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)

View file

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

View file

@ -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",

View file

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

View file

@ -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",

View file

@ -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"
),
),
]

View file

@ -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))}"

View file

@ -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

View file

@ -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):

View file

@ -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]

View file

@ -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]

View file

@ -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))

View file

@ -38,9 +38,9 @@ class StaticPagesSitemap(SitemapLanguageMixin, Sitemap): # type: ignore [type-a
},
]
for static_post_page in Post.objects.filter(is_static_page=True, is_active=True).only(
"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,

View file

@ -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:

View file

@ -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"]

View file

@ -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,

View file

@ -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)

View file

@ -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]

View file

@ -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 = (

View file

@ -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)}"

View file

@ -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)

View file

@ -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,
}

View file

@ -20,7 +20,10 @@ def get_vendors_integrations(name: str | None = None) -> list[AbstractVendor]:
vendors_integrations.append(create_object(module_name, class_name))
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

View file

@ -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"
)
)

View file

@ -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:

View file

@ -12,8 +12,15 @@ from django.contrib.sitemaps.views import index as _sitemap_index_view
from django.contrib.sitemaps.views import sitemap as _sitemap_detail_view
from django.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,

View file

@ -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 "<class 'engine.payments.models.Transaction'>":
return Response(status=status.HTTP_202_ACCEPTED, data=TransactionProcessSerializer(instance).data)
return Response(
status=status.HTTP_202_ACCEPTED,
data=TransactionProcessSerializer(instance).data,
)
case "<class 'engine.core.models.Order'>":
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)

View file

@ -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 = {}

View file

@ -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,

View file

@ -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

View file

@ -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)

View file

@ -1,4 +1,14 @@
from django.db.models import BooleanField, Case, F, Manager, Q, QuerySet, Sum, Value, When
from django.db.models import (
BooleanField,
Case,
F,
Manager,
Q,
QuerySet,
Sum,
Value,
When,
)
from django.db.models.functions import Coalesce
from django.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),
),
)

View file

@ -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",

View file

@ -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"
),
),
]

View file

@ -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={

View file

@ -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"
),
),
]

View file

@ -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)

View file

@ -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)

View file

@ -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)),

View file

@ -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)

View file

@ -20,7 +20,10 @@ def balance_deposit_email(transaction_pk: str) -> tuple[bool, str]:
return False, f"Transaction not found with the given pk: {transaction_pk}"
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)

View file

@ -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)

View file

@ -12,7 +12,11 @@ from rest_framework.views import APIView
from engine.payments.docs.drf.views import DEPOSIT_SCHEMA, LIMITS_SCHEMA
from engine.payments.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)

View file

@ -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)

View file

@ -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)

View file

@ -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,
}

View file

@ -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,
},
),

View file

@ -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

View file

@ -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 reversechronological order"),
description=_(
"the products this user has viewed most recently (max 48), in reversechronological 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)

View file

@ -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"):

View file

@ -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

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