From 20d5f5db21c83800722ccbce71137f81d43c3049 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Wed, 15 Oct 2025 14:25:58 +0300 Subject: [PATCH 01/17] Features: 1) Add support for linking `Gateway` to transactions including constraints and limits; 2) Implement new `Gateway` model with transactional rules and currency configurations; 3) Add `integration_path` field to `Vendor` for dynamic integrations; 4) Improve task logic to dynamically use vendor integrations for stock updates and order statuses. Fixes: 1) Replace `IntegerField` with `PositiveIntegerField` for product image priority to enforce positive values. Extra: 1) Optimize imports for consistent formatting; 2) Clarify logging messages in tasks; 3) Minor docstring and help text updates. --- core/models.py | 10 +++++-- core/tasks.py | 19 ++++++------ payments/models.py | 74 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 13 deletions(-) diff --git a/core/models.py b/core/models.py index 1ab647c6..80ef3bf0 100644 --- a/core/models.py +++ b/core/models.py @@ -143,6 +143,13 @@ class Vendor(ExportModelOperationsMixin("vendor"), NiceModel): # type: ignore [ verbose_name=_("response file"), help_text=_("vendor's last processing response"), ) + integration_path = CharField( + null=True, + blank=True, + max_length=255, + help_text=_("vendor's integration file path"), + verbose_name=_("integration path"), + ) def __str__(self) -> str: return self.name @@ -739,9 +746,8 @@ class ProductImage(ExportModelOperationsMixin("product_image"), NiceModel): # t verbose_name=_("product image"), upload_to=get_product_uuid_as_path, ) - priority = IntegerField( + priority = PositiveIntegerField( default=1, - validators=[MinValueValidator(1)], help_text=_("determines the order in which images are displayed"), verbose_name=_("display priority"), ) diff --git a/core/tasks.py b/core/tasks.py index 3792ad66..9f417649 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -4,7 +4,7 @@ import shutil import uuid from datetime import date, timedelta from time import sleep -from typing import Any +from typing import Any, Type import requests from celery.app import shared_task @@ -14,7 +14,8 @@ from django.core.cache import cache from core.models import Product, Promotion from core.utils.caching import set_default_cache -from core.vendors import VendorInactiveError, delete_stale +from core.utils.vendors import get_vendors_integrations +from core.vendors import VendorInactiveError, delete_stale, AbstractVendor from evibes.settings import MEDIA_ROOT logger = get_task_logger(__name__) @@ -39,16 +40,15 @@ def update_products_task() -> tuple[bool, str]: if not update_products_task_running: cache.set("update_products_task_running", True, 86400) - vendors_classes: list[Any] = [] + vendors: list[Type[AbstractVendor]] = get_vendors_integrations() - for vendor_class in vendors_classes: - vendor = vendor_class() + for vendor in vendors: try: vendor.update_stock() except VendorInactiveError: - logger.info(f"Skipping {vendor_class} due to inactivity") + logger.info(f"Skipping {vendor.__str__} due to inactivity") except Exception as e: - logger.warning(f"Skipping {vendor_class} due to error: {e!s}") + logger.warning(f"Skipping {vendor.__str__} due to error: {e!s}") delete_stale() @@ -70,10 +70,9 @@ def update_orderproducts_task() -> tuple[bool, str]: message confirming the successful execution of the task. :rtype: Tuple[bool, str] """ - vendors_classes: list[Any] = [] + vendors: list[Type[AbstractVendor]] = get_vendors_integrations() - for vendor_class in vendors_classes: - vendor = vendor_class() + for vendor in vendors: vendor.update_order_products_statuses() return True, "Success" diff --git a/payments/models.py b/payments/models.py index 77d30c70..81f9a10d 100644 --- a/payments/models.py +++ b/payments/models.py @@ -1,6 +1,18 @@ from constance import config +from django.conf import settings from django.contrib.postgres.indexes import GinIndex -from django.db.models import CASCADE, CharField, FloatField, ForeignKey, JSONField, OneToOneField, QuerySet +from django.db.models import ( + CASCADE, + CharField, + FloatField, + ForeignKey, + JSONField, + OneToOneField, + PositiveIntegerField, + QuerySet, + Sum, +) +from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from core.abstract import NiceModel @@ -20,6 +32,7 @@ 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") def __str__(self): return ( @@ -67,3 +80,62 @@ class Balance(NiceModel): if self.amount != 0.0 and len(str(self.amount).split(".")[1]) > 2: self.amount = round(self.amount, 2) super().save(**kwargs) + + +class Gateway(NiceModel): + name = CharField(max_length=20, null=False, blank=False, verbose_name=_("name")) + default_currency = CharField( + max_length=3, null=False, blank=False, verbose_name=_("default currency"), choices=settings.CURRENCIES + ) + currencies = CharField( + max_length=3, + null=False, + blank=False, + verbose_name=_("currencies"), + help_text=_(f"comma separated list of currencies supported by this gateway, choose from {settings.CURRENCIES}"), + ) + integration_path = CharField(max_length=255, null=True, blank=True) + minimum_transaction_amount = FloatField( + null=False, blank=False, default=0, verbose_name=_("minimum transaction amount") + ) + maximum_transaction_amount = FloatField( + null=False, blank=False, default=0, verbose_name=_("maximum transaction amount") + ) + daily_limit = PositiveIntegerField( + null=False, + blank=False, + default=0, + verbose_name=_("daily limit"), + help_text=_("daily sum limit of transactions' amounts. 0 means no limit"), + ) + monthly_limit = PositiveIntegerField( + null=False, + blank=False, + default=0, + 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) + + def __str__(self): + return self.name + + class Meta: + verbose_name = _("payment gateway") + verbose_name_plural = _("payment gateways") + + @property + def can_be_used(self) -> bool: + today = now().date() + current_month_start = today.replace(day=1) + + daily_sum = self.transactions.filter(created__date=today).aggregate(total=Sum("amount"))["total"] or 0 + + monthly_sum = ( + self.transactions.filter(created__gte=current_month_start).aggregate(total=Sum("amount"))["total"] or 0 + ) + + daily_ok = self.daily_limit == 0 or daily_sum < self.daily_limit + monthly_ok = self.monthly_limit == 0 or monthly_sum < self.monthly_limit + + return daily_ok and monthly_ok From f67319ba5a5ecd07e69f21cccf015d27ea37eeb0 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Sat, 18 Oct 2025 11:35:55 +0300 Subject: [PATCH 02/17] Features: 1) None; Fixes: 1) Correct conditional check for cached filterable results to handle `None` values; 2) Remove unnecessary permission checks for attribute filtering; Extra: 1) Update cache expiry time from 86400 seconds to 3600 seconds. --- core/serializers/detail.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/core/serializers/detail.py b/core/serializers/detail.py index fb8baaca..51a15e17 100644 --- a/core/serializers/detail.py +++ b/core/serializers/detail.py @@ -76,16 +76,10 @@ class CategoryDetailSerializer(ModelSerializer): def get_filterable_attributes(self, obj: Category) -> list[dict]: cache_key = f"{obj.uuid}_filterable_results" filterable_results = cache.get(cache_key) - if filterable_results: + if filterable_results is not None: return filterable_results - request = self.context.get("request") - user = getattr(request, "user", AnonymousUser()) - attrs_qs = ( - obj.attributes.filter(is_filterable=True) - if user.has_perm("view_attribute") - else obj.attributes.filter(is_active=True, is_filterable=True) - ) + attrs_qs = obj.attributes.filter(is_active=True, is_filterable=True) attributes = list(attrs_qs) attr_ids = [a.id for a in attributes] @@ -116,8 +110,7 @@ class CategoryDetailSerializer(ModelSerializer): } ) - if not user.has_perm("view_attribute"): - cache.set(cache_key, filterable_results, 86400) + cache.set(cache_key, filterable_results, 3600) return filterable_results From ef8fdd6347b85cc6b5a72df535513df6a3083287 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Sat, 18 Oct 2025 11:45:27 +0300 Subject: [PATCH 03/17] Fixes: 1) Correct ordering logic in `final_ordering` to prepend "personal_order_tail" instead of appending. --- core/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/filters.py b/core/filters.py index 713cc480..30cb3032 100644 --- a/core/filters.py +++ b/core/filters.py @@ -324,7 +324,7 @@ class ProductFilter(FilterSet): # type: ignore [misc] if "?" in mapped_requested: final_ordering = ["personal_order_tail", "?"] else: - final_ordering = mapped_requested + ["personal_order_tail"] + final_ordering = ["personal_order_tail"] + mapped_requested if final_ordering: qs = qs.order_by(*final_ordering) From efeab3e426c3519e04d69c2225fb87defd0fa405 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Sat, 18 Oct 2025 11:48:40 +0300 Subject: [PATCH 04/17] Features: 1) Add multiple database indexes to enhance query performance for models `order`, `orderproduct`, and `product`. Fixes: 1) Remove redundant blank line in migration file. Extra: 1) Simplify index definition formatting for better readability. --- ...ct_name_alter_product_name_ar_ar_and_more.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/core/migrations/0045_alter_product_name_alter_product_name_ar_ar_and_more.py b/core/migrations/0045_alter_product_name_alter_product_name_ar_ar_and_more.py index 44a56555..8c777d35 100644 --- a/core/migrations/0045_alter_product_name_alter_product_name_ar_ar_and_more.py +++ b/core/migrations/0045_alter_product_name_alter_product_name_ar_ar_and_more.py @@ -5,7 +5,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("core", "0044_vendor_last_processing_response"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), @@ -332,27 +331,19 @@ 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", From 44e5e74044c75fd40815678cef1c0c5b5c7444b5 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Sat, 18 Oct 2025 16:19:39 +0300 Subject: [PATCH 05/17] Features: 1) Add async logger initialization in `core/vendors` and log warnings for missing attribute values, groups, and integrity errors; 2) Add traceback logging for attribute-related errors in `core/vendors`; Fixes: 1) Add missing `sku` to readonly fields in `core/admin`; Extra: 1) Remove unnecessary import of `AnonymousUser` from `core/serializers/detail.py`. --- core/admin.py | 1 + core/serializers/detail.py | 1 - core/vendors/__init__.py | 8 ++++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/core/admin.py b/core/admin.py index 8f5f69ea..8a1c0326 100644 --- a/core/admin.py +++ b/core/admin.py @@ -410,6 +410,7 @@ class ProductAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type: "sku", ) readonly_fields = ( + "sku", "slug", "uuid", "modified", diff --git a/core/serializers/detail.py b/core/serializers/detail.py index 51a15e17..f9c9ef90 100644 --- a/core/serializers/detail.py +++ b/core/serializers/detail.py @@ -3,7 +3,6 @@ from collections import defaultdict from contextlib import suppress from typing import Collection, Any -from django.contrib.auth.models import AnonymousUser from django.core.cache import cache from django.db.models.functions import Length from rest_framework.fields import JSONField, SerializerMethodField diff --git a/core/vendors/__init__.py b/core/vendors/__init__.py index b4eeafbd..8bc2183c 100644 --- a/core/vendors/__init__.py +++ b/core/vendors/__init__.py @@ -1,5 +1,6 @@ import gzip import json +import traceback from contextlib import suppress from datetime import datetime from decimal import Decimal @@ -7,6 +8,7 @@ from io import BytesIO from math import ceil, log10 from typing import Any +from celery.utils.log import get_task_logger from constance import config from django.conf import settings from django.core.files.base import ContentFile @@ -28,6 +30,8 @@ from core.models import ( from payments.errors import RatesError from payments.utils import get_rates +async_logger = get_task_logger(__name__) + class NotEnoughBalanceError(Exception): """ @@ -416,9 +420,11 @@ class AbstractVendor: def process_attribute(self, key: str, value: Any, product: Product, attr_group: AttributeGroup) -> None: if not value: + async_logger.warning(f"No value for attribute {key!r} at {product.name!r}...") return if not attr_group: + async_logger.warning(f"No group for attribute {key!r} at {product.name!r}...") return if key in self.blocked_attributes: @@ -444,6 +450,8 @@ class AbstractVendor: attribute.value_type = attr_value_type attribute.save() except IntegrityError: + async_logger.warning(f"IntegrityError while processing attribute {key!r}...") + async_logger.warning(traceback.format_exc()) return attribute.save() From a700a1c7e67aec33d44b5ab5f4a39772b23d96b0 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Sat, 18 Oct 2025 22:41:43 +0300 Subject: [PATCH 06/17] Features: 1) Update `attribute.name` and its localized variants to remove the `unique` constraint and set `null=True` for optional fields; Fixes: 1) Correct field attributes in `attribute` model to align with database requirements; Extra: 1) Generated migration file `0046_alter_attribute_name_alter_attribute_name_ar_ar_and_more.py`. --- ...ame_alter_attribute_name_ar_ar_and_more.py | 302 ++++++++++++++++++ core/models.py | 1 - 2 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 core/migrations/0046_alter_attribute_name_alter_attribute_name_ar_ar_and_more.py diff --git a/core/migrations/0046_alter_attribute_name_alter_attribute_name_ar_ar_and_more.py b/core/migrations/0046_alter_attribute_name_alter_attribute_name_ar_ar_and_more.py new file mode 100644 index 00000000..d6bbce0d --- /dev/null +++ b/core/migrations/0046_alter_attribute_name_alter_attribute_name_ar_ar_and_more.py @@ -0,0 +1,302 @@ +# Generated by Django 5.2 on 2025-10-18 19:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0045_alter_product_name_alter_product_name_ar_ar_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="attribute", + name="name", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_ar_ar", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_cs_cz", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_da_dk", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_de_de", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_en_gb", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_en_us", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_es_es", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_fa_ir", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_fr_fr", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_he_il", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_hi_in", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_hr_hr", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_id_id", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_it_it", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_ja_jp", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_kk_kz", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_ko_kr", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_nl_nl", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_no_no", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_pl_pl", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_pt_br", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_ro_ro", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_ru_ru", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_sv_se", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_th_th", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_tr_tr", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_vi_vn", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + migrations.AlterField( + model_name="attribute", + name="name_zh_hans", + field=models.CharField( + help_text="name of this attribute", + max_length=255, + null=True, + verbose_name="attribute's name", + ), + ), + ] diff --git a/core/models.py b/core/models.py index 6b4b0811..63e00571 100644 --- a/core/models.py +++ b/core/models.py @@ -652,7 +652,6 @@ class Attribute(ExportModelOperationsMixin("attribute"), NiceModel): # type: ig max_length=255, help_text=_("name of this attribute"), verbose_name=_("attribute's name"), - unique=True, ) is_filterable = BooleanField( From 2212a7ccb66302a6fa7dd3e13659cbcd03bb2c45 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Sat, 18 Oct 2025 23:16:49 +0300 Subject: [PATCH 07/17] Features: (none); Fixes: 1) Remove unnecessary blank line in migration file; Extra: (none); --- ...6_alter_attribute_name_alter_attribute_name_ar_ar_and_more.py | 1 - 1 file changed, 1 deletion(-) diff --git a/core/migrations/0046_alter_attribute_name_alter_attribute_name_ar_ar_and_more.py b/core/migrations/0046_alter_attribute_name_alter_attribute_name_ar_ar_and_more.py index d6bbce0d..88a09104 100644 --- a/core/migrations/0046_alter_attribute_name_alter_attribute_name_ar_ar_and_more.py +++ b/core/migrations/0046_alter_attribute_name_alter_attribute_name_ar_ar_and_more.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("core", "0045_alter_product_name_alter_product_name_ar_ar_and_more"), ] From 1fed75584e181a32f42bf8beb5ed39ce67016e7b Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Sun, 19 Oct 2025 00:16:57 +0300 Subject: [PATCH 08/17] Features: 1) Add migration to enforce unique constraint on Attribute model for fields `name`, `group`, and `value_type`; Fixes: 1) Remove redundant traceback logging in `core/vendors/__init__.py`; Extra: 1) Update Attribute model's `unique_together` to include `value_type`. --- .../0047_alter_attribute_unique_together.py | 16 ++++++++++++++++ core/models.py | 1 + core/vendors/__init__.py | 1 - 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 core/migrations/0047_alter_attribute_unique_together.py diff --git a/core/migrations/0047_alter_attribute_unique_together.py b/core/migrations/0047_alter_attribute_unique_together.py new file mode 100644 index 00000000..18cdadef --- /dev/null +++ b/core/migrations/0047_alter_attribute_unique_together.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2 on 2025-10-18 21:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0046_alter_attribute_name_alter_attribute_name_ar_ar_and_more"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="attribute", + unique_together={("name", "group", "value_type")}, + ), + ] diff --git a/core/models.py b/core/models.py index 63e00571..9595beee 100644 --- a/core/models.py +++ b/core/models.py @@ -667,6 +667,7 @@ class Attribute(ExportModelOperationsMixin("attribute"), NiceModel): # type: ig unique_together = ( "name", "group", + "value_type", ) verbose_name = _("attribute") verbose_name_plural = _("attributes") diff --git a/core/vendors/__init__.py b/core/vendors/__init__.py index 8bc2183c..31e8f957 100644 --- a/core/vendors/__init__.py +++ b/core/vendors/__init__.py @@ -451,7 +451,6 @@ class AbstractVendor: attribute.save() except IntegrityError: async_logger.warning(f"IntegrityError while processing attribute {key!r}...") - async_logger.warning(traceback.format_exc()) return attribute.save() From 2712ccdeb7419844e8ad0fd99310d955e5815834 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Sun, 19 Oct 2025 01:44:28 +0300 Subject: [PATCH 09/17] Features: 1) Add retry mechanism with exponential backoff for saving attributes to handle deadlocks. Fixes: 1) Remove unused `traceback` import; 2) Add missing import for `OperationalError`; 3) Prevent redundant saves for unchanged attributes. Extra: 1) Simplify attribute save logic and improve efficiency; 2) Code cleanup for better readability. --- core/vendors/__init__.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/core/vendors/__init__.py b/core/vendors/__init__.py index 31e8f957..052f50a7 100644 --- a/core/vendors/__init__.py +++ b/core/vendors/__init__.py @@ -1,6 +1,6 @@ import gzip import json -import traceback +import time from contextlib import suppress from datetime import datetime from decimal import Decimal @@ -14,6 +14,7 @@ from django.conf import settings from django.core.files.base import ContentFile from django.db import IntegrityError, transaction from django.db.models import QuerySet +from django.db.utils import OperationalError from core.elasticsearch import process_system_query from core.models import ( @@ -446,15 +447,27 @@ class AbstractVendor: ) except Attribute.MultipleObjectsReturned: attribute = Attribute.objects.filter(name=key, group=attr_group).order_by("uuid").first() # type: ignore [assignment] - attribute.is_active = True - attribute.value_type = attr_value_type - attribute.save() + fields_to_update: list[str] = [] + if not attribute.is_active: + attribute.is_active = True + fields_to_update.append("is_active") + if attribute.value_type != attr_value_type: + attribute.value_type = attr_value_type + fields_to_update.append("value_type") + if fields_to_update: + for attempt in range(5): + try: + attribute.save(update_fields=fields_to_update) + break + except OperationalError as e: + if "deadlock detected" in str(e): + time.sleep(0.1 * (2**attempt)) + continue + raise except IntegrityError: async_logger.warning(f"IntegrityError while processing attribute {key!r}...") return - attribute.save() - if not is_created: return From 06290c0278882b7c424653df2388e1ba48774ec4 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Mon, 20 Oct 2025 22:56:11 +0300 Subject: [PATCH 10/17] Features: 1) Add dynamic static page generation in `StaticPagesSitemap` by integrating active blog posts marked as static pages; 2) Introduce `SitemapLanguageMixin` to handle language-based URL generation across sitemaps; 3) Add `is_static_page` field to `Post` model for designating posts as static pages; Fixes: 1) Correct router naming in `blog/urls.py` from `payment_router` to `blog_router` for clarity; Extra: 1) Refactor obsolete `StaticPagesSitemap.PAGES` structure with a dynamic `items` method; 2) Create placeholder 404 URLs for non-existent slugs; 3) Update and simplify docstring for `Post` class, replacing inline details with a concise translation-aware docstring. --- blog/models.py | 34 ++++++--------- blog/urls.py | 6 +-- core/sitemaps.py | 111 ++++++++++++++++++----------------------------- 3 files changed, 59 insertions(+), 92 deletions(-) diff --git a/blog/models.py b/blog/models.py index d11f3de6..98a6b22c 100644 --- a/blog/models.py +++ b/blog/models.py @@ -1,4 +1,4 @@ -from django.db.models import CASCADE, CharField, FileField, ForeignKey, ManyToManyField +from django.db.models import CASCADE, CharField, FileField, ForeignKey, ManyToManyField, BooleanField from django.utils.translation import gettext_lazy as _ from django_extensions.db.fields import AutoSlugField from markdown.extensions.toc import TocExtension @@ -8,26 +8,13 @@ from core.abstract import NiceModel class Post(NiceModel): # type: ignore [django-manager-missing] - """ - Represents a blog post model extending NiceModel. - - The Post class defines the structure and behavior of a blog post. It includes - attributes for author, title, content, optional file attachment, slug, - and associated tags. The class enforces constraints such as requiring either - content or a file attachment but not both simultaneously. It also supports - automatic slug generation based on the title. This model can be used in - a blogging platform to manage posts created by users. - - Attributes: - is_publicly_visible (bool): Specifies whether the post is visible to the public. - author (ForeignKey): A reference to the user who authored the post. - title (CharField): The title of the post. Must be unique and non-empty. - content (MarkdownField): The content of the post written in Markdown format. - file (FileField): An optional file attachment for the post. - slug (AutoSlugField): A unique, automatically generated slug based on the title. - tags (ManyToManyField): Tags associated with the post for categorization. - - """ + __doc__ = _( + "Represents a blog post model. " + "The Post class defines the structure and behavior of a blog post. " + "It includes attributes for author, title, content, optional file attachment, slug, and associated tags. " + "The class enforces constraints such as requiring either content or a file attachment but not both simultaneously. " + "It also supports automatic slug generation based on the title." + ) is_publicly_visible = True @@ -76,6 +63,11 @@ class Post(NiceModel): # type: ignore [django-manager-missing] 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`)?"), + ) def __str__(self): return f"{self.title} | {self.author.first_name} {self.author.last_name}" diff --git a/blog/urls.py b/blog/urls.py index ac04560f..cd00f4f7 100644 --- a/blog/urls.py +++ b/blog/urls.py @@ -5,9 +5,9 @@ from blog.viewsets import PostViewSet app_name = "blog" -payment_router = DefaultRouter() -payment_router.register(prefix=r"posts", viewset=PostViewSet, basename="posts") +blog_router = DefaultRouter() +blog_router.register(prefix=r"posts", viewset=PostViewSet, basename="posts") urlpatterns = [ - path(r"", include(payment_router.urls)), + path(r"", include(blog_router.urls)), ] diff --git a/core/sitemaps.py b/core/sitemaps.py index 93ecd6ad..18496eb7 100644 --- a/core/sitemaps.py +++ b/core/sitemaps.py @@ -2,51 +2,52 @@ from django.conf import settings from django.contrib.sitemaps import Sitemap from django.utils.translation import gettext_lazy as _ +from blog.models import Post from core.models import Brand, Category, Product -from core.utils.seo_builders import any_non_digital -from evibes.settings import LANGUAGE_CODE -class StaticPagesSitemap(Sitemap): # type: ignore [type-arg] +class SitemapLanguageMixin: + def _lang(self) -> str: + req = getattr(self, "request", None) + return getattr(req, "LANGUAGE_CODE", settings.LANGUAGE_CODE) + + +class StaticPagesSitemap(SitemapLanguageMixin, Sitemap): # type: ignore [type-arg] protocol = "https" changefreq = "monthly" priority = 0.8 limit = 1000 - PAGES = [ - { - "name": _("Home"), - "path": f"/{LANGUAGE_CODE}", - "lastmod": settings.RELEASE_DATE, - }, - { - "name": _("Contact Us"), - "path": f"/{LANGUAGE_CODE}/contact-us", - "lastmod": settings.RELEASE_DATE, - }, - { - "name": _("About Us"), - "path": f"/{LANGUAGE_CODE}/about-us", - "lastmod": settings.RELEASE_DATE, - }, - { - "name": _("Payment Information"), - "path": f"/{LANGUAGE_CODE}/help/payments", - "lastmod": settings.RELEASE_DATE, - }, - ] - - if any_non_digital(): - PAGES.append( - { - "name": _("Delivery"), - "path": f"/{LANGUAGE_CODE}/help/delivery", - "lastmod": settings.RELEASE_DATE, - } - ) - def items(self): - return self.PAGES + lang = self._lang() + pages = [ + { + "name": _("Home"), + "path": f"/{lang}", + "lastmod": settings.RELEASE_DATE, + }, + { + "name": _("Contact Us"), + "path": f"/{lang}/contact-us", + "lastmod": settings.RELEASE_DATE, + }, + { + "name": _("About Us"), + "path": f"/{lang}/about-us", + "lastmod": settings.RELEASE_DATE, + }, + ] + + 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, + "path": f"/{lang}/information/{static_post_page.slug}", + "lastmod": static_post_page.modified, + } + ) + + return pages def location(self, obj): return obj["path"] @@ -55,33 +56,7 @@ class StaticPagesSitemap(Sitemap): # type: ignore [type-arg] return obj.get("lastmod") -# class FeaturedProductsSitemap(Sitemap): # type: ignore [type-arg] -# protocol = "https" -# changefreq = "daily" -# priority = 0.9 -# limit = 25000 -# -# def items(self): -# return ( -# Product.objects.filter( -# is_active=True, -# brand__is_active=True, -# category__is_active=True, -# stocks__isnull=False, -# stocks__vendor__is_active=True, -# ) -# .only("uuid", "name", "modified", "slug") -# .order_by("-modified") -# ) -# -# def lastmod(self, obj): -# return obj.modified -# -# def location(self, obj): -# return f"/{LANGUAGE_CODE}/product/{obj.slug}" - - -class ProductSitemap(Sitemap): # type: ignore [type-arg] +class ProductSitemap(SitemapLanguageMixin, Sitemap): # type: ignore [type-arg] protocol = "https" changefreq = "daily" priority = 0.9 @@ -104,10 +79,10 @@ class ProductSitemap(Sitemap): # type: ignore [type-arg] return obj.modified def location(self, obj): - return f"/{LANGUAGE_CODE}/product/{obj.slug}" + return f"/{self._lang()}/product/{obj.slug if obj.slug else '404-non-existent-product'}" -class CategorySitemap(Sitemap): # type: ignore [type-arg] +class CategorySitemap(SitemapLanguageMixin, Sitemap): # type: ignore [type-arg] protocol = "https" changefreq = "weekly" priority = 0.7 @@ -120,10 +95,10 @@ class CategorySitemap(Sitemap): # type: ignore [type-arg] return obj.modified def location(self, obj): - return f"/{LANGUAGE_CODE}/catalog/{obj.slug}" + return f"/{self._lang()}/catalog/{obj.slug if obj.slug else '404-non-existent-category'}" -class BrandSitemap(Sitemap): # type: ignore [type-arg] +class BrandSitemap(SitemapLanguageMixin, Sitemap): # type: ignore [type-arg] protocol = "https" changefreq = "weekly" priority = 0.6 @@ -136,4 +111,4 @@ class BrandSitemap(Sitemap): # type: ignore [type-arg] return obj.modified def location(self, obj): - return f"/{LANGUAGE_CODE}/brand/{obj.slug}" + return f"/{self._lang()}/brand/{obj.slug if obj.slug else '404-non-existent-brand'}" From 3b7c405e84fd400ba4e9527ed0d4a1c3c374f377 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Tue, 21 Oct 2025 12:23:02 +0300 Subject: [PATCH 11/17] Features: 1) Add `GatewayForm` to manage Gateway model; 2) Enhance `GatewayAdmin` with list display, search, and ordering; 3) Introduce `integration_variables` field in the Gateway model; 4) Expand support for currencies with symbols via `CURRENCIES_WITH_SYMBOLS`; Fixes: 1) Add missing Gateway import in forms and admin modules; 2) Correct maximum lengths for currency fields in the Gateway model; Extra: 1) Refactor README for clarity and conciseness; 2) Adjust formatting in `core/sitemaps.py` for readability. --- README.md | 201 +++++++++++++++++++--------------------- core/sitemaps.py | 4 +- evibes/settings/base.py | 46 ++++++++- payments/admin.py | 16 +++- payments/forms.py | 11 ++- payments/models.py | 14 ++- 6 files changed, 173 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 11ed606f..83e4e865 100644 --- a/README.md +++ b/README.md @@ -2,153 +2,138 @@ ![LOGO](core/docs/images/evibes-big.png) -eVibes — your store without the extra baggage. -Everything works out of the box: storefront, product catalog, cart, and orders. -Minimal complexity, maximum flexibility — install, adjust to your needs, and start selling. +eVibes — a lightweight, production-ready e‑commerce backend. Storefront, product catalog, cart, and orders work out of the box. Minimal complexity, maximum flexibility — install, adjust to your needs, and start selling. + +- Public issues: https://plane.wiseless.xyz/spaces/issues/dd33cb0ab9b04ef08a10f7eefae6d90c/?board=kanban ## Table of Contents -- [Features](#features) -- [Getting Started](#getting-started) - - [Prerequisites](#prerequisites) - - [Installation](#installation) -- [Configuration](#configuration) - - [Dockerfile](#Dockerfile) - - [nginx](#nginx) - - [.env](#env) -- [Usage](#usage) -- [Contact](#contact) +- Features +- Quick Start + - Prerequisites + - Installation +- Configuration + - Dockerfile + - nginx + - .env +- Usage +- Contributing +- Contact +- License ## Features -- **Modular Architecture**: Extend and customize the backend to fit your needs. -- **Dockerized Deployment**: Quick setup and deployment using Docker and Docker Compose. -- **Asynchronous Task Processing**: Integrated Celery workers and beat scheduler for background tasks. -- **GraphQL and REST APIs**: Supports both GraphQL and RESTful API endpoints. -- **Internationalization**: Multilingual support using modeltranslate. -- **Advanced Caching**: Utilizes Redis for caching and task queuing. -- **Security**: Implements JWT authentication and rate limiting. +- Modular backend, easy to extend and customize +- Dockerized deployment with Docker Compose +- Celery workers and beat for background tasks +- REST and GraphQL APIs +- Internationalization with modeltranslation +- Redis-based caching and queues +- JWT auth and rate limiting -## Getting Started +## Quick Start ### Prerequisites -- Docker and Docker Compose are installed on your machine. +- Docker and Docker Compose ### Installation -1. Clone the repository: - +1. Clone the repository ```bash git clone https://gitlab.com/wiseless.xyz/eVibes.git cd eVibes ``` -2. Choose the storefront. By default, `main` branch has no storefront included. -Skip this step if you're OK with that and plan to only use API or develop your own storefront. - +2. Choose a storefront (optional). The `main` branch ships without a storefront. If you want one, pick a branch: ```bash - git checkout storefront- + git checkout storefront- ``` -3. Generate your .env file. Check and confirm the contents afterward. +3. Generate your .env file and review its values + - Windows + ```powershell + scripts\Windows\generate-environment-file.ps1 + ``` + - Unix + ```bash + scripts/Unix/generate-environment-file.sh + ``` - - Windows - ```powershell - scripts\Windows\generate-environment-file.ps1 - ``` - - Unix - ```bash - scripts/Unix/generate-environment-file.sh - ``` +4. Install dependencies + - Windows + ```powershell + scripts\Windows\install.ps1 + ``` + - Unix + ```bash + scripts/Unix/install.sh + ``` -4. Install all the dependencies. +5. Run the stack + - Windows + ```powershell + scripts\Windows\run.ps1 + ``` + - Unix + ```bash + scripts/Unix/run.sh + ``` - - Windows - ```powershell - scripts\Windows\install.ps1 - ``` - - Unix - ```bash - scripts/Unix/install.sh - ``` - -5. Spin it up. - - - Windows - ```powershell - scripts\Windows\run.ps1 - ``` - - Unix - ```bash - scripts/Unix/run.sh - ``` - -6. Bring to production. - - Include `nginx` file to your nginx configuration, you really want to install and - run [Certbot](https://certbot.eff.org/) afterward! +6. Production checklist + - Include `nginx.conf` into your Nginx setup + - Issue TLS certs with Certbot (https://certbot.eff.org/) ## Configuration ### Dockerfile - -Remember to change the -`RUN sed -i 's|https://deb.debian.org/debian|https://ftp..debian.org/debian|g' /etc/apt/sources.list.d/debian.sources` -before running installment scripts +If you rely on locale mirrors, adjust Debian sources before running install scripts: +``` +RUN sed -i 's|https://deb.debian.org/debian|https://ftp..debian.org/debian|g' /etc/apt/sources.list.d/debian.sources +``` ### nginx - -Please comment-out SSL-related lines, then apply necessary configurations, run `certbot --cert-only --nginx`, -decomment previously commented lines, and enjoy eVibes over HTTPS! +- Comment out SSL-related lines +- Apply your domain-specific settings +- Run `certbot --cert-only --nginx` +- Uncomment SSL lines and reload Nginx ### .env - -After .env file generation, you may want to edit some of its values, such as macroservices` API keys, database password, -redis password, etc. +After generation, review and update secrets and credentials (API keys, DB password, Redis password, etc.). ## Usage -- Add the necessary subdomains to DNS-settings of your domain, those are: +- DNS records you’ll typically want: + 1. @.your-domain.com + 2. www.your-domain.com + 3. api.your-domain.com + 4. b2b.your-domain.com + 5. prometheus.your-domain.com -1. @.your-domain.com -2. www.your-domain.com -3. api.your-domain.com -4. b2b.your-domain.com -5. prometheus.your-domain.com - -- Add these lines to your hosts-file to use django-hosts functionality on localhost(*DEVELOPMENT ONLY*): - -```hosts -127.0.0.1 api.localhost -127.0.0.1 b2b.localhost -``` - -Once the services are up and running, you can access the application at -`http://api.your-domain.com`(http://api.localhost:8000). - -- **Django Admin**: `http://api.your-domain.com/` (will redirect to admin) -- **API Docs**: - - REST API: `http://api.localhost:8000/docs/swagger` or `http://api.localhost:8000/docs/redoc` - - GraphQL API: `http://api.localhost:8000/graphql/` - -## Uninstall eVibes - -You are not planning to do that, aren't you? - -- Windows - ```powershell - scripts\Windows\uninstall.ps1 - ``` -- Unix - ```bash - scripts/Unix/uninstall.sh +- For local development, add hosts entries (development only): + ```hosts + 127.0.0.1 api.localhost + 127.0.0.1 b2b.localhost ``` +- Once running, access: + - API root / Admin redirect: http://api.localhost:8000/ + - REST docs: http://api.localhost:8000/docs/swagger or http://api.localhost:8000/docs/redoc + - GraphQL: http://api.localhost:8000/graphql/ + +## Contributing + +- Track and report issues here: https://plane.wiseless.xyz/spaces/issues/dd33cb0ab9b04ef08a10f7eefae6d90c/?board=list +- Pull requests are welcome. Please keep changes minimal and focused. + ## Contact -- **Author**: Egor "fureunoir" Gorbunov - - Email: contact@fureunoir.com - - Telegram: [@fureunoir](https://t.me/fureunoir) +- Author: Egor "fureunoir" Gorbunov + - Email: contact@fureunoir.com + - Telegram: https://t.me/fureunoir + +## License + +This project is licensed under the terms of the LICENSE file included in this repository. ![FAVICON](core/docs/images/evibes.png) \ No newline at end of file diff --git a/core/sitemaps.py b/core/sitemaps.py index 18496eb7..24db6dfc 100644 --- a/core/sitemaps.py +++ b/core/sitemaps.py @@ -38,7 +38,9 @@ class StaticPagesSitemap(SitemapLanguageMixin, Sitemap): # type: ignore [type-a }, ] - for static_post_page in Post.objects.filter(is_static_page=True, is_active=True).only("title", "slug", "modified"): + for static_post_page in Post.objects.filter(is_static_page=True, is_active=True).only( + "title", "slug", "modified" + ): pages.append( { "name": static_post_page.title, diff --git a/evibes/settings/base.py b/evibes/settings/base.py index 25c66bdf..9ec2107d 100644 --- a/evibes/settings/base.py +++ b/evibes/settings/base.py @@ -222,28 +222,64 @@ LANGUAGES: tuple[tuple[str, str], ...] = ( LANGUAGE_CODE: str = "en-gb" -CURRENCIES: tuple[tuple[str, str], ...] = ( - ("en-gb", "EUR"), +CURRENCIES_BY_LANGUAGES: tuple[tuple[str, str], ...] = ( ("ar-ar", "AED"), ("cs-cz", "CZK"), - ("da-dk", "EUR"), + ("da-dk", "DKK"), ("de-de", "EUR"), + ("en-gb", "GBP"), ("en-us", "USD"), ("es-es", "EUR"), + ("fa-ir", "IRR"), ("fr-fr", "EUR"), + ("he-il", "ILS"), ("hi-in", "INR"), + ("hr-hr", "EUR"), + ("id-id", "IDR"), ("it-it", "EUR"), ("ja-jp", "JPY"), ("kk-kz", "KZT"), + ("ko-kr", "KRW"), ("nl-nl", "EUR"), + ("no-no", "NOK"), ("pl-pl", "PLN"), - ("pt-br", "EUR"), + ("pt-br", "BRL"), ("ro-ro", "RON"), ("ru-ru", "RUB"), + ("sv-se", "SEK"), + ("th-th", "THB"), + ("tr-tr", "TRY"), + ("vi-vn", "VND"), ("zh-hans", "CNY"), ) -CURRENCY_CODE: str = dict(CURRENCIES).get(LANGUAGE_CODE) # type: ignore [assignment] +CURRENCIES_WITH_SYMBOLS: tuple[tuple[str, str], ...] = ( + ("AED", "د.إ"), + ("BRL", "R$"), + ("CNY", "¥"), + ("CZK", "Kč"), + ("DKK", "kr"), + ("EUR", "€"), + ("GBP", "£"), + ("IDR", "Rp"), + ("ILS", "₪"), + ("INR", "₹"), + ("IRR", "﷼"), + ("JPY", "¥"), + ("KRW", "₩"), + ("KZT", "₸"), + ("NOK", "kr"), + ("PLN", "zł"), + ("RON", "lei"), + ("RUB", "₽"), + ("SEK", "kr"), + ("THB", "฿"), + ("TRY", "₺"), + ("USD", "$"), + ("VND", "₫"), +) + +CURRENCY_CODE: str = dict(CURRENCIES_BY_LANGUAGES).get(LANGUAGE_CODE) # type: ignore[assignment] MODELTRANSLATION_FALLBACK_LANGUAGES: tuple[str, ...] = (LANGUAGE_CODE, "en-us", "de-de") diff --git a/payments/admin.py b/payments/admin.py index bc22b6dc..1f9e4c9c 100644 --- a/payments/admin.py +++ b/payments/admin.py @@ -5,7 +5,7 @@ from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ from core.admin import ActivationActionsMixin -from payments.forms import TransactionForm +from payments.forms import TransactionForm, GatewayForm from payments.models import Balance, Transaction @@ -41,3 +41,17 @@ class TransactionAdmin(ActivationActionsMixin, ModelAdmin): # type: ignore [mis list_filter = ("currency", "payment_method") ordering = ("balance",) form = TransactionForm + + +class GatewayAdmin(ActivationActionsMixin, ModelAdmin): + list_display = ( + "name", + "can_be_used", + "is_active", + ) + search_fields = ( + "name", + "default_currency", + ) + ordering = ("name",) + form = GatewayForm diff --git a/payments/forms.py b/payments/forms.py index 00952e67..b752d00b 100644 --- a/payments/forms.py +++ b/payments/forms.py @@ -1,7 +1,7 @@ from django import forms from core.widgets import JSONTableWidget -from payments.models import Transaction +from payments.models import Gateway, Transaction class TransactionForm(forms.ModelForm): # type: ignore [type-arg] @@ -11,3 +11,12 @@ class TransactionForm(forms.ModelForm): # type: ignore [type-arg] widgets = { "process": JSONTableWidget(), } + + +class GatewayForm(forms.ModelForm): # type: ignore [type-arg] + class Meta: + model = Gateway + fields = "__all__" + widgets = { + "integration_variables": JSONTableWidget(), + } diff --git a/payments/models.py b/payments/models.py index 81f9a10d..a779ea0a 100644 --- a/payments/models.py +++ b/payments/models.py @@ -85,14 +85,21 @@ class Balance(NiceModel): class Gateway(NiceModel): name = CharField(max_length=20, null=False, blank=False, verbose_name=_("name")) default_currency = CharField( - max_length=3, null=False, blank=False, verbose_name=_("default currency"), choices=settings.CURRENCIES + max_length=4, + null=False, + blank=False, + verbose_name=_("default currency"), + choices=settings.CURRENCIES_WITH_SYMBOLS, ) currencies = CharField( - max_length=3, + max_length=255, null=False, blank=False, verbose_name=_("currencies"), - help_text=_(f"comma separated list of currencies supported by this gateway, choose from {settings.CURRENCIES}"), + help_text=_( + f"comma separated list of currencies supported by this gateway, " + f"choose from {', '.join([code for code, _ in settings.CURRENCIES_WITH_SYMBOLS])}" + ), ) integration_path = CharField(max_length=255, null=True, blank=True) minimum_transaction_amount = FloatField( @@ -116,6 +123,7 @@ class Gateway(NiceModel): 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")) def __str__(self): return self.name From a87cb31d7e5aa9679491dd6de20b81b0e9e0d9ad Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Tue, 21 Oct 2025 12:25:24 +0300 Subject: [PATCH 12/17] Features: 1) Add `Gateway` model with fields for UUID, activation status, currency details, limits, and integration variables; 2) Add `gateway` foreign key to `Transaction` model; 3) Introduce `is_static_page` field to `Post` model to differentiate static pages; 4) Add `integration_path` field to `Vendor` model. Fixes: 1) Correct import order in `payments.admin`. Extra: 1) Update `productimage.priority` field in `core` app with default value and help text; 2) Include `integration_path` in `Vendor` admin additional fields. --- blog/migrations/0007_post_is_static_page.py | 22 +++ core/admin.py | 1 + ...ration_path_alter_productimage_priority.py | 33 ++++ payments/admin.py | 2 +- .../0005_gateway_transaction_gateway.py | 156 ++++++++++++++++++ 5 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 blog/migrations/0007_post_is_static_page.py create mode 100644 core/migrations/0048_vendor_integration_path_alter_productimage_priority.py create mode 100644 payments/migrations/0005_gateway_transaction_gateway.py diff --git a/blog/migrations/0007_post_is_static_page.py b/blog/migrations/0007_post_is_static_page.py new file mode 100644 index 00000000..2f3a971e --- /dev/null +++ b/blog/migrations/0007_post_is_static_page.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2 on 2025-10-21 09:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("blog", "0006_post_meta_description_post_meta_description_ar_ar_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="post", + name="is_static_page", + field=models.BooleanField( + default=False, + help_text="is this a post for a page with static URL (e.g. `/help/delivery`)?", + verbose_name="is static page", + ), + ), + ] diff --git a/core/admin.py b/core/admin.py index 8a1c0326..209ab8e8 100644 --- a/core/admin.py +++ b/core/admin.py @@ -518,6 +518,7 @@ class VendorAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type: "users", ] additional_fields = [ + "integration_path", "last_processing_response", "b2b_auth_token", ] diff --git a/core/migrations/0048_vendor_integration_path_alter_productimage_priority.py b/core/migrations/0048_vendor_integration_path_alter_productimage_priority.py new file mode 100644 index 00000000..4b7d4719 --- /dev/null +++ b/core/migrations/0048_vendor_integration_path_alter_productimage_priority.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2 on 2025-10-21 09:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0047_alter_attribute_unique_together"), + ] + + operations = [ + migrations.AddField( + model_name="vendor", + name="integration_path", + field=models.CharField( + blank=True, + help_text="vendor's integration file path", + max_length=255, + null=True, + verbose_name="integration path", + ), + ), + migrations.AlterField( + model_name="productimage", + name="priority", + field=models.PositiveIntegerField( + default=1, + help_text="determines the order in which images are displayed", + verbose_name="display priority", + ), + ), + ] diff --git a/payments/admin.py b/payments/admin.py index 1f9e4c9c..a557b458 100644 --- a/payments/admin.py +++ b/payments/admin.py @@ -5,7 +5,7 @@ from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ from core.admin import ActivationActionsMixin -from payments.forms import TransactionForm, GatewayForm +from payments.forms import GatewayForm, TransactionForm from payments.models import Balance, Transaction diff --git a/payments/migrations/0005_gateway_transaction_gateway.py b/payments/migrations/0005_gateway_transaction_gateway.py new file mode 100644 index 00000000..59d38dac --- /dev/null +++ b/payments/migrations/0005_gateway_transaction_gateway.py @@ -0,0 +1,156 @@ +# Generated by Django 5.2 on 2025-10-21 09:24 + +import django.db.models.deletion +import django_extensions.db.fields +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payments", "0004_alter_transaction_payment_method"), + ] + + operations = [ + migrations.CreateModel( + name="Gateway", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="unique id is used to surely identify any database object", + primary_key=True, + serialize=False, + verbose_name="unique id", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="if set to false, this object can't be seen by users without needed permission", + verbose_name="is active", + ), + ), + ( + "created", + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, + help_text="when the object first appeared on the database", + verbose_name="created", + ), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, + help_text="when the object was last modified", + verbose_name="modified", + ), + ), + ("name", models.CharField(max_length=20, verbose_name="name")), + ( + "default_currency", + models.CharField( + choices=[ + ("AED", "د.إ"), + ("BRL", "R$"), + ("CNY", "¥"), + ("CZK", "Kč"), + ("DKK", "kr"), + ("EUR", "€"), + ("GBP", "£"), + ("IDR", "Rp"), + ("ILS", "₪"), + ("INR", "₹"), + ("IRR", "﷼"), + ("JPY", "¥"), + ("KRW", "₩"), + ("KZT", "₸"), + ("NOK", "kr"), + ("PLN", "zł"), + ("RON", "lei"), + ("RUB", "₽"), + ("SEK", "kr"), + ("THB", "฿"), + ("TRY", "₺"), + ("USD", "$"), + ("VND", "₫"), + ], + max_length=4, + verbose_name="default currency", + ), + ), + ( + "currencies", + models.CharField( + help_text="comma separated list of currencies supported by this gateway, choose from AED, BRL, CNY, CZK, DKK, EUR, GBP, IDR, ILS, INR, IRR, JPY, KRW, KZT, NOK, PLN, RON, RUB, SEK, THB, TRY, USD, VND", + max_length=255, + verbose_name="currencies", + ), + ), + ( + "integration_path", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "minimum_transaction_amount", + models.FloatField( + default=0, verbose_name="minimum transaction amount" + ), + ), + ( + "maximum_transaction_amount", + models.FloatField( + default=0, verbose_name="maximum transaction amount" + ), + ), + ( + "daily_limit", + models.PositiveIntegerField( + default=0, + help_text="daily sum limit of transactions' amounts. 0 means no limit", + verbose_name="daily limit", + ), + ), + ( + "monthly_limit", + models.PositiveIntegerField( + default=0, + help_text="monthly sum limit of transactions' amounts. 0 means no limit", + verbose_name="monthly limit", + ), + ), + ( + "priority", + models.PositiveIntegerField( + default=10, unique=True, verbose_name="priority" + ), + ), + ( + "integration_variables", + models.JSONField( + default=dict, verbose_name="integration variables" + ), + ), + ], + options={ + "verbose_name": "payment gateway", + "verbose_name_plural": "payment gateways", + }, + ), + migrations.AddField( + model_name="transaction", + name="gateway", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="transactions", + to="payments.gateway", + ), + ), + ] From 5384fa494d5f71bc0210a1d8c4afbbe85d29474e Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Tue, 21 Oct 2025 12:33:17 +0300 Subject: [PATCH 13/17] Features: 1) Add `get_integration_class_object` method to `Transaction` for dynamic gateway class instantiation; Fixes: 1) Validate gateway presence in `process_transaction_changes` signal; 2) Add missing imports for `Type`, `create_object`, and `AbstractGateway`; Extra: 1) Cleanup unused diff lines in migrations files; 2) Improve JSONField, FloatField, and PositiveIntegerField declarations by consolidating into single lines; 3) Remove redundant match-case logic in `signals.py`. --- blog/migrations/0007_post_is_static_page.py | 1 - ...egration_path_alter_productimage_priority.py | 1 - .../0005_gateway_transaction_gateway.py | 17 ++++------------- payments/models.py | 15 +++++++++++++++ payments/signals.py | 11 +++-------- 5 files changed, 22 insertions(+), 23 deletions(-) diff --git a/blog/migrations/0007_post_is_static_page.py b/blog/migrations/0007_post_is_static_page.py index 2f3a971e..051728de 100644 --- a/blog/migrations/0007_post_is_static_page.py +++ b/blog/migrations/0007_post_is_static_page.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("blog", "0006_post_meta_description_post_meta_description_ar_ar_and_more"), ] diff --git a/core/migrations/0048_vendor_integration_path_alter_productimage_priority.py b/core/migrations/0048_vendor_integration_path_alter_productimage_priority.py index 4b7d4719..220042f4 100644 --- a/core/migrations/0048_vendor_integration_path_alter_productimage_priority.py +++ b/core/migrations/0048_vendor_integration_path_alter_productimage_priority.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("core", "0047_alter_attribute_unique_together"), ] diff --git a/payments/migrations/0005_gateway_transaction_gateway.py b/payments/migrations/0005_gateway_transaction_gateway.py index 59d38dac..506c5f6b 100644 --- a/payments/migrations/0005_gateway_transaction_gateway.py +++ b/payments/migrations/0005_gateway_transaction_gateway.py @@ -7,7 +7,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("payments", "0004_alter_transaction_payment_method"), ] @@ -98,15 +97,11 @@ 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", @@ -126,15 +121,11 @@ class Migration(migrations.Migration): ), ( "priority", - models.PositiveIntegerField( - default=10, unique=True, verbose_name="priority" - ), + models.PositiveIntegerField(default=10, unique=True, verbose_name="priority"), ), ( "integration_variables", - models.JSONField( - default=dict, verbose_name="integration variables" - ), + models.JSONField(default=dict, verbose_name="integration variables"), ), ], options={ diff --git a/payments/models.py b/payments/models.py index a779ea0a..78fc9c68 100644 --- a/payments/models.py +++ b/payments/models.py @@ -1,3 +1,5 @@ +from typing import Type + from constance import config from django.conf import settings from django.contrib.postgres.indexes import GinIndex @@ -16,6 +18,8 @@ from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from core.abstract import NiceModel +from evibes.utils.misc import create_object +from payments.gateways import AbstractGateway class Transaction(NiceModel): @@ -147,3 +151,14 @@ class Gateway(NiceModel): monthly_ok = self.monthly_limit == 0 or monthly_sum < self.monthly_limit return daily_ok and monthly_ok + + def get_integration_class_object(self, raise_exc: bool = True) -> Type[AbstractGateway] | None: + if not self.integration_path: + if raise_exc: + raise ValueError(_("gateway integration path is not set")) + return None + 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 + return create_object(module_name, class_name) diff --git a/payments/signals.py b/payments/signals.py index dfaae48a..43fc689d 100644 --- a/payments/signals.py +++ b/payments/signals.py @@ -24,15 +24,10 @@ def create_balance_on_user_creation_signal(instance: User, created: bool, **kwar @receiver(post_save, sender=Transaction) def process_transaction_changes(instance: Transaction, created: bool, **kwargs: dict[Any, Any]) -> None: if created: + if not instance.gateway: + raise ValueError("gateway is required to process a transaction") try: - gateway = None - match instance.process.get("gateway", "default"): - case "gateway": - gateway = AbstractGateway() - case "default": - gateway = AbstractGateway() - case _: - gateway = AbstractGateway() + gateway = instance.gateway.get_integration_class_object() gateway.process_transaction(instance) except Exception as e: instance.process = {"status": "ERRORED", "error": str(e)} From 5b06f83cfcfefbe639fdc35a2baf5641a7a4cc0a Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Tue, 21 Oct 2025 12:36:53 +0300 Subject: [PATCH 14/17] Features: 1) None; Fixes: 1) Remove unused import of `AbstractGateway` from `payments/signals.py`; 2) Adjust type hints in `AbstractGateway` methods to use `Any` instead of `Transaction`; Extra: 1) Replace unused import of `Transaction` in `payments/gateways/__init__.py` with `Any`. --- payments/gateways/__init__.py | 6 +++--- payments/signals.py | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/payments/gateways/__init__.py b/payments/gateways/__init__.py index f4926b44..bd777365 100644 --- a/payments/gateways/__init__.py +++ b/payments/gateways/__init__.py @@ -1,4 +1,4 @@ -from payments.models import Transaction +from typing import Any class UnknownGatewayError(Exception): @@ -7,9 +7,9 @@ class UnknownGatewayError(Exception): class AbstractGateway: @staticmethod - def process_transaction(transaction: Transaction) -> None: + def process_transaction(transaction: Any) -> None: raise NotImplementedError @staticmethod - def process_callback(transaction: Transaction) -> None: + def process_callback(transaction: Any) -> None: raise NotImplementedError diff --git a/payments/signals.py b/payments/signals.py index 43fc689d..32e3dd59 100644 --- a/payments/signals.py +++ b/payments/signals.py @@ -5,7 +5,6 @@ from typing import Any from django.db.models.signals import post_save from django.dispatch import receiver -from payments.gateways import AbstractGateway from payments.models import Balance, Transaction from payments.utils.emailing import balance_deposit_email from vibes_auth.models import User From 45d20bc17eed8e203d2e565e35d523c7fdb1960e Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Tue, 21 Oct 2025 12:40:21 +0300 Subject: [PATCH 15/17] Features: 1) Add integration_path filter to active vendor query in get_vendors_integrations; Fixes: 1) Ensure only vendors with a valid integration_path are included in the results; Extra: 1) Adjust query formatting for improved readability. --- core/utils/vendors.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/core/utils/vendors.py b/core/utils/vendors.py index f68a4c31..61a210e9 100644 --- a/core/utils/vendors.py +++ b/core/utils/vendors.py @@ -7,7 +7,18 @@ from evibes.utils.misc import create_object def get_vendors_integrations(name: str | None = None) -> list[Type[AbstractVendor]]: vendors_integrations: list[Type[AbstractVendor]] = [] - vendors = Vendor.objects.filter(is_active=True, name=name) if name else Vendor.objects.filter(is_active=True) + vendors = ( + Vendor.objects.filter( + is_active=True, + integration_path__isnull=False, + name=name, + ) + if name + else Vendor.objects.filter( + is_active=True, + integration_path__isnull=False, + ) + ) for vendor in vendors: if vendor.integration_path: module_name = ".".join(vendor.integration_path.split(".")[:-1]) From 202964aea604bf9d71be5690c2d6cc3cc75cffe5 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Tue, 21 Oct 2025 12:44:19 +0300 Subject: [PATCH 16/17] Features: 1) Add `_in_celery_task` utility function to detect task execution context; 2) Enhance vendor integration retrieval with context-specific logging (async/sync); 3) Improve error handling in vendor integration loading with exception logging; Fixes: None; Extra: 1) Refactor vendor filtering logic for clarity; 2) Add imports for logging, contextlib, and Celery utilities; --- core/utils/vendors.py | 42 +++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/core/utils/vendors.py b/core/utils/vendors.py index 61a210e9..462f2eab 100644 --- a/core/utils/vendors.py +++ b/core/utils/vendors.py @@ -1,27 +1,39 @@ +import logging +from contextlib import suppress from typing import Type +from celery import current_task +from celery.utils.log import get_task_logger + from core.models import Vendor from core.vendors import AbstractVendor from evibes.utils.misc import create_object +sync_logger = logging.getLogger("django") +async_logger = get_task_logger(__name__) + + +def _in_celery_task() -> bool: + task = current_task + with suppress(Exception): + return bool(task and getattr(task, "request", None) and getattr(task.request, "id", None)) + return False + def get_vendors_integrations(name: str | None = None) -> list[Type[AbstractVendor]]: vendors_integrations: list[Type[AbstractVendor]] = [] - vendors = ( - Vendor.objects.filter( - is_active=True, - integration_path__isnull=False, - name=name, - ) - if name - else Vendor.objects.filter( - is_active=True, - integration_path__isnull=False, - ) - ) + + vendors = Vendor.objects.filter(is_active=True, integration_path__isnull=False) + if name: + vendors = vendors.filter(name=name) + + logger = async_logger if _in_celery_task() else sync_logger + for vendor in vendors: - if vendor.integration_path: - module_name = ".".join(vendor.integration_path.split(".")[:-1]) - class_name = vendor.integration_path.split(".")[-1] + try: + module_name, class_name = vendor.integration_path.rsplit(".", 1) vendors_integrations.append(create_object(module_name, class_name)) + except Exception as e: + logger.warning("Couldn't load integration for vendor %s: %s", vendor.name, e) + return vendors_integrations From 33fbbc049ab4c41948f3f15666266e3078c35719 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Tue, 21 Oct 2025 12:58:55 +0300 Subject: [PATCH 17/17] Features: 1) Register `Gateway` model in the admin interface; Fixes: 1) Add missing `Gateway` import to `payments/admin.py`; Extra: 1) No extra changes made; --- payments/admin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/payments/admin.py b/payments/admin.py index a557b458..2e8ad440 100644 --- a/payments/admin.py +++ b/payments/admin.py @@ -6,7 +6,7 @@ from django.utils.translation import gettext_lazy as _ from core.admin import ActivationActionsMixin from payments.forms import GatewayForm, TransactionForm -from payments.models import Balance, Transaction +from payments.models import Balance, Transaction, Gateway class TransactionInline(admin.TabularInline): # type: ignore [type-arg] @@ -43,6 +43,7 @@ class TransactionAdmin(ActivationActionsMixin, ModelAdmin): # type: ignore [mis form = TransactionForm +@register(Gateway) class GatewayAdmin(ActivationActionsMixin, ModelAdmin): list_display = ( "name",