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`.
This commit is contained in:
Egor Pavlovich Gorbunov 2025-07-28 11:56:32 +03:00
parent 5ae198911b
commit f52227973b
11 changed files with 146 additions and 62 deletions

View file

@ -312,8 +312,8 @@ class VendorAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type:
readonly_fields = ("uuid", "modified", "created") readonly_fields = ("uuid", "modified", "created")
form = VendorForm form = VendorForm
general_fields = ["is_active", "name", "markup_percent", "authentication"] general_fields = ["is_active", "name", "markup_percent", "authentication", "b2b_auth_token"]
relation_fields = [] relation_fields = ["users"]
@register(Feedback) @register(Feedback)

View file

@ -13,6 +13,7 @@ from graphene import (
ObjectType, ObjectType,
String, String,
relay, relay,
Boolean,
) )
from graphene.types.generic import GenericScalar from graphene.types.generic import GenericScalar
from graphene_django import DjangoObjectType from graphene_django import DjangoObjectType

View file

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

View file

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

View file

@ -50,6 +50,7 @@ from core.utils import (
generate_human_readable_id, generate_human_readable_id,
get_product_uuid_as_path, get_product_uuid_as_path,
get_random_code, get_random_code,
generate_human_readable_token,
) )
from core.utils.db import TweakedAutoSlugField, unicode_slugify_function from core.utils.db import TweakedAutoSlugField, unicode_slugify_function
from core.utils.lists import FAILED_STATUSES from core.utils.lists import FAILED_STATUSES
@ -164,10 +165,23 @@ class Vendor(ExportModelOperationsMixin("vendor"), NiceModel): # type: ignore [
null=False, null=False,
unique=True, 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: def __str__(self) -> str:
return self.name 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: class Meta:
verbose_name = _("vendor") verbose_name = _("vendor")
verbose_name_plural = _("vendors") verbose_name_plural = _("vendors")
@ -1404,9 +1418,11 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi
@property @property
def is_business(self) -> bool: 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 pending_orders = 0
if self.user: if self.user:
pending_orders = self.user.orders.filter(status="PENDING").count() 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 shipping_address = billing_address
else: else:
billing_address = Address.objects.get(uuid=billing_address_uuid) # type: ignore [misc] billing_address = Address.objects.get(uuid=billing_address_uuid)
shipping_address = Address.objects.get(uuid=shipping_address_uuid) # type: ignore [misc] shipping_address = Address.objects.get(uuid=shipping_address_uuid)
self.billing_address = billing_address self.billing_address = billing_address
self.shipping_address = shipping_address self.shipping_address = shipping_address

View file

@ -7,7 +7,6 @@ from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache from django.core.cache import cache
from django.db.models.functions import Length from django.db.models.functions import Length
from rest_framework.fields import JSONField, SerializerMethodField from rest_framework.fields import JSONField, SerializerMethodField
from rest_framework.request import Request
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from rest_framework_recursive.fields import RecursiveField 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.simple import CategorySimpleSerializer, ProductSimpleSerializer
from core.serializers.utility import AddressSerializer from core.serializers.utility import AddressSerializer
from vibes_auth.models import User
logger = logging.getLogger("django") logger = logging.getLogger("django")

View file

@ -147,3 +147,10 @@ def generate_human_readable_id(length: int = 6) -> str:
chars.insert(pos, "-") chars.insert(pos, "-")
return "".join(chars) 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)])

View file

@ -1,9 +1,11 @@
import json import json
from contextlib import suppress from contextlib import suppress
from decimal import Decimal
from math import ceil, log10 from math import ceil, log10
from typing import Any from typing import Any
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import QuerySet
from core.elasticsearch import process_query from core.elasticsearch import process_query
from core.models import ( from core.models import (
@ -73,13 +75,13 @@ class AbstractVendor:
instance. 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.vendor_name = vendor_name
self.currency = currency self.currency = currency
self.blocked_attributes = [] self.blocked_attributes = []
@staticmethod @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) total = len(data)
if total == 0: if total == 0:
return [] return []
@ -87,7 +89,7 @@ class AbstractVendor:
return [data[i : i + chunk_size] for i in range(0, total, chunk_size)] return [data[i : i + chunk_size] for i in range(0, total, chunk_size)]
@staticmethod @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. Attempts to convert a value to a more specific type.
Handles booleans, numbers, objects (dicts), and arrays (lists), Handles booleans, numbers, objects (dicts), and arrays (lists),
@ -163,7 +165,7 @@ class AbstractVendor:
queryset.delete() queryset.delete()
return chosen return chosen
def auto_resolve_category(self, category_name: str): def auto_resolve_category(self, category_name: str = "") -> Category | None:
if category_name: if category_name:
try: try:
search = process_query(category_name) search = process_query(category_name)
@ -181,7 +183,7 @@ class AbstractVendor:
return self.auto_resolver_helper(Category, category_name) 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: if brand_name:
try: try:
search = process_query(brand_name) search = process_query(brand_name)
@ -220,7 +222,7 @@ class AbstractVendor:
return round(price, 2) 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) rates = get_rates(provider)
rate = rates.get(currency or self.currency) rate = rates.get(currency or self.currency)
@ -263,7 +265,7 @@ class AbstractVendor:
return float(psychological) return float(psychological)
def get_vendor_instance(self): def get_vendor_instance(self) -> Vendor | None:
try: try:
vendor = Vendor.objects.get(name=self.vendor_name) vendor = Vendor.objects.get(name=self.vendor_name)
if vendor.is_active: if vendor.is_active:
@ -272,16 +274,16 @@ class AbstractVendor:
except Vendor.DoesNotExist as dne: except Vendor.DoesNotExist as dne:
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): def get_products(self) -> None:
pass 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) 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) 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( return AttributeValue.objects.filter(
product__in=self.get_products_queryset(), product__orderproduct__isnull=True product__in=self.get_products_queryset(), product__orderproduct__isnull=True
) )
@ -300,7 +302,7 @@ class AbstractVendor:
case _: case _:
raise ValueError(f"Invalid method {method!r} for products update...") raise ValueError(f"Invalid method {method!r} for products update...")
def delete_inactives(self, inactivation_method: str = "deactivate", size: int = 5000): def delete_inactives(self, inactivation_method: str = "deactivate", size: int = 5000) -> None:
match inactivation_method: match inactivation_method:
case "deactivate": case "deactivate":
filter_kwargs: dict[str, Any] = {"is_active": False} filter_kwargs: dict[str, Any] = {"is_active": False}
@ -318,12 +320,12 @@ class AbstractVendor:
ProductImage.objects.filter(product_id__in=batch_ids).delete() ProductImage.objects.filter(product_id__in=batch_ids).delete()
Product.objects.filter(pk__in=batch_ids).delete() Product.objects.filter(pk__in=batch_ids).delete()
def delete_belongings(self): def delete_belongings(self) -> None:
self.get_products_queryset().delete() self.get_products_queryset().delete()
self.get_stocks_queryset().delete() self.get_stocks_queryset().delete()
self.get_attribute_values_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: if not value:
return return
@ -375,5 +377,5 @@ class AbstractVendor:
pass pass
def delete_stale(): def delete_stale() -> None:
Product.objects.filter(stocks__isnull=True, orderproduct__isnull=True).delete() Product.objects.filter(stocks__isnull=True, orderproduct__isnull=True).delete()

View file

@ -50,43 +50,45 @@ CONSTANCE_CONFIG = OrderedDict(
] ]
) )
CONSTANCE_CONFIG_FIELDSETS = OrderedDict({ CONSTANCE_CONFIG_FIELDSETS = OrderedDict(
gettext_noop("General Options"): ( {
"PROJECT_NAME", gettext_noop("General Options"): (
"FRONTEND_DOMAIN", "PROJECT_NAME",
"BASE_DOMAIN", "FRONTEND_DOMAIN",
"COMPANY_NAME", "BASE_DOMAIN",
"COMPANY_ADDRESS", "COMPANY_NAME",
"COMPANY_PHONE_NUMBER", "COMPANY_ADDRESS",
), "COMPANY_PHONE_NUMBER",
gettext_noop("Email Options"): ( ),
"EMAIL_HOST", gettext_noop("Email Options"): (
"EMAIL_PORT", "EMAIL_HOST",
"EMAIL_USE_TLS", "EMAIL_PORT",
"EMAIL_USE_SSL", "EMAIL_USE_TLS",
"EMAIL_HOST_USER", "EMAIL_USE_SSL",
"EMAIL_HOST_PASSWORD", "EMAIL_HOST_USER",
"EMAIL_FROM", "EMAIL_HOST_PASSWORD",
), "EMAIL_FROM",
gettext_noop("Payment Gateway Options"): ( ),
"PAYMENT_GATEWAY_URL", gettext_noop("Payment Gateway Options"): (
"PAYMENT_GATEWAY_TOKEN", "PAYMENT_GATEWAY_URL",
"EXCHANGE_RATE_API_KEY", "PAYMENT_GATEWAY_TOKEN",
"PAYMENT_GATEWAY_MINIMUM", "EXCHANGE_RATE_API_KEY",
"PAYMENT_GATEWAY_MAXIMUM", "PAYMENT_GATEWAY_MINIMUM",
), "PAYMENT_GATEWAY_MAXIMUM",
gettext_noop("Features Options"): ( ),
"DISABLED_COMMERCE", gettext_noop("Features Options"): (
"NOMINATIM_URL", "DISABLED_COMMERCE",
"OPENAI_API_KEY", "NOMINATIM_URL",
"ABSTRACT_API_KEY", "OPENAI_API_KEY",
"HTTP_PROXY", "ABSTRACT_API_KEY",
), "HTTP_PROXY",
gettext_noop("SEO Options"): ( ),
"ADVERTISEMENT_DATA", gettext_noop("SEO Options"): (
"ANALYTICS_DATA", "ADVERTISEMENT_DATA",
), "ANALYTICS_DATA",
}) ),
}
)
EXPOSABLE_KEYS = [ EXPOSABLE_KEYS = [
"PROJECT_NAME", "PROJECT_NAME",

View file

@ -105,7 +105,7 @@ linting = ["black", "isort", "flake8", "bandit"]
[tool.mypy] [tool.mypy]
strict = true strict = true
disable_error_code = ["no-redef", "import-untyped"] disable_error_code = ["no-redef", "import-untyped"]
exclude = ["*/migrations/*"] exclude = ["*/migrations/*", "storefront/*"]
plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"] plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"]
[tool.django-stubs] [tool.django-stubs]

View file

@ -54,7 +54,7 @@ class UserAdmin(ActivationActionsMixin, BaseUserAdmin): # type: ignore [misc]
(None, {"fields": ("email", "password")}), (None, {"fields": ("email", "password")}),
( (
_("personal info"), _("personal info"),
{"fields": ("first_name", "last_name", "phone_number", "avatar")}, {"fields": ("first_name", "last_name", "phone_number", "avatar", "address_set")},
), ),
( (
_("permissions"), _("permissions"),