diff --git a/.gitignore b/.gitignore index c5fa23ed..0ea2fc71 100644 --- a/.gitignore +++ b/.gitignore @@ -87,6 +87,7 @@ wheels/ share/python-wheels/ pip-log.txt pip-delete-this-directory.txt +desktop.ini # Node build artifacts npm-debug.log* diff --git a/README.md b/README.md index 79fe6115..1c2d347e 100644 --- a/README.md +++ b/README.md @@ -103,12 +103,12 @@ decomment previously commented lines and enjoy eVibes over HTTPS! ### .env -After .env file generation, you may want to edit some of its values, such as macroservices' API keys, database password, +After .env file generation, you may want to edit some of its values, such as macroservices` API keys, database password, redis password, etc. ## Usage -- Add necessary subdomains to DNS-settings of your domain, those are: +- Add the necessary subdomains to DNS-settings of your domain, those are: 1. @.your-domain.com 2. www.your-domain.com diff --git a/blog/admin.py b/blog/admin.py index 7ff9129d..6f967035 100644 --- a/blog/admin.py +++ b/blog/admin.py @@ -8,6 +8,13 @@ from .models import Post, PostTag @admin.register(Post) class PostAdmin(admin.ModelAdmin): + def preview_html(self, obj): + html = obj.content.html or "{}".format(_("(no content yet)")) + # noinspection DjangoSafeString + return mark_safe(html) + + preview_html.short_description = _("rendered HTML") # type: ignore + form = PostAdminForm list_display = ("title", "author", "slug", "created", "modified") list_filter = ("author", "tags", "created", "modified") @@ -33,13 +40,6 @@ class PostAdmin(admin.ModelAdmin): ), ) - def preview_html(self, obj): - html = obj.content.html or "{}".format(_("(no content yet)")) - # noinspection DjangoSafeString - return mark_safe(html) - - preview_html.short_description = _("rendered HTML") # type: ignore - @admin.register(PostTag) class PostTagAdmin(admin.ModelAdmin): diff --git a/core/admin.py b/core/admin.py index 6af8f80f..d208fa2f 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,3 +1,4 @@ +# noinspection PyUnresolvedReferences from constance.admin import Config from constance.admin import ConstanceAdmin as BaseConstanceAdmin from django.apps import apps diff --git a/core/graphene/mutations.py b/core/graphene/mutations.py index f3d9f64b..0fccb0dd 100644 --- a/core/graphene/mutations.py +++ b/core/graphene/mutations.py @@ -260,6 +260,7 @@ class BulkOrderAction(BaseMutation): elif order_hr_id: order = Order.objects.get(user=user, human_readable_id=order_hr_id) + # noinspection PyUnreachableCode match action: case "add": order = order.bulk_add_products(products) diff --git a/core/management/commands/fix_prices.py b/core/management/commands/fix_prices.py index 2ecadc6d..2a27fd72 100644 --- a/core/management/commands/fix_prices.py +++ b/core/management/commands/fix_prices.py @@ -15,7 +15,7 @@ class Command(BaseCommand): for product in Product.objects.filter(stocks__isnull=False): for stock in product.stocks.all(): try: - stock.price = AbstractVendor.round_price_marketologically(stock.price) + stock.price = AbstractVendor.round_price_marketologically(stock.price) # type: ignore stock.save() except Exception as e: self.stdout.write(self.style.WARNING(f"Couldn't fix price on {stock.uuid}")) diff --git a/core/management/commands/translate_fields.py b/core/management/commands/translate_fields.py index 068ba87b..d431ec81 100644 --- a/core/management/commands/translate_fields.py +++ b/core/management/commands/translate_fields.py @@ -40,13 +40,13 @@ class Command(BaseCommand): "-t", "--target", required=True, - help=("Dotted path to the field to translate, e.g. core.models.Product.description"), + help="Dotted path to the field to translate, e.g. core.models.Product.description", ) parser.add_argument( "-l", "--language", required=True, - help=("Modeltranslation language code to translate into, e.g. de-de, fr-fr, zh-hans"), + help="Modeltranslation language code to translate into, e.g. de-de, fr-fr, zh-hans", ) def handle(self, *args, **options): diff --git a/core/models.py b/core/models.py index 9463d62e..81df9fc3 100644 --- a/core/models.py +++ b/core/models.py @@ -49,6 +49,7 @@ from core.utils.lists import FAILED_STATUSES from core.validators import validate_category_image_dimensions from evibes.settings import CURRENCY_CODE from payments.models import Transaction +from vibes_auth.models import User logger = logging.getLogger(__name__) @@ -56,7 +57,7 @@ logger = logging.getLogger(__name__) class AttributeGroup(ExportModelOperationsMixin("attribute_group"), NiceModel): is_publicly_visible = True - parent: ForeignKey = ForeignKey( + parent: Self = ForeignKey( "self", on_delete=CASCADE, null=True, @@ -65,7 +66,7 @@ class AttributeGroup(ExportModelOperationsMixin("attribute_group"), NiceModel): help_text=_("parent of this group"), verbose_name=_("parent attribute group"), ) - name: CharField = CharField( + name: str = CharField( max_length=255, verbose_name=_("attribute group's name"), help_text=_("attribute group's name"), @@ -80,82 +81,89 @@ class AttributeGroup(ExportModelOperationsMixin("attribute_group"), NiceModel): verbose_name_plural = _("attribute groups") -class Attribute(ExportModelOperationsMixin("attribute"), NiceModel): +class Vendor(ExportModelOperationsMixin("vendor"), NiceModel): + is_publicly_visible = False + + authentication: dict = JSONField( + blank=True, + null=True, + help_text=_("stores credentials and endpoints required for vendor communication"), + verbose_name=_("authentication info"), + ) + markup_percent: int = IntegerField( + default=0, + validators=[MinValueValidator(0), MaxValueValidator(100)], + help_text=_("define the markup for products retrieved from this vendor"), + verbose_name=_("vendor markup percentage"), + ) + name: str = CharField( + max_length=255, + help_text=_("name of this vendor"), + verbose_name=_("vendor name"), + blank=False, + null=False, + unique=True, + ) + + def __str__(self) -> str: + return self.name + + class Meta: + verbose_name = _("vendor") + verbose_name_plural = _("vendors") + indexes = [ + GinIndex(fields=["authentication"]), + ] + + +class ProductTag(ExportModelOperationsMixin("product_tag"), NiceModel): is_publicly_visible = True - categories: ManyToManyField = ManyToManyField( - "core.Category", - related_name="attributes", - help_text=_("category of this attribute"), - verbose_name=_("categories"), - ) - - group: ForeignKey = ForeignKey( - "core.AttributeGroup", - on_delete=CASCADE, - related_name="attributes", - help_text=_("group of this attribute"), - verbose_name=_("attribute group"), - ) - value_type: CharField = CharField( - max_length=50, - choices=[ - ("string", _("string")), - ("integer", _("integer")), - ("float", _("float")), - ("boolean", _("boolean")), - ("array", _("array")), - ("object", _("object")), - ], - help_text=_("type of the attribute's value"), - verbose_name=_("value type"), - ) - - name: CharField = CharField( + tag_name: str = CharField( + blank=False, + null=False, max_length=255, - help_text=_("name of this attribute"), - verbose_name=_("attribute's name"), + help_text=_("internal tag identifier for the product tag"), + verbose_name=_("tag name"), + ) + name: str = CharField( + max_length=255, + help_text=_("user-friendly name for the product tag"), + verbose_name=_("tag display name"), unique=True, ) def __str__(self): - return self.name + return self.tag_name class Meta: - verbose_name = _("attribute") - verbose_name_plural = _("attributes") + verbose_name = _("product tag") + verbose_name_plural = _("product tags") -class AttributeValue(ExportModelOperationsMixin("attribute_value"), NiceModel): +class CategoryTag(ExportModelOperationsMixin("category_tag"), NiceModel): is_publicly_visible = True - attribute: ForeignKey = ForeignKey( - "core.Attribute", - on_delete=CASCADE, - related_name="values", - help_text=_("attribute of this value"), - verbose_name=_("attribute"), - ) - product: ForeignKey = ForeignKey( - "core.Product", - on_delete=CASCADE, + tag_name: str = CharField( blank=False, - null=True, - help_text=_("the specific product associated with this attribute's value"), - verbose_name=_("associated product"), - related_name="attributes", + null=False, + max_length=255, + help_text=_("internal tag identifier for the product tag"), + verbose_name=_("tag name"), ) - value: TextField = TextField( - verbose_name=_("attribute value"), - help_text=_("the specific value for this attribute"), + name: str = CharField( + max_length=255, + help_text=_("user-friendly name for the product tag"), + verbose_name=_("tag display name"), + unique=True, ) def __str__(self): - return f"{self.attribute!s}: {self.value}" + return self.tag_name class Meta: - verbose_name = _("attribute value") - verbose_name_plural = _("attribute values") + verbose_name = _("category tag") + verbose_name_plural = _("category tags") class Category(ExportModelOperationsMixin("category"), NiceModel, MPTTModel): @@ -169,13 +177,13 @@ class Category(ExportModelOperationsMixin("category"), NiceModel, MPTTModel): validators=[validate_category_image_dimensions], verbose_name=_("category image"), ) - markup_percent: IntegerField = IntegerField( + markup_percent: int = IntegerField( default=0, validators=[MinValueValidator(0), MaxValueValidator(100)], help_text=_("define a markup percentage for products in this category"), verbose_name=_("markup percentage"), ) - parent = TreeForeignKey( + parent: Self = TreeForeignKey( "self", on_delete=CASCADE, blank=True, @@ -185,28 +193,28 @@ class Category(ExportModelOperationsMixin("category"), NiceModel, MPTTModel): verbose_name=_("parent category"), ) - name: CharField = CharField( + name: str = CharField( max_length=255, verbose_name=_("category name"), help_text=_("provide a name for this category"), unique=True, ) - description: TextField = TextField( + description: str = TextField( blank=True, null=True, help_text=_("add a detailed description for this category"), verbose_name=_("category description"), ) - slug: AutoSlugField = AutoSlugField( + slug: str = AutoSlugField( populate_from=("uuid", "name"), allow_unicode=True, unique=True, editable=False, null=True, ) - tags: ManyToManyField = ManyToManyField( + tags: CategoryTag = ManyToManyField( "core.CategoryTag", blank=True, help_text=_("tags that help describe or group this category"), @@ -230,7 +238,7 @@ class Category(ExportModelOperationsMixin("category"), NiceModel, MPTTModel): class Brand(ExportModelOperationsMixin("brand"), NiceModel): is_publicly_visible = True - name: CharField = CharField( + name: str = CharField( max_length=255, help_text=_("name of this brand"), verbose_name=_("brand name"), @@ -252,13 +260,13 @@ class Brand(ExportModelOperationsMixin("brand"), NiceModel): validators=[validate_category_image_dimensions], verbose_name=_("brand big image"), ) - description: TextField = TextField( + description: str = TextField( blank=True, null=True, help_text=_("add a detailed description of the brand"), verbose_name=_("brand description"), ) - categories: ManyToManyField = ManyToManyField( + categories: Category = ManyToManyField( "core.Category", blank=True, help_text=_("optional categories that this brand is associated with"), @@ -276,14 +284,14 @@ class Brand(ExportModelOperationsMixin("brand"), NiceModel): class Product(ExportModelOperationsMixin("product"), NiceModel): is_publicly_visible = True - category: ForeignKey = ForeignKey( + category: Category = ForeignKey( "core.Category", on_delete=CASCADE, help_text=_("category this product belongs to"), verbose_name=_("category"), related_name="products", ) - brand: ForeignKey = ForeignKey( + brand: Brand = ForeignKey( "core.Brand", on_delete=CASCADE, blank=True, @@ -291,31 +299,31 @@ class Product(ExportModelOperationsMixin("product"), NiceModel): help_text=_("optionally associate this product with a brand"), verbose_name=_("brand"), ) - tags: ManyToManyField = ManyToManyField( + tags: ProductTag = ManyToManyField( "core.ProductTag", blank=True, help_text=_("tags that help describe or group this product"), verbose_name=_("product tags"), ) - is_digital: BooleanField = BooleanField( + is_digital: bool = BooleanField( default=False, help_text=_("indicates whether this product is digitally delivered"), verbose_name=_("is product digital"), blank=False, null=False, ) - name: CharField = CharField( + name: str = CharField( max_length=255, help_text=_("provide a clear identifying name for the product"), verbose_name=_("product name"), ) - description: TextField = TextField( + description: str = TextField( blank=True, null=True, help_text=_("add a detailed description of the product"), verbose_name=_("product description"), ) - partnumber: CharField = CharField( + partnumber: str = CharField( unique=True, default=None, blank=False, @@ -323,7 +331,7 @@ class Product(ExportModelOperationsMixin("product"), NiceModel): help_text=_("part number for this product"), verbose_name=_("part number"), ) - slug = AutoSlugField( + slug: str | None = AutoSlugField( populate_from=("uuid", "category__name", "name"), allow_unicode=True, unique=True, @@ -379,78 +387,440 @@ class Product(ExportModelOperationsMixin("product"), NiceModel): return quantity -class Vendor(ExportModelOperationsMixin("vendor"), NiceModel): - is_publicly_visible = False +class Attribute(ExportModelOperationsMixin("attribute"), NiceModel): + is_publicly_visible = True - authentication = JSONField( - blank=True, - null=True, - help_text=_("stores credentials and endpoints required for vendor communication"), - verbose_name=_("authentication info"), + categories: Category = ManyToManyField( + "core.Category", + related_name="attributes", + help_text=_("category of this attribute"), + verbose_name=_("categories"), ) - markup_percent: IntegerField = IntegerField( - default=0, - validators=[MinValueValidator(0), MaxValueValidator(100)], - help_text=_("define the markup for products retrieved from this vendor"), - verbose_name=_("vendor markup percentage"), + + group: AttributeGroup = ForeignKey( + "core.AttributeGroup", + on_delete=CASCADE, + related_name="attributes", + help_text=_("group of this attribute"), + verbose_name=_("attribute group"), ) - name: CharField = CharField( + value_type: str = CharField( + max_length=50, + choices=[ + ("string", _("string")), + ("integer", _("integer")), + ("float", _("float")), + ("boolean", _("boolean")), + ("array", _("array")), + ("object", _("object")), + ], + help_text=_("type of the attribute's value"), + verbose_name=_("value type"), + ) + + name: str = CharField( max_length=255, - help_text=_("name of this vendor"), - verbose_name=_("vendor name"), - blank=False, - null=False, + help_text=_("name of this attribute"), + verbose_name=_("attribute's name"), unique=True, ) - def __str__(self) -> str: + def __str__(self): return self.name class Meta: - verbose_name = _("vendor") - verbose_name_plural = _("vendors") - indexes = [ - GinIndex(fields=["authentication"]), - ] + verbose_name = _("attribute") + verbose_name_plural = _("attributes") -class Feedback(ExportModelOperationsMixin("feedback"), NiceModel): +class AttributeValue(ExportModelOperationsMixin("attribute_value"), NiceModel): is_publicly_visible = True - comment: TextField = TextField( - blank=True, - null=True, - help_text=_("user-provided comments about their experience with the product"), - verbose_name=_("feedback comments"), + attribute: Attribute = ForeignKey( + "core.Attribute", + on_delete=CASCADE, + related_name="values", + help_text=_("attribute of this value"), + verbose_name=_("attribute"), ) - order_product: OneToOneField = OneToOneField( - "core.OrderProduct", + product: Product = ForeignKey( + "core.Product", on_delete=CASCADE, blank=False, - null=False, - help_text=_("references the specific product in an order that this feedback is about"), - verbose_name=_("related order product"), + null=True, + help_text=_("the specific product associated with this attribute's value"), + verbose_name=_("associated product"), + related_name="attributes", ) - rating: FloatField = FloatField( + value: str = TextField( + verbose_name=_("attribute value"), + help_text=_("the specific value for this attribute"), + ) + + def __str__(self): + return f"{self.attribute!s}: {self.value}" + + class Meta: + verbose_name = _("attribute value") + verbose_name_plural = _("attribute values") + + +class ProductImage(ExportModelOperationsMixin("product_image"), NiceModel): + is_publicly_visible = True + + alt: str = CharField( + max_length=255, + help_text=_("provide alternative text for the image for accessibility"), + verbose_name=_("image alt text"), + ) + image = ImageField( + help_text=_("upload the image file for this product"), + verbose_name=_("product image"), + upload_to=get_product_uuid_as_path, + ) + priority: int = IntegerField( + default=1, + validators=[MinValueValidator(1)], + help_text=_("determines the order in which images are displayed"), + verbose_name=_("display priority"), + ) + product: ForeignKey = ForeignKey( + "core.Product", + on_delete=CASCADE, + help_text=_("the product that this image represents"), + verbose_name=_("associated product"), + related_name="images", + ) + + def get_product_uuid_as_path(self, *args): + return str(self.product.uuid) + "/" + args[0] + + def __str__(self) -> str: + return self.alt + + class Meta: + ordering = ("priority",) + verbose_name = _("product image") + verbose_name_plural = _("product images") + + +class Promotion(ExportModelOperationsMixin("promotion"), NiceModel): + is_publicly_visible = True + + discount_percent: int = IntegerField( + validators=[MinValueValidator(1), MaxValueValidator(100)], + help_text=_("percentage discount for the selected products"), + verbose_name=_("discount percentage"), + ) + name: str = CharField( + max_length=256, + unique=True, + help_text=_("provide a unique name for this promotion"), + verbose_name=_("promotion name"), + ) + description: str = TextField( blank=True, null=True, - help_text=_("user-assigned rating for the product"), - verbose_name=_("product rating"), - validators=[MinValueValidator(0), MaxValueValidator(10)], + help_text=_("add a detailed description of the product"), + verbose_name=_("promotion description"), + ) + products: ManyToManyField = ManyToManyField( + "core.Product", + blank=True, + help_text=_("select which products are included in this promotion"), + verbose_name=_("included products"), + ) + + class Meta: + verbose_name = _("promotion") + verbose_name_plural = _("promotions") + + def __str__(self) -> str: + if self.name: + return self.name + return str(self.id) + + +class Stock(ExportModelOperationsMixin("stock"), NiceModel): + is_publicly_visible = False + + vendor: ForeignKey = ForeignKey( + "core.Vendor", + on_delete=CASCADE, + help_text=_("the vendor supplying this product stock"), + verbose_name=_("associated vendor"), + ) + price: float = FloatField( + default=0.0, + help_text=_("final price to the customer after markups"), + verbose_name=_("selling price"), + ) + product: ForeignKey = ForeignKey( + "core.Product", + on_delete=CASCADE, + help_text=_("the product associated with this stock entry"), + verbose_name=_("associated product"), + related_name="stocks", + blank=True, + null=True, + ) + purchase_price: float = FloatField( + default=0.0, + help_text=_("the price paid to the vendor for this product"), + verbose_name=_("vendor purchase price"), + ) + quantity: int = IntegerField( + default=0, + help_text=_("available quantity of the product in stock"), + verbose_name=_("quantity in stock"), + ) + sku: str = CharField( + max_length=255, + help_text=_("vendor-assigned SKU for identifying the product"), + verbose_name=_("vendor sku"), + ) + digital_asset = FileField( + default=None, + blank=True, + null=True, + help_text=_("digital file associated with this stock if applicable"), + verbose_name=_("digital file"), + upload_to="downloadables/", ) def __str__(self) -> str: - return f"{self.rating} by {self.order_product.order.user.email}" + return f"{self.vendor.name} - {self.product!s}" # type: ignore class Meta: - verbose_name = _("feedback") - verbose_name_plural = _("feedbacks") + verbose_name = _("stock") + verbose_name_plural = _("stock entries") + + +class Wishlist(ExportModelOperationsMixin("wishlist"), NiceModel): + is_publicly_visible = False + + products: ManyToManyField = ManyToManyField( + "core.Product", + blank=True, + help_text=_("products that the user has marked as wanted"), + verbose_name=_("wishlisted products"), + ) + user: OneToOneField = OneToOneField( + "vibes_auth.User", + on_delete=CASCADE, + blank=True, + null=True, + help_text=_("user who owns this wishlist"), + verbose_name=_("wishlist owner"), + related_name="user_related_wishlist", + ) + + def __str__(self): + return f"{self.user.email}'s wishlist" + + class Meta: + verbose_name = _("wishlist") + verbose_name_plural = _("wishlists") + + def add_product(self, product_uuid): + try: + product = Product.objects.get(uuid=product_uuid) + if product in self.products.all(): + return self + self.products.add(product) + except Product.DoesNotExist: + name = "Product" + raise Http404(_(f"{name} does not exist: {product_uuid}")) + + return self + + def remove_product(self, product_uuid): + try: + product = Product.objects.get(uuid=product_uuid) + if product not in self.products.all(): + return self + self.products.remove(product) + except Product.DoesNotExist: + name = "Product" + raise Http404(_(f"{name} does not exist: {product_uuid}")) + + return self + + def bulk_add_products(self, product_uuids): + self.products.add(*Product.objects.filter(uuid__in=product_uuids)) + + return self + + def bulk_remove_products(self, product_uuids): + self.products.remove(*Product.objects.filter(uuid__in=product_uuids)) + + return self + + +class Documentary(ExportModelOperationsMixin("attribute_group"), NiceModel): + is_publicly_visible = True + + product: ForeignKey = ForeignKey(to=Product, on_delete=CASCADE, related_name="documentaries") + document = FileField(upload_to=get_product_uuid_as_path) + + class Meta: + verbose_name = _("documentary") + verbose_name_plural = _("documentaries") + + def __str__(self): + return f"{self.product.name} - {self.document.name}" + + def get_product_uuid_as_path(self, *args): + return str(self.product.uuid) + "/" + args[0] + + @property + def file_type(self): + return self.document.name.split(".")[-1] or _("unresolved") + + +class Address(ExportModelOperationsMixin("address"), NiceModel): + is_publicly_visible = False + + address_line: str = TextField( + blank=True, + null=True, + help_text=_("address line for the customer"), + verbose_name=_("address line"), + ) + street: str = CharField(_("street"), max_length=255, null=True) + district: str = CharField(_("district"), max_length=255, null=True) + city: str = CharField(_("city"), max_length=100, null=True) + region: str = CharField(_("region"), max_length=100, null=True) + postal_code: str = CharField(_("postal code"), max_length=20, null=True) + country: str = CharField(_("country"), max_length=40, null=True) + + location: PointField = PointField( + geography=True, srid=4326, null=True, blank=True, help_text=_("geolocation point: (longitude, latitude)") + ) + + raw_data: dict = JSONField(blank=True, null=True, help_text=_("full JSON response from geocoder for this address")) + + api_response: dict = JSONField( + blank=True, null=True, help_text=_("stored JSON response from the geocoding service") + ) + + user: ForeignKey = ForeignKey(to="vibes_auth.User", on_delete=CASCADE, blank=True, null=True) + + objects = AddressManager() + + class Meta: + verbose_name = _("address") + verbose_name_plural = _("addresses") + indexes = [ + Index(fields=["location"]), + ] + + def __str__(self): + base = f"{self.street}, {self.city}, {self.country}" + return f"{base} for {self.user.email}" if self.user else base + + +class PromoCode(ExportModelOperationsMixin("promocode"), NiceModel): + is_publicly_visible = False + + code: str = CharField( + max_length=20, + unique=True, + default=get_random_code, + help_text=_("unique code used by a user to redeem a discount"), + verbose_name=_("promo code identifier"), + ) + discount_amount: DecimalField = DecimalField( + max_digits=10, + decimal_places=2, + blank=True, + null=True, + help_text=_("fixed discount amount applied if percent is not used"), + verbose_name=_("fixed discount amount"), + ) + discount_percent: int = IntegerField( + validators=[MinValueValidator(1), MaxValueValidator(100)], + blank=True, + null=True, + help_text=_("percentage discount applied if fixed amount is not used"), + verbose_name=_("percentage discount"), + ) + end_time: datetime = DateTimeField( + blank=True, + null=True, + help_text=_("timestamp when the promocode expires"), + verbose_name=_("end validity time"), + ) + start_time: datetime = DateTimeField( + blank=True, + null=True, + help_text=_("timestamp from which this promocode is valid"), + verbose_name=_("start validity time"), + ) + used_on: datetime = DateTimeField( + blank=True, + null=True, + help_text=_("timestamp when the promocode was used, blank if not used yet"), + verbose_name=_("usage timestamp"), + ) + user: ForeignKey = ForeignKey( + "vibes_auth.User", + on_delete=CASCADE, + help_text=_("user assigned to this promocode if applicable"), + verbose_name=_("assigned user"), + null=True, + blank=True, + related_name="promocodes", + ) + + class Meta: + verbose_name = _("promo code") + verbose_name_plural = _("promo codes") + + def save(self, **kwargs): + if (self.discount_amount is not None and self.discount_percent is not None) or ( + self.discount_amount is None and self.discount_percent is None + ): + raise ValidationError( + _("only one type of discount should be defined (amount or percent), but not both or neither.") + ) + super().save(**kwargs) + + def __str__(self) -> str: + return self.code + + @property + def discount_type(self): + if self.discount_amount is not None: + return "amount" + return "percent" + + def use(self, order: "Order") -> float: + if self.used_on: + raise ValueError(_("promocode already used")) + + amount = order.total_price + + if self.discount_type == "percent": + amount -= round(amount * (self.discount_percent / 100), 2) + order.attributes.update({"promocode": str(self.uuid), "final_price": amount}) + order.save() + elif self.discount_type == "amount": + amount -= round(float(self.discount_amount), 2) + order.attributes.update({"promocode": str(self.uuid), "final_price": amount}) + order.save() + else: + raise ValueError(_(f"invalid discount type for promocode {self.uuid}")) + + self.used_on = datetime.datetime.now() + self.save() + return amount class Order(ExportModelOperationsMixin("order"), NiceModel): is_publicly_visible = False - billing_address: ForeignKey = ForeignKey( + billing_address: Address = ForeignKey( "core.Address", on_delete=CASCADE, blank=True, @@ -459,7 +829,7 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): help_text=_("the billing address used for this order"), verbose_name=_("billing address"), ) - promo_code: ForeignKey = ForeignKey( + promo_code: PromoCode = ForeignKey( "core.PromoCode", on_delete=PROTECT, blank=True, @@ -467,7 +837,7 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): help_text=_("optional promo code applied to this order"), verbose_name=_("applied promo code"), ) - shipping_address: ForeignKey = ForeignKey( + shipping_address: Address = ForeignKey( "core.Address", on_delete=CASCADE, blank=True, @@ -476,26 +846,26 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): help_text=_("the shipping address used for this order"), verbose_name=_("shipping address"), ) - status: CharField = CharField( + status: str = CharField( default="PENDING", max_length=64, choices=ORDER_STATUS_CHOICES, help_text=_("current status of the order in its lifecycle"), verbose_name=_("order status"), ) - notifications = JSONField( + notifications: dict = JSONField( blank=True, null=True, help_text=_("json structure of notifications to display to users"), verbose_name=_("notifications"), ) - attributes = JSONField( + attributes: dict = JSONField( blank=True, null=True, help_text=_("json representation of order attributes for this order"), verbose_name=_("attributes"), ) - user: ForeignKey = ForeignKey( + user: User = ForeignKey( "vibes_auth.User", on_delete=CASCADE, help_text=_("the user who placed the order"), @@ -504,14 +874,14 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): blank=True, null=True, ) - buy_time: DateTimeField = DateTimeField( + buy_time: datetime = DateTimeField( help_text=_("the timestamp when the order was finalized"), verbose_name=_("buy time"), default=None, null=True, blank=True, ) - human_readable_id: CharField = CharField( + human_readable_id: str = CharField( max_length=8, help_text=_("a human-readable identifier for the order"), verbose_name=_("human readable id"), @@ -716,7 +1086,7 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): match force: case "balance": - if self.user.payments_balance.amount < amount: + if self.user.payments_balance.amount < amount: # type: ignore raise NotEnoughMoneyError(_("insufficient funds to complete the order")) self.status = "CREATED" self.buy_time = timezone.now() @@ -727,7 +1097,7 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): self.status = "PAYMENT" self.save() return Transaction.objects.create( - balance=self.user.payments_balance, + balance=self.user.payments_balance, # type: ignore amount=amount, currency=CURRENCY_CODE, order=self, @@ -823,31 +1193,31 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): class OrderProduct(ExportModelOperationsMixin("order_product"), NiceModel): is_publicly_visible = False - buy_price: FloatField = FloatField( + buy_price: float = FloatField( blank=True, null=True, help_text=_("the price paid by the customer for this product at purchase time"), verbose_name=_("purchase price at order time"), ) - comments: TextField = TextField( + comments: str = TextField( blank=True, null=True, help_text=_("internal comments for admins about this ordered product"), verbose_name=_("internal comments"), ) - notifications: JSONField = JSONField( + notifications: dict = JSONField( blank=True, null=True, help_text=_("json structure of notifications to display to users"), verbose_name=_("user notifications"), ) - attributes: JSONField = JSONField( + attributes: dict = JSONField( blank=True, null=True, help_text=_("json representation of this item's attributes"), verbose_name=_("ordered product attributes"), ) - order: ForeignKey = ForeignKey( + order: Order = ForeignKey( "core.Order", on_delete=CASCADE, help_text=_("reference to the parent order that contains this product"), @@ -855,7 +1225,7 @@ class OrderProduct(ExportModelOperationsMixin("order_product"), NiceModel): related_name="order_products", null=True, ) - product: ForeignKey = ForeignKey( + product: Product = ForeignKey( "core.Product", on_delete=PROTECT, blank=True, @@ -863,14 +1233,14 @@ class OrderProduct(ExportModelOperationsMixin("order_product"), NiceModel): help_text=_("the specific product associated with this order line"), verbose_name=_("associated product"), ) - quantity: PositiveIntegerField = PositiveIntegerField( + quantity: int = PositiveIntegerField( blank=False, null=False, default=1, help_text=_("quantity of this specific product in the order"), verbose_name=_("product quantity"), ) - status: CharField = CharField( + status: str = CharField( max_length=128, blank=False, null=False, @@ -881,7 +1251,7 @@ class OrderProduct(ExportModelOperationsMixin("order_product"), NiceModel): ) def __str__(self) -> str: - return f"{self.product.name} for ({self.order.user.email if self.order.user else 'unregistered user'})" + return f"{self.product.name} for ({self.order.user.email if self.order.user else 'unregistered user'})" # type: ignore class Meta: verbose_name = _("order product") @@ -922,369 +1292,29 @@ class OrderProduct(ExportModelOperationsMixin("order_product"), NiceModel): @property def download_url(self) -> str: - if self.product.is_digital and self.product.stocks.first().digital_asset: + if self.product.is_digital and self.product.stocks.first().digital_asset: # type: ignore return self.download.url return "" - def do_feedback(self, rating: int = 10, comment: str = "", action: str = "add") -> None: + def do_feedback(self, rating: int = 10, comment: str = "", action: str = "add") -> Optional["Feedback"]: if action not in ["add", "remove"]: raise ValueError(_(f"wrong action specified for feedback: {action}")) if action == "remove" and self.feedback: self.feedback.delete() return None if action == "add" and not self.feedback: - if self.order.status not in ["MOMENTAL", "PENDING"]: + if self.order.status not in ["MOMENTAL", "PENDING"]: # type: ignore return Feedback.objects.create(rating=rating, comment=comment, order_product=self) else: raise ValueError(_("you cannot feedback an order which is not received")) return None -class ProductTag(ExportModelOperationsMixin("product_tag"), NiceModel): - is_publicly_visible = True - - tag_name: CharField = CharField( - blank=False, - null=False, - max_length=255, - help_text=_("internal tag identifier for the product tag"), - verbose_name=_("tag name"), - ) - name: CharField = CharField( - max_length=255, - help_text=_("user-friendly name for the product tag"), - verbose_name=_("tag display name"), - unique=True, - ) - - def __str__(self): - return self.tag_name - - class Meta: - verbose_name = _("product tag") - verbose_name_plural = _("product tags") - - -class CategoryTag(ExportModelOperationsMixin("category_tag"), NiceModel): - is_publicly_visible = True - - tag_name: CharField = CharField( - blank=False, - null=False, - max_length=255, - help_text=_("internal tag identifier for the product tag"), - verbose_name=_("tag name"), - ) - name: CharField = CharField( - max_length=255, - help_text=_("user-friendly name for the product tag"), - verbose_name=_("tag display name"), - unique=True, - ) - - def __str__(self): - return self.tag_name - - class Meta: - verbose_name = _("category tag") - verbose_name_plural = _("category tags") - - -class ProductImage(ExportModelOperationsMixin("product_image"), NiceModel): - is_publicly_visible = True - - alt: CharField = CharField( - max_length=255, - help_text=_("provide alternative text for the image for accessibility"), - verbose_name=_("image alt text"), - ) - image = ImageField( - help_text=_("upload the image file for this product"), - verbose_name=_("product image"), - upload_to=get_product_uuid_as_path, - ) - priority: IntegerField = IntegerField( - default=1, - validators=[MinValueValidator(1)], - help_text=_("determines the order in which images are displayed"), - verbose_name=_("display priority"), - ) - product: ForeignKey = ForeignKey( - "core.Product", - on_delete=CASCADE, - help_text=_("the product that this image represents"), - verbose_name=_("associated product"), - related_name="images", - ) - - def get_product_uuid_as_path(self, *args): - return str(self.product.uuid) + "/" + args[0] - - def __str__(self) -> str: - return self.alt - - class Meta: - ordering = ("priority",) - verbose_name = _("product image") - verbose_name_plural = _("product images") - - -class PromoCode(ExportModelOperationsMixin("promocode"), NiceModel): - is_publicly_visible = False - - code: CharField = CharField( - max_length=20, - unique=True, - default=get_random_code, - help_text=_("unique code used by a user to redeem a discount"), - verbose_name=_("promo code identifier"), - ) - discount_amount = DecimalField( - max_digits=10, - decimal_places=2, - blank=True, - null=True, - help_text=_("fixed discount amount applied if percent is not used"), - verbose_name=_("fixed discount amount"), - ) - discount_percent: IntegerField = IntegerField( - validators=[MinValueValidator(1), MaxValueValidator(100)], - blank=True, - null=True, - help_text=_("percentage discount applied if fixed amount is not used"), - verbose_name=_("percentage discount"), - ) - end_time = DateTimeField( - blank=True, - null=True, - help_text=_("timestamp when the promocode expires"), - verbose_name=_("end validity time"), - ) - start_time = DateTimeField( - blank=True, - null=True, - help_text=_("timestamp from which this promocode is valid"), - verbose_name=_("start validity time"), - ) - used_on = DateTimeField( - blank=True, - null=True, - help_text=_("timestamp when the promocode was used, blank if not used yet"), - verbose_name=_("usage timestamp"), - ) - user: ForeignKey = ForeignKey( - "vibes_auth.User", - on_delete=CASCADE, - help_text=_("user assigned to this promocode if applicable"), - verbose_name=_("assigned user"), - null=True, - blank=True, - related_name="promocodes", - ) - - class Meta: - verbose_name = _("promo code") - verbose_name_plural = _("promo codes") - - def save(self, **kwargs): - if (self.discount_amount is not None and self.discount_percent is not None) or ( - self.discount_amount is None and self.discount_percent is None - ): - raise ValidationError( - _("only one type of discount should be defined (amount or percent), but not both or neither.") - ) - super().save(**kwargs) - - def __str__(self) -> str: - return self.code - - @property - def discount_type(self): - if self.discount_amount is not None: - return "amount" - return "percent" - - def use(self, order: Order) -> float: - if self.used_on: - raise ValueError(_("promocode already used")) - - amount = order.total_price - - match self.discount_type: - case "percent": - amount -= round(amount * (self.discount_percent / 100), 2) - order.attributes.update({"promocode": str(self.uuid), "final_price": amount}) - order.save() - case "amount": - amount -= round(float(self.discount_amount), 2) - order.attributes.update({"promocode": str(self.uuid), "final_price": amount}) - order.save() - case _: - raise ValueError(_(f"invalid discount type for promocode {self.uuid}")) - - self.used_on = datetime.datetime.now() - self.save() - return amount - - -class Promotion(ExportModelOperationsMixin("promotion"), NiceModel): - is_publicly_visible = True - - discount_percent: IntegerField = IntegerField( - validators=[MinValueValidator(1), MaxValueValidator(100)], - help_text=_("percentage discount for the selected products"), - verbose_name=_("discount percentage"), - ) - name: CharField = CharField( - max_length=256, - unique=True, - help_text=_("provide a unique name for this promotion"), - verbose_name=_("promotion name"), - ) - description = TextField( # noqa: DJ001 - blank=True, - null=True, - help_text=_("add a detailed description of the product"), - verbose_name=_("promotion description"), - ) - products: ManyToManyField = ManyToManyField( - "core.Product", - blank=True, - help_text=_("select which products are included in this promotion"), - verbose_name=_("included products"), - ) - - class Meta: - verbose_name = _("promotion") - verbose_name_plural = _("promotions") - - def __str__(self) -> str: - if self.name: - return self.name - return str(self.id) - - -class Stock(ExportModelOperationsMixin("stock"), NiceModel): - is_publicly_visible = False - - vendor: ForeignKey = ForeignKey( - "core.Vendor", - on_delete=CASCADE, - help_text=_("the vendor supplying this product stock"), - verbose_name=_("associated vendor"), - ) - price = FloatField( - default=0.0, - help_text=_("final price to the customer after markups"), - verbose_name=_("selling price"), - ) - product: ForeignKey = ForeignKey( - "core.Product", - on_delete=CASCADE, - help_text=_("the product associated with this stock entry"), - verbose_name=_("associated product"), - related_name="stocks", - blank=True, - null=True, - ) - purchase_price = FloatField( - default=0.0, - help_text=_("the price paid to the vendor for this product"), - verbose_name=_("vendor purchase price"), - ) - quantity: IntegerField = IntegerField( - default=0, - help_text=_("available quantity of the product in stock"), - verbose_name=_("quantity in stock"), - ) - sku: CharField = CharField( - max_length=255, - help_text=_("vendor-assigned SKU for identifying the product"), - verbose_name=_("vendor sku"), - ) - digital_asset = FileField( - default=None, - blank=True, - null=True, - help_text=_("digital file associated with this stock if applicable"), - verbose_name=_("digital file"), - upload_to="downloadables/", - ) - - def __str__(self) -> str: - return f"{self.vendor.name} - {self.product!s}" - - class Meta: - verbose_name = _("stock") - verbose_name_plural = _("stock entries") - - -class Wishlist(ExportModelOperationsMixin("wishlist"), NiceModel): - is_publicly_visible = False - - products: ManyToManyField = ManyToManyField( - "core.Product", - blank=True, - help_text=_("products that the user has marked as wanted"), - verbose_name=_("wishlisted products"), - ) - user: OneToOneField = OneToOneField( - "vibes_auth.User", - on_delete=CASCADE, - blank=True, - null=True, - help_text=_("user who owns this wishlist"), - verbose_name=_("wishlist owner"), - related_name="user_related_wishlist", - ) - - def __str__(self): - return f"{self.user.email}'s wishlist" - - class Meta: - verbose_name = _("wishlist") - verbose_name_plural = _("wishlists") - - def add_product(self, product_uuid): - try: - product = Product.objects.get(uuid=product_uuid) - if product in self.products.all(): - return self - self.products.add(product) - except Product.DoesNotExist: - name = "Product" - raise Http404(_(f"{name} does not exist: {product_uuid}")) - - return self - - def remove_product(self, product_uuid): - try: - product = Product.objects.get(uuid=product_uuid) - if product not in self.products.all(): - return self - self.products.remove(product) - except Product.DoesNotExist: - name = "Product" - raise Http404(_(f"{name} does not exist: {product_uuid}")) - - return self - - def bulk_add_products(self, product_uuids): - self.products.add(*Product.objects.filter(uuid__in=product_uuids)) - - return self - - def bulk_remove_products(self, product_uuids): - self.products.remove(*Product.objects.filter(uuid__in=product_uuids)) - - return self - - class DigitalAssetDownload(ExportModelOperationsMixin("attribute_group"), NiceModel): is_publicly_visible = False order_product: OneToOneField = OneToOneField(to=OrderProduct, on_delete=CASCADE, related_name="download") - num_downloads: IntegerField = IntegerField(default=0) + num_downloads: int = IntegerField(default=0) class Meta: verbose_name = _("download") @@ -1303,66 +1333,34 @@ class DigitalAssetDownload(ExportModelOperationsMixin("attribute_group"), NiceMo ) -class Documentary(ExportModelOperationsMixin("attribute_group"), NiceModel): +class Feedback(ExportModelOperationsMixin("feedback"), NiceModel): is_publicly_visible = True - product: ForeignKey = ForeignKey(to=Product, on_delete=CASCADE, related_name="documentaries") - document = FileField(upload_to=get_product_uuid_as_path) - - class Meta: - verbose_name = _("documentary") - verbose_name_plural = _("documentaries") - - def __str__(self): - return f"{self.product.name} - {self.document.name}" - - def get_product_uuid_as_path(self, *args): - return str(self.product.uuid) + "/" + args[0] - - @property - def file_type(self): - return self.document.name.split(".")[-1] or _("unresolved") - - -class Address(ExportModelOperationsMixin("address"), NiceModel): - is_publicly_visible = False - - address_line = TextField( # noqa: DJ001 + comment: str = TextField( blank=True, null=True, - help_text=_("address line for the customer"), - verbose_name=_("address line"), + help_text=_("user-provided comments about their experience with the product"), + verbose_name=_("feedback comments"), ) - street: CharField = CharField(_("street"), max_length=255, null=True) - district: CharField = CharField(_("district"), max_length=255, null=True) - city: CharField = CharField(_("city"), max_length=100, null=True) - region: CharField = CharField(_("region"), max_length=100, null=True) - postal_code: CharField = CharField(_("postal code"), max_length=20, null=True) - country: CharField = CharField(_("country"), max_length=40, null=True) - - location: PointField = PointField( - geography=True, srid=4326, null=True, blank=True, help_text=_("geolocation point: (longitude, latitude)") + order_product: OrderProduct = OneToOneField( + "core.OrderProduct", + on_delete=CASCADE, + blank=False, + null=False, + help_text=_("references the specific product in an order that this feedback is about"), + verbose_name=_("related order product"), + ) + rating: float = FloatField( + blank=True, + null=True, + help_text=_("user-assigned rating for the product"), + verbose_name=_("product rating"), + validators=[MinValueValidator(0), MaxValueValidator(10)], ) - raw_data: JSONField = JSONField( - blank=True, null=True, help_text=_("full JSON response from geocoder for this address") - ) - - api_response: JSONField = JSONField( - blank=True, null=True, help_text=_("stored JSON response from the geocoding service") - ) - - user: ForeignKey = ForeignKey(to="vibes_auth.User", on_delete=CASCADE, blank=True, null=True) - - objects = AddressManager() + def __str__(self) -> str: + return f"{self.rating} by {self.order_product.order.user.email}" class Meta: - verbose_name = _("address") - verbose_name_plural = _("addresses") - indexes = [ - Index(fields=["location"]), - ] - - def __str__(self): - base = f"{self.street}, {self.city}, {self.country}" - return f"{base} for {self.user.email}" if self.user else base + verbose_name = _("feedback") + verbose_name_plural = _("feedbacks") diff --git a/core/serializers/simple.py b/core/serializers/simple.py index 587dfd8f..4f6eddf7 100644 --- a/core/serializers/simple.py +++ b/core/serializers/simple.py @@ -27,8 +27,8 @@ from core.serializers.utility import AddressSerializer class AttributeGroupSimpleSerializer(ModelSerializer): - parent: PrimaryKeyRelatedField = PrimaryKeyRelatedField(read_only=True) - children: PrimaryKeyRelatedField = PrimaryKeyRelatedField(many=True, read_only=True) + parent: PrimaryKeyRelatedField = PrimaryKeyRelatedField(read_only=True) # type: ignore + children: PrimaryKeyRelatedField = PrimaryKeyRelatedField(many=True, read_only=True) # type: ignore class Meta: model = AttributeGroup diff --git a/core/static/maintenance.html b/core/static/maintenance.html index 452c1f59..9bd32d29 100644 --- a/core/static/maintenance.html +++ b/core/static/maintenance.html @@ -1,5 +1,5 @@ - +
diff --git a/core/tasks.py b/core/tasks.py index 6efb1732..87eccaf9 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -160,7 +160,6 @@ def process_promotions() -> tuple[bool, str]: :return: A tuple where the first element is a boolean indicating success, and the second element is a message describing the operation's outcome. - :rtype: tuple[bool, str] """ if not config.ABSTRACT_API_KEY or config.ABSTRACT_API_KEY == "example key": return False, "Abstract features disabled." diff --git a/core/utils/emailing.py b/core/utils/emailing.py index 41011a3b..29dc73a1 100644 --- a/core/utils/emailing.py +++ b/core/utils/emailing.py @@ -47,7 +47,7 @@ def send_order_created_email(order_pk: str) -> tuple[bool, str]: except Order.DoesNotExist: return False, f"Order not found with the given pk: {order_pk}" - activate(order.user.language) + activate(order.user.language) # type: ignore set_email_settings() connection = mail.get_connection() @@ -64,7 +64,7 @@ def send_order_created_email(order_pk: str) -> tuple[bool, str]: "total_price": order.total_price, }, ), - to=[order.user.email], + to=[order.user.email], # type: ignore from_email=f"{config.PROJECT_NAME} <{config.EMAIL_FROM}>", connection=connection, ) @@ -80,7 +80,7 @@ def send_order_finished_email(order_pk: str) -> tuple[bool, str]: if len(ops) <= 0: return - activate(order.user.language) + activate(order.user.language) # type: ignore set_email_settings() connection = mail.get_connection() @@ -91,16 +91,16 @@ def send_order_finished_email(order_pk: str) -> tuple[bool, str]: template_name="digital_order_delivered_email.html", context={ "order_uuid": order.human_readable_id, - "user_first_name": order.user.first_name, + "user_first_name": order.user.first_name, # type: ignore "order_products": ops, "project_name": config.PROJECT_NAME, "contact_email": config.EMAIL_FROM, "total_price": round(sum(op.buy_price for op in ops), 2), - "display_system_attributes": order.user.has_perm("core.view_order"), + "display_system_attributes": order.user.has_perm("core.view_order"), # type: ignore "today": datetime.today(), }, ), - to=[order.user.email], + to=[order.user.email], # type: ignore from_email=f"{config.PROJECT_NAME} <{config.EMAIL_FROM}>", connection=connection, ) @@ -108,7 +108,7 @@ def send_order_finished_email(order_pk: str) -> tuple[bool, str]: email.send() def send_thank_you_email(ops: list[OrderProduct]): - activate(order.user.language) + activate(order.user.language) # type: ignore set_email_settings() diff --git a/core/vendors/__init__.py b/core/vendors/__init__.py index be8bb641..c03e12bc 100644 --- a/core/vendors/__init__.py +++ b/core/vendors/__init__.py @@ -197,7 +197,7 @@ class AbstractVendor: price = float(original_price) if category and category.markup_percent: - price *= 1 + category.markup_percent / 100.0 + price *= 1 + float(category.markup_percent) / 100.0 # type: ignore elif vendor and vendor.markup_percent: price *= 1 + vendor.markup_percent / 100.0 diff --git a/evibes/middleware.py b/evibes/middleware.py index 75e0a03c..56e21545 100644 --- a/evibes/middleware.py +++ b/evibes/middleware.py @@ -42,6 +42,7 @@ class CustomLocaleMiddleware(LocaleMiddleware): request.LANGUAGE_CODE = normalized +# noinspection PyShadowingBuiltins class GrapheneJWTAuthorizationMiddleware: def resolve(self, next, root, info, **args): context = info.context @@ -92,6 +93,7 @@ class BlockInvalidHostMiddleware: return self.get_response(request) +# noinspection PyShadowingBuiltins class GrapheneLoggingErrorsDebugMiddleware: def resolve(self, next, root, info, **args): try: diff --git a/evibes/pagination.py b/evibes/pagination.py index b1632180..be486861 100644 --- a/evibes/pagination.py +++ b/evibes/pagination.py @@ -10,9 +10,9 @@ class CustomPagination(PageNumberPagination): { "links": {"forward": self.get_next_link(), "backward": self.get_previous_link()}, "counts": { - "total_pages": None or self.page.paginator.num_pages, - "page_size": None or self.page_size, - "total_items": None or self.page.paginator.count, + "total_pages": None or self.page.paginator.num_pages, # type: ignore + "page_size": None or self.page_size, # type: ignore + "total_items": None or self.page.paginator.count, # type: ignore }, "data": data, } diff --git a/evibes/settings/base.py b/evibes/settings/base.py index bc042600..f08908c3 100644 --- a/evibes/settings/base.py +++ b/evibes/settings/base.py @@ -227,7 +227,7 @@ CURRENCIES: tuple[tuple[str, str], ...] = ( ("zh-hans", "CNY"), ) -CURRENCY_CODE: str = dict(CURRENCIES).get(LANGUAGE_CODE) +CURRENCY_CODE: str | None = dict(CURRENCIES).get(LANGUAGE_CODE) MODELTRANSLATION_FALLBACK_LANGUAGES: tuple = (LANGUAGE_CODE, "en-us", "de-de") @@ -274,7 +274,7 @@ INTERNAL_IPS: list[str] = [ "127.0.0.1", ] -DAISY_SETTINGS: dict[str, str | list[str] | dict[str, str] | None | bool | int | float | list[dict[str, str]]] = { +DAISY_SETTINGS: dict = { "SITE_LOGO": "/static/favicon.ico", "EXTRA_STYLES": [], "EXTRA_SCRIPTS": [], @@ -283,6 +283,10 @@ DAISY_SETTINGS: dict[str, str | list[str] | dict[str, str] | None | bool | int | "DONT_SUPPORT_ME": True, "SIDEBAR_FOOTNOTE": "eVibes by Wiseless", "APPS_REORDER": { + "django_celery_results": { + "hide": True, + "app": "django_celery_results", + }, "django_celery_beat": { "icon": "fa fa-solid fa-timeline", "hide": False, diff --git a/evibes/settings/drf.py b/evibes/settings/drf.py index a7841b97..ab53a990 100644 --- a/evibes/settings/drf.py +++ b/evibes/settings/drf.py @@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _ from evibes.settings.base import * # noqa: F403 from evibes.settings.constance import CONSTANCE_CONFIG -REST_FRAMEWORK: dict[str, int | str | dict[str, str | bool]] = { +REST_FRAMEWORK: dict = { "DEFAULT_PAGINATION_CLASS": "evibes.pagination.CustomPagination", "PAGE_SIZE": 30, "DEFAULT_AUTHENTICATION_CLASSES": [ @@ -45,6 +45,7 @@ SIMPLE_JWT: dict[str, timedelta | str | bool] = { } # type: ignore +# noinspection Mypy SPECTACULAR_B2B_DESCRIPTION = _(f""" Welcome to the {CONSTANCE_CONFIG.get("PROJECT_NAME")[0]} B2B API documentation. @@ -69,6 +70,7 @@ Current API version: {EVIBES_VERSION} """) # noqa: E501, F405 # type: ignore +# noinspection Mypy SPECTACULAR_PLATFORM_DESCRIPTION = _(f""" Welcome to the {CONSTANCE_CONFIG.get("PROJECT_NAME")[0]} Platform API documentation. @@ -95,6 +97,7 @@ The {CONSTANCE_CONFIG.get("PROJECT_NAME")[0]} API is the central hub for managin Current API version: {EVIBES_VERSION} """) # noqa: E501, F405 +# noinspection Mypy SPECTACULAR_PLATFORM_SETTINGS = { "TITLE": f"{CONSTANCE_CONFIG.get('PROJECT_NAME')[0]} API", "DESCRIPTION": SPECTACULAR_PLATFORM_DESCRIPTION, @@ -146,6 +149,7 @@ SPECTACULAR_PLATFORM_SETTINGS = { }, } +# noinspection Mypy SPECTACULAR_B2B_SETTINGS = { "TITLE": f"{CONSTANCE_CONFIG.get('PROJECT_NAME')[0]} API", "DESCRIPTION": SPECTACULAR_B2B_DESCRIPTION, diff --git a/evibes/settings/emailing.py b/evibes/settings/emailing.py index 8a90b999..c2eccc79 100644 --- a/evibes/settings/emailing.py +++ b/evibes/settings/emailing.py @@ -1,9 +1,9 @@ from evibes.settings import CONSTANCE_CONFIG EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -EMAIL_HOST = CONSTANCE_CONFIG.get("EMAIL_HOST")[0] -EMAIL_PORT = CONSTANCE_CONFIG.get("EMAIL_PORT")[0] -EMAIL_USE_TLS = CONSTANCE_CONFIG.get("EMAIL_USE_TLS")[0] -EMAIL_USE_SSL = CONSTANCE_CONFIG.get("EMAIL_USE_SSL")[0] -EMAIL_HOST_USER = CONSTANCE_CONFIG.get("EMAIL_HOST_USER")[0] -EMAIL_HOST_PASSWORD = CONSTANCE_CONFIG.get("EMAIL_HOST_PASSWORD")[0] +EMAIL_HOST = CONSTANCE_CONFIG.get("EMAIL_HOST")[0] # type: ignore +EMAIL_PORT = CONSTANCE_CONFIG.get("EMAIL_PORT")[0] # type: ignore +EMAIL_USE_TLS = CONSTANCE_CONFIG.get("EMAIL_USE_TLS")[0] # type: ignore +EMAIL_USE_SSL = CONSTANCE_CONFIG.get("EMAIL_USE_SSL")[0] # type: ignore +EMAIL_HOST_USER = CONSTANCE_CONFIG.get("EMAIL_HOST_USER")[0] # type: ignore +EMAIL_HOST_PASSWORD = CONSTANCE_CONFIG.get("EMAIL_HOST_PASSWORD")[0] # type: ignore diff --git a/evibes/settings/logconfig.py b/evibes/settings/logconfig.py index 7f889cba..2168802b 100644 --- a/evibes/settings/logconfig.py +++ b/evibes/settings/logconfig.py @@ -6,7 +6,7 @@ LOGGING = { "formatters": { "color": { "()": "colorlog.ColoredFormatter", - "format": ("%(asctime)s %(log_color)s[%(levelname)s]%(reset)s %(name)s: %(message)s"), + "format": "%(asctime)s %(log_color)s[%(levelname)s]%(reset)s %(name)s: %(message)s", "datefmt": "%Y-%m-%d %H:%M:%S", "log_colors": { "DEBUG": "cyan", diff --git a/payments/admin.py b/payments/admin.py index 13a0eff1..9de87f4b 100644 --- a/payments/admin.py +++ b/payments/admin.py @@ -1,3 +1,4 @@ +# noinspection PyUnresolvedReferences from django.contrib import admin from django.utils.translation import gettext_lazy as _ diff --git a/payments/models.py b/payments/models.py index b5fc6d66..b3540ed1 100644 --- a/payments/models.py +++ b/payments/models.py @@ -4,11 +4,13 @@ from django.db.models import CASCADE, CharField, FloatField, ForeignKey, JSONFie from django.utils.translation import gettext_lazy as _ from core.abstract import NiceModel +from core.models import Order +from vibes_auth.models import User class Balance(NiceModel): - amount: FloatField = FloatField(null=False, blank=False, default=0) - user: OneToOneField = OneToOneField( + amount: float = FloatField(null=False, blank=False, default=0) + user: User = OneToOneField( to="vibes_auth.User", on_delete=CASCADE, blank=True, null=True, related_name="payments_balance" ) @@ -26,11 +28,11 @@ class Balance(NiceModel): class Transaction(NiceModel): - amount: FloatField = FloatField(null=False, blank=False) - balance: ForeignKey = ForeignKey(Balance, on_delete=CASCADE, blank=True, null=True, related_name="transactions") - currency: CharField = CharField(max_length=3, null=False, blank=False) - payment_method: CharField = CharField(max_length=20, null=True, blank=True) - order: ForeignKey = ForeignKey( + amount: float = FloatField(null=False, blank=False) + balance: Balance = ForeignKey(Balance, on_delete=CASCADE, blank=True, null=True, related_name="transactions") + currency: str = CharField(max_length=3, null=False, blank=False) + payment_method: str = CharField(max_length=20, null=True, blank=True) + order: Order = ForeignKey( "core.Order", on_delete=CASCADE, blank=True, @@ -38,7 +40,7 @@ class Transaction(NiceModel): help_text=_("order to process after paid"), related_name="payments_transactions", ) - process: JSONField = JSONField(verbose_name=_("processing details"), default=dict) + process: dict = JSONField(verbose_name=_("processing details"), default=dict) def __str__(self): return f"{self.balance.user.email} | {self.amount}" diff --git a/payments/serializers.py b/payments/serializers.py index db34beb1..47f953a0 100644 --- a/payments/serializers.py +++ b/payments/serializers.py @@ -20,10 +20,10 @@ class TransactionProcessSerializer(ModelSerializer): order_uuid = SerializerMethodField(read_only=True, required=False) def get_order_hr_id(self, obj: Transaction): - return obj.order.human_readable_id if obj.order else None + return obj.order.human_readable_id if obj.order else None # type: ignore def get_order_uuid(self, obj: Transaction): - return obj.order.uuid if obj.order else None + return obj.order.uuid if obj.order else None # type: ignore class Meta: model = Transaction diff --git a/payments/tests.py b/payments/tests.py index cef4e9a3..4112fb1b 100644 --- a/payments/tests.py +++ b/payments/tests.py @@ -18,6 +18,7 @@ from payments.views import CallbackAPIView, DepositView ############################################################################### +# noinspection PyArgumentList class BalanceModelTests(TestCase): def setUp(self): self.user_model = get_user_model() @@ -34,9 +35,10 @@ class BalanceModelTests(TestCase): self.balance.save() self.balance.refresh_from_db() # round(10.129, 2) == 10.13 - self.assertAlmostEqual(self.balance.amount, 10.13, places=2) + self.assertAlmostEqual(float(self.balance.amount), 10.13, places=2) +# noinspection PyArgumentList class TransactionModelTests(TestCase): def setUp(self): self.user_model = get_user_model() @@ -66,6 +68,7 @@ class TransactionModelTests(TestCase): ############################################################################### +# noinspection PyArgumentList class DepositViewTests(TestCase): def setUp(self): self.factory = APIRequestFactory() @@ -115,6 +118,7 @@ class CallbackViewTests(TestCase): ############################################################################### +# noinspection PyArgumentList class SignalTests(TestCase): def setUp(self): self.user_model = get_user_model() @@ -133,6 +137,7 @@ class SignalTests(TestCase): ############################################################################### +# noinspection PyArgumentList class GraphQLDepositTests(TestCase): def setUp(self): self.user_model = get_user_model() diff --git a/payments/utils/__init__.py b/payments/utils/__init__.py index 7f6a2c58..f8fc9e86 100644 --- a/payments/utils/__init__.py +++ b/payments/utils/__init__.py @@ -7,6 +7,7 @@ def get_rates(provider: str): if not provider: raise ValueError(_("a provider to get rates from is required")) + # noinspection PyUnreachableCode match provider: case "cbr": return get_rates_cbr() diff --git a/payments/utils/emailing.py b/payments/utils/emailing.py index 9372e5b8..d4aa83c4 100644 --- a/payments/utils/emailing.py +++ b/payments/utils/emailing.py @@ -20,7 +20,8 @@ def balance_email(user_pk: str) -> tuple[bool, str]: email = EmailMessage( "eVibes | Successful Order", - render_to_string("balance.html", {"user": user, "current_year": timezone.now().year, "config": config}), + render_to_string("balance_deposit_email.html", + {"user": user, "current_year": timezone.now().year, "config": config}), to=[user.email], from_email=f"{config.PROJECT_NAME} <{config.EMAIL_FROM}>", ) diff --git a/payments/views.py b/payments/views.py index 763d007c..9d3a1c6b 100644 --- a/payments/views.py +++ b/payments/views.py @@ -37,6 +37,7 @@ class CallbackAPIView(APIView): logger.debug(request.__dict__) try: gateway = kwargs.get("gateway", "") + # noinspection PyUnreachableCode match gateway: case "gateway": # Gateway.process_callback(request.data) diff --git a/pyproject.toml b/pyproject.toml index 8385f837..f23c2540 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,110 +7,111 @@ readme = "README.md" package-mode = false [build-system] -requires = ["poetry-core"] +requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.poetry.dependencies] -aiosmtpd = "1.4.6" -celery = { version = "5.5.2", optional = true } -celery-prometheus-exporter = { version = "1.7.0", optional = true } -colorlog = "6.9.0" -cryptography = "44.0.3" -django = "5.2" -django-cacheops = "7.2" -django-celery-beat = { version = "2.8.0", optional = true } -django-celery-results = { version = "2.6.0", optional = true } -django-constance = "4.3.2" -django-cors-headers = "4.7.0" -django-daisy = "1.0.23" -django-dbbackup = "4.2.1" -django-elasticsearch-dsl = "8.0" -django-elasticsearch-dsl-drf = "0.22.5" -django-extensions = "4.1" -django-filter = "25.1" -django-health-check = "3.19.1" -django-hosts = "6.0" -django-json-widget = "2.0.1" -django-mailbox = "4.10.1" -django-model-utils = "5.0.0" -django-modeltranslation = "0.19.14" -django-mptt = "0.17.0" -django-prometheus = "^2.3.1" -django-redis = "5.4.0" -django-ratelimit = "4.1.0" -django-storages = "1.14.6" -django-stubs = "5.2.0" -django-widget-tweaks = "1.5.0" -django-md-field = "0.1.0" -djangorestframework = "3.16.0" +aiosmtpd = "1.4.6" +celery = { version = "5.5.2", optional = true } +celery-prometheus-exporter = { version = "1.7.0", optional = true } +colorlog = "6.9.0" +cryptography = "44.0.3" +django = "5.2" +django-cacheops = "7.2" +django-celery-beat = { version = "2.8.0", optional = true } +django-celery-results = { version = "2.6.0", optional = true } +django-constance = "4.3.2" +django-cors-headers = "4.7.0" +django-daisy = "1.0.23" +django-dbbackup = "4.2.1" +django-elasticsearch-dsl = "8.0" +django-elasticsearch-dsl-drf = "0.22.5" +django-extensions = "4.1" +django-filter = "25.1" +django-health-check = "3.19.1" +django-hosts = "6.0" +django-json-widget = "2.0.1" +django-mailbox = "4.10.1" +django-model-utils = "5.0.0" +django-modeltranslation = "0.19.14" +django-mptt = "0.17.0" +django-prometheus = "^2.3.1" +django-redis = "5.4.0" +django-ratelimit = "4.1.0" +django-storages = "1.14.6" +django-stubs = "5.2.0" +django-widget-tweaks = "1.5.0" +django-md-field = "0.1.0" +djangorestframework = "3.16.0" djangorestframework-camel-case = "1.4.2" -djangorestframework-recursive = "0.1.2" -djangorestframework-simplejwt = { extras = ["crypto"], version = "5.5.0" } -djangorestframework-stubs = "3.16.0" -djangorestframework-xml = "2.0.0" -djangorestframework-yaml = "2.0.0" -drf-spectacular = { extras = ["sidecar"], version = "0.28.0" } -elasticsearch-dsl = "8.18.0" -filetype = "1.2.0" -graphene-django = "3.2.3" -graphene-file-upload = "1.3.0" -gunicorn = "23.0.0" -httpx = "0.28.1" -jupyter = { version = "1.1.1", optional = true } -mypy = "1.16.1" -openai = { version = "1.77.0", optional = true } -paramiko = "3.5.1" -pillow = "11.2.1" -polib = "1.2.0" -poetry-core = "2.1.3" -python = ">=3.12,<3.13" -psutil = "6.1.1" -psycopg2 = "2.9.10" -pygraphviz = { version = "1.14", optional = true, markers = "sys_platform != 'win32'"} -pymdown-extensions = "10.15" -redis = "6.0.0" -requests = "2.32.3" -ruff = "0.11.8" -sentry-sdk = { extras = ["django", "celery", "opentelemetry", "redis"], version = "2.27.0" } -six = "1.17.0" -swapper = "1.4.0" -zeep = "4.3.1" +djangorestframework-recursive = "0.1.2" +djangorestframework-simplejwt = { extras = ["crypto"], version = "5.5.0" } +djangorestframework-stubs = "3.16.0" +djangorestframework-xml = "2.0.0" +djangorestframework-yaml = "2.0.0" +drf-spectacular = { extras = ["sidecar"], version = "0.28.0" } +elasticsearch-dsl = "8.18.0" +filetype = "1.2.0" +graphene-django = "3.2.3" +graphene-file-upload = "1.3.0" +gunicorn = "23.0.0" +httpx = "0.28.1" +jupyter = { version = "1.1.1", optional = true } +mypy = "1.16.1" +openai = { version = "1.77.0", optional = true } +paramiko = "3.5.1" +pillow = "11.2.1" +polib = "1.2.0" +poetry-core = "2.1.3" +python = ">=3.12,<3.13" +psutil = "6.1.1" +psycopg2 = "2.9.10" +pygraphviz = { version = "1.14", optional = true, markers = "sys_platform != 'win32'" } +pymdown-extensions = "10.15" +redis = "6.0.0" +requests = "2.32.3" +ruff = "0.11.8" +sentry-sdk = { extras = ["django", "celery", "opentelemetry", "redis"], version = "2.27.0" } +six = "1.17.0" +swapper = "1.4.0" +zeep = "4.3.1" [tool.poetry.group.dev.dependencies] -black = "25.1.0" -celery-stubs = "0.1.3" -isort = "5.13.2" -flake8 = "7.2.0" -mypy-extensions = "1.1.0" -pytest = "8.4.1" -pytest-django = "4.11.1" -coverage = "7.8.2" -pre-commit = "4.2.0" -safety = "3.5.2" -bandit = "1.2.0" +black = "25.1.0" +celery-stubs = "0.1.3" +isort = "5.13.2" +flake8 = "7.2.0" +mypy-extensions = "1.1.0" +pytest = "8.4.1" +pytest-django = "4.11.1" +coverage = "7.8.2" +pre-commit = "4.2.0" +safety = "3.5.2" +bandit = "1.2.0" [tool.poetry.extras] -graph = ["pygraphviz"] -worker = ["celery", "django-celery-beat", "django-celery-results", "celery-prometheus-exporter"] -openai = ["openai"] +graph = ["pygraphviz"] +worker = ["celery", "django-celery-beat", "django-celery-results", "celery-prometheus-exporter"] +openai = ["openai"] jupyter = ["jupyter"] -docs = ["sphinx", "sphinx-rtd-theme", "m2r2"] +docs = ["sphinx", "sphinx-rtd-theme", "m2r2"] testing = ["pytest", "pytest-django", "coverage"] linting = ["black", "isort", "flake8", "bandit"] [tool.mypy] disable_error_code = ["import-untyped", "misc"] +exclude = ["*/migrations/*"] [tool.ruff] -line-length = 120 -target-version = "py38" -exclude = ["migrations", "media", "static", "storefront"] +line-length = 120 +target-version = "py38" +exclude = ["migrations", "media", "static", "storefront"] [tool.ruff.lint] -select = ["E", "W", "F", "B", "I", "RUF", "UP", "N", "A", "COM", "C4", "DJ001", "RSE", "SIM", "ISC", "TID252", "PGH004"] -ignore = ["B904", "RUF001", "RUF002", "RUF003", "RUF005", "RUF012", "A003", "A002", "COM812", "S603"] +select = ["E", "W", "F", "B", "I", "RUF", "UP", "N", "A", "COM", "C4", "DJ001", "RSE", "SIM", "ISC", "TID252", "PGH004"] +ignore = ["B904", "RUF001", "RUF002", "RUF003", "RUF005", "RUF012", "A003", "A002", "COM812", "S603"] per-file-ignores = { "__init__.py" = ["E402", "F401"] } [tool.ruff.format] -quote-style = "double" -indent-style = "space" \ No newline at end of file +quote-style = "double" +indent-style = "space" \ No newline at end of file diff --git a/vibes_auth/managers.py b/vibes_auth/managers.py index 8f894221..ec41b096 100644 --- a/vibes_auth/managers.py +++ b/vibes_auth/managers.py @@ -41,6 +41,7 @@ class UserManager(BaseUserManager): def _create_user(self, email, password, **extra_fields): email = self.normalize_email(email) + # noinspection PyShadowingNames user = self.model(email=email, **extra_fields) user.password = make_password(password) user.save(using=self._db) @@ -55,10 +56,11 @@ class UserManager(BaseUserManager): def create_superuser(self, email=None, password=None, **extra_fields): extra_fields.setdefault("is_staff", True) extra_fields.setdefault("is_superuser", True) - if extra_fields.get("is_staff") is not True: + if not extra_fields.get("is_staff"): raise ValueError("Superuser must have is_staff=True.") - if extra_fields.get("is_superuser") is not True: + if not extra_fields.get("is_superuser"): raise ValueError("Superuser must have is_superuser=True.") + # noinspection PyShadowingNames user = self._create_user(email, password, **extra_fields) user.is_active = True user.is_verified = True diff --git a/vibes_auth/models.py b/vibes_auth/models.py index 3f859d70..49cb0954 100644 --- a/vibes_auth/models.py +++ b/vibes_auth/models.py @@ -1,3 +1,4 @@ +import uuid from uuid import uuid4 from django.contrib.auth.models import AbstractUser @@ -29,8 +30,8 @@ class User(AbstractUser, NiceModel): def get_uuid_as_path(self, *args): return str(self.uuid) + "/" + args[0] - email: EmailField = EmailField(_("email"), unique=True, help_text=_("user email address")) - phone_number: CharField = CharField( + email: str = EmailField(_("email"), unique=True, help_text=_("user email address")) + phone_number: str = CharField( _("phone_number"), max_length=20, unique=True, @@ -42,9 +43,9 @@ class User(AbstractUser, NiceModel): ], ) username = None - first_name: CharField = CharField(_("first_name"), max_length=150, blank=True, null=True) - last_name: CharField = CharField(_("last_name"), max_length=150, blank=True, null=True) - avatar: ImageField = ImageField( + first_name: str = CharField(_("first_name"), max_length=150, blank=True, null=True) + last_name: str = CharField(_("last_name"), max_length=150, blank=True, null=True) + avatar = ImageField( null=True, verbose_name=_("avatar"), upload_to=get_uuid_as_path, @@ -52,27 +53,28 @@ class User(AbstractUser, NiceModel): help_text=_("user profile image"), ) - is_verified: BooleanField = BooleanField( + is_verified: bool = BooleanField( default=False, verbose_name=_("is verified"), help_text=_("user verification status"), ) - is_active: BooleanField = BooleanField( + is_active: bool = BooleanField( _("is_active"), default=False, help_text=_("unselect this instead of deleting accounts"), ) - is_subscribed: BooleanField = BooleanField( + is_subscribed: bool = BooleanField( verbose_name=_("is_subscribed"), help_text=_("user's newsletter subscription status"), default=False ) - activation_token: UUIDField = UUIDField(default=uuid4, verbose_name=_("activation token")) - language: CharField = CharField(choices=LANGUAGES, default=LANGUAGE_CODE, null=False, blank=False, max_length=7) - attributes: JSONField = JSONField(verbose_name=_("attributes"), default=dict, blank=True, null=True) + activation_token: uuid = UUIDField(default=uuid4, verbose_name=_("activation token")) + language: str = CharField(choices=LANGUAGES, default=LANGUAGE_CODE, null=False, blank=False, max_length=7) + attributes: dict = JSONField(verbose_name=_("attributes"), default=dict, blank=True, null=True) USERNAME_FIELD = "email" REQUIRED_FIELDS = [] - objects = UserManager() # type: ignore + # noinspection PyClassVar + objects = UserManager() def add_to_recently_viewed(self, product_uuid): recently_viewed = self.recently_viewed diff --git a/vibes_auth/serializers.py b/vibes_auth/serializers.py index 243ff26d..35e0336c 100644 --- a/vibes_auth/serializers.py +++ b/vibes_auth/serializers.py @@ -122,12 +122,12 @@ class TokenObtainSerializer(Serializer): with suppress(KeyError): authenticate_kwargs["request"] = self.context["request"] - self.user = authenticate(**authenticate_kwargs) + self.user = authenticate(**authenticate_kwargs) # type: ignore if not api_settings.USER_AUTHENTICATION_RULE(self.user): raise AuthenticationFailed( self.error_messages["no_active_account"], - _("no active account"), + _("no active account"), # type: ignore ) return {} @@ -145,15 +145,15 @@ class TokenObtainPairSerializer(TokenObtainSerializer): logger.debug("Data validated") - refresh = self.get_token(self.user) + refresh = self.get_token(self.user) # type: ignore data["refresh"] = str(refresh) - data["access"] = str(refresh.access_token) + data["access"] = str(refresh.access_token) # type: ignore data["user"] = UserSerializer(self.user).data logger.debug("Data formed") if api_settings.UPDATE_LAST_LOGIN: - update_last_login(self.user, self.user) + update_last_login(self.user, self.user) # type: ignore logger.debug("Updated last login") logger.debug("Returning data") @@ -181,7 +181,7 @@ class TokenRefreshSerializer(Serializer): data["refresh"] = str(refresh) user = User.objects.get(uuid=refresh.payload["user_uuid"]) - data["user"] = UserSerializer(user).data + data["user"] = UserSerializer(user).data # type: ignore return data @@ -213,7 +213,7 @@ class TokenVerifySerializer(Serializer): except User.DoesNotExist: raise ValidationError(_("user does not exist")) - attrs["user"] = UserSerializer(user).data + attrs["user"] = UserSerializer(user).data # type: ignore return attrs