From f52227973b981cbfbcd1c2f4f01914a2c6d00de5 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Mon, 28 Jul 2025 11:56:32 +0300 Subject: [PATCH] Features: 1) Add `b2b_auth_token` and `users` fields to `Vendor` model with associated migration; 2) Introduce unique constraint for `b2b_auth_token` with migration; 3) Enhance `VendorAdmin` and `Vendor` model's save method to manage related users and token fields automatically; Fixes: 1) Adjust `is_business` property logic for accuracy; 2) Fix import cleanup in serializers and utils files; Extra: Refactor `core.models`, `core.utils`, and `core.vendors` for improved type annotations and other minor adjustments; Expand mypy exclusions in `pyproject.toml`. --- core/admin.py | 4 +- core/graphene/object_types.py | 1 + ...0036_vendor_b2b_auth_token_vendor_users.py | 33 ++++++++ .../0037_alter_vendor_b2b_auth_token.py | 25 ++++++ core/models.py | 24 +++++- core/serializers/detail.py | 2 - core/utils/__init__.py | 7 ++ core/vendors/__init__.py | 32 ++++---- evibes/settings/constance.py | 76 ++++++++++--------- pyproject.toml | 2 +- vibes_auth/admin.py | 2 +- 11 files changed, 146 insertions(+), 62 deletions(-) create mode 100644 core/migrations/0036_vendor_b2b_auth_token_vendor_users.py create mode 100644 core/migrations/0037_alter_vendor_b2b_auth_token.py diff --git a/core/admin.py b/core/admin.py index 2f11dc50..d7bfbcf0 100644 --- a/core/admin.py +++ b/core/admin.py @@ -312,8 +312,8 @@ class VendorAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type: readonly_fields = ("uuid", "modified", "created") form = VendorForm - general_fields = ["is_active", "name", "markup_percent", "authentication"] - relation_fields = [] + general_fields = ["is_active", "name", "markup_percent", "authentication", "b2b_auth_token"] + relation_fields = ["users"] @register(Feedback) diff --git a/core/graphene/object_types.py b/core/graphene/object_types.py index 28f0fb42..8bd074e7 100644 --- a/core/graphene/object_types.py +++ b/core/graphene/object_types.py @@ -13,6 +13,7 @@ from graphene import ( ObjectType, String, relay, + Boolean, ) from graphene.types.generic import GenericScalar from graphene_django import DjangoObjectType diff --git a/core/migrations/0036_vendor_b2b_auth_token_vendor_users.py b/core/migrations/0036_vendor_b2b_auth_token_vendor_users.py new file mode 100644 index 00000000..81af09b7 --- /dev/null +++ b/core/migrations/0036_vendor_b2b_auth_token_vendor_users.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2 on 2025-07-28 08:55 + +import core.utils +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0035_alter_brand_slug_alter_category_slug_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="vendor", + name="b2b_auth_token", + field=models.CharField( + blank=True, + default=core.utils.generate_human_readable_token, + max_length=20, + null=True, + ), + ), + migrations.AddField( + model_name="vendor", + name="users", + field=models.ManyToManyField( + blank=True, related_name="vendors", to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/core/migrations/0037_alter_vendor_b2b_auth_token.py b/core/migrations/0037_alter_vendor_b2b_auth_token.py new file mode 100644 index 00000000..eea70237 --- /dev/null +++ b/core/migrations/0037_alter_vendor_b2b_auth_token.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2 on 2025-07-28 08:55 + +import core.utils +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0036_vendor_b2b_auth_token_vendor_users"), + ] + + operations = [ + migrations.AlterField( + model_name="vendor", + name="b2b_auth_token", + field=models.CharField( + blank=True, + default=core.utils.generate_human_readable_token, + max_length=20, + null=True, + unique=True, + ), + ), + ] diff --git a/core/models.py b/core/models.py index cd16dd9b..a34a6488 100644 --- a/core/models.py +++ b/core/models.py @@ -50,6 +50,7 @@ from core.utils import ( generate_human_readable_id, get_product_uuid_as_path, get_random_code, + generate_human_readable_token, ) from core.utils.db import TweakedAutoSlugField, unicode_slugify_function from core.utils.lists import FAILED_STATUSES @@ -164,10 +165,23 @@ class Vendor(ExportModelOperationsMixin("vendor"), NiceModel): # type: ignore [ null=False, unique=True, ) + users = ManyToManyField(to="vibes_auth.User", related_name="vendors", blank=True) + b2b_auth_token = CharField(default=generate_human_readable_token, max_length=20, unique=True, null=True, blank=True) def __str__(self) -> str: return self.name + def save(self, **kwargs): + users = self.users.filter(is_active=True) + users = users.exclude(attributes__icontains="is_business") + if users.count() > 0: + for user in users: + if not user.attributes: + user.attributes = {} + user.attributes.update({"is_business": True}) + user.save() + return super().save(**kwargs) + class Meta: verbose_name = _("vendor") verbose_name_plural = _("vendors") @@ -1404,9 +1418,11 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi @property def is_business(self) -> bool: - return self.attributes.get("is_business", False) if self.attributes else False + return (self.user.attributes.get("is_business", False) if self.user else False) or ( + self.attributes.get("is_business", False) if self.attributes else False + ) - def save(self, **kwargs): + def save(self, **kwargs: dict) -> Self: pending_orders = 0 if self.user: pending_orders = self.user.orders.filter(status="PENDING").count() @@ -1559,8 +1575,8 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi shipping_address = billing_address else: - billing_address = Address.objects.get(uuid=billing_address_uuid) # type: ignore [misc] - shipping_address = Address.objects.get(uuid=shipping_address_uuid) # type: ignore [misc] + billing_address = Address.objects.get(uuid=billing_address_uuid) + shipping_address = Address.objects.get(uuid=shipping_address_uuid) self.billing_address = billing_address self.shipping_address = shipping_address diff --git a/core/serializers/detail.py b/core/serializers/detail.py index 0c6b446e..8feceb33 100644 --- a/core/serializers/detail.py +++ b/core/serializers/detail.py @@ -7,7 +7,6 @@ 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 -from rest_framework.request import Request from rest_framework.serializers import ModelSerializer from rest_framework_recursive.fields import RecursiveField @@ -31,7 +30,6 @@ from core.models import ( ) from core.serializers.simple import CategorySimpleSerializer, ProductSimpleSerializer from core.serializers.utility import AddressSerializer -from vibes_auth.models import User logger = logging.getLogger("django") diff --git a/core/utils/__init__.py b/core/utils/__init__.py index d2b9dbc9..4607b3c2 100644 --- a/core/utils/__init__.py +++ b/core/utils/__init__.py @@ -147,3 +147,10 @@ def generate_human_readable_id(length: int = 6) -> str: chars.insert(pos, "-") return "".join(chars) + + +def generate_human_readable_token() -> str: + """ + Generate a human-readable token of 20 characters (from the Crockford set), + """ + return "".join([secrets.choice(CROCKFORD) for _ in range(20)]) diff --git a/core/vendors/__init__.py b/core/vendors/__init__.py index 413b5320..b0868fa0 100644 --- a/core/vendors/__init__.py +++ b/core/vendors/__init__.py @@ -1,9 +1,11 @@ import json from contextlib import suppress +from decimal import Decimal from math import ceil, log10 from typing import Any from django.db import IntegrityError +from django.db.models import QuerySet from core.elasticsearch import process_query from core.models import ( @@ -73,13 +75,13 @@ class AbstractVendor: instance. """ - def __init__(self, vendor_name=None, currency="USD"): + def __init__(self, vendor_name: str = None, currency: str = "USD") -> None: self.vendor_name = vendor_name self.currency = currency self.blocked_attributes = [] @staticmethod - def chunk_data(data, num_chunks=20): + def chunk_data(data: dict = list, num_chunks: int = 20) -> list[dict[Any, Any]] | None: total = len(data) if total == 0: return [] @@ -87,7 +89,7 @@ class AbstractVendor: return [data[i : i + chunk_size] for i in range(0, total, chunk_size)] @staticmethod - def auto_convert_value(value: Any): + def auto_convert_value(value: Any) -> tuple[Any, str]: """ Attempts to convert a value to a more specific type. Handles booleans, numbers, objects (dicts), and arrays (lists), @@ -163,7 +165,7 @@ class AbstractVendor: queryset.delete() return chosen - def auto_resolve_category(self, category_name: str): + def auto_resolve_category(self, category_name: str = "") -> Category | None: if category_name: try: search = process_query(category_name) @@ -181,7 +183,7 @@ class AbstractVendor: return self.auto_resolver_helper(Category, category_name) - def auto_resolve_brand(self, brand_name: str): + def auto_resolve_brand(self, brand_name: str = "") -> Brand | None: if brand_name: try: search = process_query(brand_name) @@ -220,7 +222,7 @@ class AbstractVendor: return round(price, 2) - def resolve_price_with_currency(self, price, provider, currency=None): + def resolve_price_with_currency(self, price: float | int | Decimal, provider: str, currency: str = ""): rates = get_rates(provider) rate = rates.get(currency or self.currency) @@ -263,7 +265,7 @@ class AbstractVendor: return float(psychological) - def get_vendor_instance(self): + def get_vendor_instance(self) -> Vendor | None: try: vendor = Vendor.objects.get(name=self.vendor_name) if vendor.is_active: @@ -272,16 +274,16 @@ class AbstractVendor: except Vendor.DoesNotExist as dne: raise Exception(f"No matching vendor found with name {self.vendor_name!r}...") from dne - def get_products(self): + def get_products(self) -> None: pass - def get_products_queryset(self): + def get_products_queryset(self) -> QuerySet[Product] | None: return Product.objects.filter(stocks__vendor=self.get_vendor_instance(), orderproduct__isnull=True) - def get_stocks_queryset(self): + def get_stocks_queryset(self) -> QuerySet[Stock] | None: return Stock.objects.filter(product__in=self.get_products_queryset(), product__orderproduct__isnull=True) - def get_attribute_values_queryset(self): + def get_attribute_values_queryset(self) -> QuerySet[AttributeValue] | None: return AttributeValue.objects.filter( product__in=self.get_products_queryset(), product__orderproduct__isnull=True ) @@ -300,7 +302,7 @@ class AbstractVendor: case _: raise ValueError(f"Invalid method {method!r} for products update...") - def delete_inactives(self, inactivation_method: str = "deactivate", size: int = 5000): + def delete_inactives(self, inactivation_method: str = "deactivate", size: int = 5000) -> None: match inactivation_method: case "deactivate": filter_kwargs: dict[str, Any] = {"is_active": False} @@ -318,12 +320,12 @@ class AbstractVendor: ProductImage.objects.filter(product_id__in=batch_ids).delete() Product.objects.filter(pk__in=batch_ids).delete() - def delete_belongings(self): + def delete_belongings(self) -> None: self.get_products_queryset().delete() self.get_stocks_queryset().delete() self.get_attribute_values_queryset().delete() - def process_attribute(self, key: str, value, product: Product, attr_group: AttributeGroup): + def process_attribute(self, key: str, value, product: Product, attr_group: AttributeGroup) -> None: if not value: return @@ -375,5 +377,5 @@ class AbstractVendor: pass -def delete_stale(): +def delete_stale() -> None: Product.objects.filter(stocks__isnull=True, orderproduct__isnull=True).delete() diff --git a/evibes/settings/constance.py b/evibes/settings/constance.py index 2395f758..69b12293 100644 --- a/evibes/settings/constance.py +++ b/evibes/settings/constance.py @@ -50,43 +50,45 @@ CONSTANCE_CONFIG = OrderedDict( ] ) -CONSTANCE_CONFIG_FIELDSETS = OrderedDict({ - gettext_noop("General Options"): ( - "PROJECT_NAME", - "FRONTEND_DOMAIN", - "BASE_DOMAIN", - "COMPANY_NAME", - "COMPANY_ADDRESS", - "COMPANY_PHONE_NUMBER", - ), - gettext_noop("Email Options"): ( - "EMAIL_HOST", - "EMAIL_PORT", - "EMAIL_USE_TLS", - "EMAIL_USE_SSL", - "EMAIL_HOST_USER", - "EMAIL_HOST_PASSWORD", - "EMAIL_FROM", - ), - gettext_noop("Payment Gateway Options"): ( - "PAYMENT_GATEWAY_URL", - "PAYMENT_GATEWAY_TOKEN", - "EXCHANGE_RATE_API_KEY", - "PAYMENT_GATEWAY_MINIMUM", - "PAYMENT_GATEWAY_MAXIMUM", - ), - gettext_noop("Features Options"): ( - "DISABLED_COMMERCE", - "NOMINATIM_URL", - "OPENAI_API_KEY", - "ABSTRACT_API_KEY", - "HTTP_PROXY", - ), - gettext_noop("SEO Options"): ( - "ADVERTISEMENT_DATA", - "ANALYTICS_DATA", - ), -}) +CONSTANCE_CONFIG_FIELDSETS = OrderedDict( + { + gettext_noop("General Options"): ( + "PROJECT_NAME", + "FRONTEND_DOMAIN", + "BASE_DOMAIN", + "COMPANY_NAME", + "COMPANY_ADDRESS", + "COMPANY_PHONE_NUMBER", + ), + gettext_noop("Email Options"): ( + "EMAIL_HOST", + "EMAIL_PORT", + "EMAIL_USE_TLS", + "EMAIL_USE_SSL", + "EMAIL_HOST_USER", + "EMAIL_HOST_PASSWORD", + "EMAIL_FROM", + ), + gettext_noop("Payment Gateway Options"): ( + "PAYMENT_GATEWAY_URL", + "PAYMENT_GATEWAY_TOKEN", + "EXCHANGE_RATE_API_KEY", + "PAYMENT_GATEWAY_MINIMUM", + "PAYMENT_GATEWAY_MAXIMUM", + ), + gettext_noop("Features Options"): ( + "DISABLED_COMMERCE", + "NOMINATIM_URL", + "OPENAI_API_KEY", + "ABSTRACT_API_KEY", + "HTTP_PROXY", + ), + gettext_noop("SEO Options"): ( + "ADVERTISEMENT_DATA", + "ANALYTICS_DATA", + ), + } +) EXPOSABLE_KEYS = [ "PROJECT_NAME", diff --git a/pyproject.toml b/pyproject.toml index 41cbc366..56372b72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,7 +105,7 @@ linting = ["black", "isort", "flake8", "bandit"] [tool.mypy] strict = true disable_error_code = ["no-redef", "import-untyped"] -exclude = ["*/migrations/*"] +exclude = ["*/migrations/*", "storefront/*"] plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"] [tool.django-stubs] diff --git a/vibes_auth/admin.py b/vibes_auth/admin.py index 7dc34ba1..71dc1a1f 100644 --- a/vibes_auth/admin.py +++ b/vibes_auth/admin.py @@ -54,7 +54,7 @@ class UserAdmin(ActivationActionsMixin, BaseUserAdmin): # type: ignore [misc] (None, {"fields": ("email", "password")}), ( _("personal info"), - {"fields": ("first_name", "last_name", "phone_number", "avatar")}, + {"fields": ("first_name", "last_name", "phone_number", "avatar", "address_set")}, ), ( _("permissions"),