diff --git a/solution/api/v1/auth.py b/solution/api/v1/auth.py index a425910..1fb3886 100644 --- a/solution/api/v1/auth.py +++ b/solution/api/v1/auth.py @@ -7,6 +7,7 @@ from ninja.security import HttpBearer from pydantic import BaseModel, ValidationError import apps.business.models +import apps.user.models class BusinessToken(BaseModel): @@ -28,7 +29,7 @@ class BusinessAuth(HttpBearer): try: business = apps.business.models.Business.objects.get( - id=token_payload.business_id + id=token_payload.business_id, ) except apps.business.models.Business.DoesNotExist: return None @@ -37,3 +38,31 @@ class BusinessAuth(HttpBearer): return None return business + + +class UserToken(BaseModel): + user_id: uuid.UUID + token_version: int + + +class UserAuth(HttpBearer): + def authenticate(self, request: HttpRequest, token: str) -> str | None: + try: + decoded_payload = jwt.decode( + token, + settings.SECRET_KEY, + algorithms=["HS256"], + ) + token_payload = UserToken(**decoded_payload) + except (jwt.PyJWTError, ValidationError): + return None + + try: + user = apps.user.models.User.objects.get(id=token_payload.user_id) + except apps.user.models.User.DoesNotExist: + return None + + if user.token_version != token_payload.token_version: + return None + + return user diff --git a/solution/api/v1/business/schemas.py b/solution/api/v1/business/schemas.py index 58e6b94..7f14fc2 100644 --- a/solution/api/v1/business/schemas.py +++ b/solution/api/v1/business/schemas.py @@ -1,20 +1,20 @@ -import re +import datetime import uuid -from typing import ClassVar +from typing import ClassVar, Literal from ninja import ModelSchema, Schema -from pydantic import EmailStr, field_validator +from pydantic import Field from apps.business.models import Business +from apps.promo.models import Promocode, PromocodeTarget class BusinessSignUpIn(ModelSchema): - email: EmailStr - class Meta: model = Business fields: ClassVar[list[str]] = [ Business.name.field.name, + Business.email.field.name, Business.password.field.name, ] @@ -24,27 +24,85 @@ class BusinessSignUpOut(Schema): company_id: uuid.UUID -class BusinessSignInIn(Schema): - email: EmailStr - password: str - - @field_validator("password") - def validate_password(cls, value: str) -> str: # noqa: N805 - pattern = r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,60}$" # noqa: E501 - if not re.match(pattern, value): - e = ( - "Password must contain at least 8 characters, one uppercase " - "letter, one lowercase letter, one number, and one special " - "character (@$!%*?&)." - ) - raise ValueError(e) - return value +class BusinessSignInIn(ModelSchema): + class Meta: + model = Business + fields: ClassVar[list[str]] = [ + Business.email.field.name, + Business.password.field.name, + ] class BusinessSignInOut(Schema): token: str +class PromocodeTarget(ModelSchema): + categories: list[str] | None = None + + class Meta: + model = PromocodeTarget + fields: ClassVar[list[str]] = [ + PromocodeTarget.age_from.field.name, + PromocodeTarget.age_until.field.name, + PromocodeTarget.country.field.name, + PromocodeTarget.categories.field.name, + ] + + +class CreatePromocodeIn(ModelSchema): + target: PromocodeTarget + promo_unique: list[str] | None = None + + class Meta: + model = Promocode + fields: ClassVar[list[str]] = [ + Promocode.description.field.name, + Promocode.image_url.field.name, + Promocode.max_count.field.name, + Promocode.active_from.field.name, + Promocode.active_until.field.name, + Promocode.mode.field.name, + Promocode.promo_common.field.name, + ] + + +class CreatePromocodeOut(Schema): + id: uuid.UUID + + +class PromocodeListFilters(Schema): + limit: int = 10 + offset: int = 0 + query: Literal["active_from", "active_until", None] = None + country__in: list[str] = Field(None, alias="country") + + +class PromocodeTargetViewOut(Schema): + age_from: int | None + age_until: int | None + country: str | None + categories: list[str] | None + + +class PromocodeViewOut(Schema): + promo_id: uuid.UUID + company_id: uuid.UUID + description: str + image_url: str | None + target: PromocodeTargetViewOut + max_count: int + active_from: datetime.date | None + active_until: datetime.date | None + mode: str + promo_common: str | None + promo_unique: list[str] | None + company_name: str + like_count: int + used_count: int + active: bool + + __all__ = [ "BusinessSignInIn", "BusinessSignInOut", diff --git a/solution/api/v1/business/views.py b/solution/api/v1/business/views.py index b01e541..aa39bdb 100644 --- a/solution/api/v1/business/views.py +++ b/solution/api/v1/business/views.py @@ -1,12 +1,18 @@ +import datetime from http import HTTPStatus as status -from django.http import HttpRequest -from ninja import Router -from ninja.errors import AuthenticationError +from django.core.exceptions import ValidationError +from django.db.models import Count, Q, Value +from django.db.models.functions import Coalesce +from django.http import HttpRequest, HttpResponse +from ninja import Query, Router +from ninja.errors import AuthenticationError, HttpError from api.v1 import schemas as global_schemas +from api.v1.auth import BusinessAuth from api.v1.business import schemas from apps.business.models import Business +from apps.promo.models import Promocode, PromocodeTarget router = Router(tags=["business"]) @@ -20,13 +26,15 @@ router = Router(tags=["business"]) }, ) def signup( - request: HttpRequest, business: schemas.BusinessSignUpIn + request: HttpRequest, + business: schemas.BusinessSignUpIn, ) -> tuple[int, schemas.BusinessSignUpOut]: business_obj = Business(**business.dict()) business_obj.save() return status.OK, schemas.BusinessSignUpOut( - token=business_obj.generate_token(), company_id=business_obj.id + token=business_obj.generate_token(), + company_id=business_obj.id, ) @@ -39,8 +47,16 @@ def signup( }, ) def signin( - request: HttpRequest, login_data: schemas.BusinessSignInIn + request: HttpRequest, + login_data: schemas.BusinessSignInIn, ) -> tuple[int, schemas.BusinessSignInOut]: + business_obj = Business(**dict(login_data)) + business_obj.validate( + include=[Business.email.field, Business.password.field], + validate_unique=False, + validate_constraints=False, + ) + try: business_obj = Business.objects.get(email=login_data.email) except Business.DoesNotExist: @@ -53,5 +69,167 @@ def signin( business_obj.save() return status.OK, schemas.BusinessSignInOut( - token=business_obj.generate_token() + token=business_obj.generate_token(), + ) + + +@router.post( + "/promo", + auth=BusinessAuth(), + response={ + status.CREATED: schemas.CreatePromocodeOut, + status.BAD_REQUEST: global_schemas.ValidationError, + status.UNAUTHORIZED: global_schemas.UnauthorizedError, + }, +) +def create_promocode( + request: HttpRequest, + promocode: schemas.CreatePromocodeIn, +) -> schemas.CreatePromocodeOut: + business = request.auth + promocode = dict(promocode) + target = dict(promocode.pop("target")) + + target_obj = PromocodeTarget(**target) + target_obj.save() + + promocode_obj = Promocode( + business=business, + target=target_obj, + **promocode, + ) + try: + promocode_obj.save() + except ValidationError as e: + target_obj.delete() + raise e # noqa: TRY201 + + return status.CREATED, schemas.CreatePromocodeOut(id=promocode_obj.id) + + +@router.get( + "/promo", + auth=BusinessAuth(), + response={ + status.OK: list[schemas.PromocodeViewOut], + status.BAD_REQUEST: global_schemas.ValidationError, + }, + exclude_none=True, +) +def list_promocode( + request: HttpRequest, + filters: Query[schemas.PromocodeListFilters], + response: HttpResponse, +) -> list[schemas.PromocodeViewOut]: + business = request.auth + + promocodes = Promocode.objects.filter(business=business) + + if filters.country__in: + promocodes = promocodes.filter( + Q(target__country__in=filters.country__in) + | Q(target__country__isnull=True) + ) + + if filters.query == "active_from": + promocodes = promocodes.annotate( + active_from_sort=Coalesce("active_from", Value(datetime.min)) + ).order_by("-active_from_sort") + elif filters.query == "active_until": + promocodes = promocodes.annotate( + active_until_sort=Coalesce("active_until", Value(datetime.max)) + ).order_by("-active_until_sort") + else: + promocodes = promocodes.order_by("-created_at") + + promocodes = promocodes.annotate( + used_count=Count("activations"), + like_count=Count("comments"), + ) + + response["X-Total-Count"] = promocodes.count() + + promocodes = promocodes[filters.offset : filters.offset + filters.limit] + + return [ + schemas.PromocodeViewOut( + promo_id=promocode.id, + company_id=promocode.business.id, + company_name=promocode.business.name, + description=promocode.description, + image_url=promocode.image_url, + target=schemas.PromocodeTargetViewOut( + age_from=promocode.target.age_from, + age_until=promocode.target.age_until, + country=promocode.target.country.code + if promocode.target.country + else None, + categories=promocode.target.categories, + ), + max_count=promocode.max_count, + active_from=promocode.active_from, + active_until=promocode.active_until, + mode=promocode.mode, + promo_common=promocode.promo_common, + promo_unique=promocode.promo_unique, + like_count=promocode.like_count, + used_count=promocode.used_count, + active=promocode.active, + ) + for promocode in promocodes + ] + + +@router.get( + "/promo/{promocode_id}", + auth=BusinessAuth(), + response={ + status.OK: schemas.PromocodeViewOut, + status.NOT_FOUND: global_schemas.NotFoundError, + }, + exclude_none=True, +) +def product_get( + request: HttpRequest, promocode_id: str +) -> schemas.PromocodeViewOut: + business = request.auth + + promocodes = Promocode.objects.filter(id=promocode_id) + + if len(promocodes) == 0: + raise HttpError(status.NOT_FOUND, status.NOT_FOUND.phrase) + + promocodes = promocodes.annotate( + used_count=Count("activations"), + like_count=Count("comments"), + ) + + promocode = promocodes[0] + + if promocode.business != business: + raise HttpError(status.FORBIDDEN, status.FORBIDDEN.phrase) + + return schemas.PromocodeViewOut( + promo_id=promocode.id, + company_id=promocode.business.id, + company_name=promocode.business.name, + description=promocode.description, + image_url=promocode.image_url, + target=schemas.PromocodeTargetViewOut( + age_from=promocode.target.age_from, + age_until=promocode.target.age_until, + country=promocode.target.country.code + if promocode.target.country + else None, + categories=promocode.target.categories, + ), + max_count=promocode.max_count, + active_from=promocode.active_from, + active_until=promocode.active_until, + mode=promocode.mode, + promo_common=promocode.promo_common, + promo_unique=promocode.promo_unique, + like_count=promocode.like_count, + used_count=promocode.used_count, + active=promocode.active, ) diff --git a/solution/api/v1/handlers.py b/solution/api/v1/handlers.py index 21dcf72..1f8c273 100644 --- a/solution/api/v1/handlers.py +++ b/solution/api/v1/handlers.py @@ -46,7 +46,9 @@ def handle_django_validation_error( def handle_authentication_error( - request: HttpRequest, exc: errors.AuthenticationError, router: NinjaAPI + request: HttpRequest, + exc: errors.AuthenticationError, + router: NinjaAPI, ) -> HttpResponse: return router.create_response( request, @@ -56,7 +58,9 @@ def handle_authentication_error( def handle_validation_error( - request: HttpRequest, exc: errors.ValidationError, router: NinjaAPI + request: HttpRequest, + exc: errors.ValidationError, + router: NinjaAPI, ) -> HttpResponse: return router.create_response( request, @@ -66,7 +70,9 @@ def handle_validation_error( def handle_not_found_error( - request: HttpRequest, exc: Exception, router: NinjaAPI + request: HttpRequest, + exc: Exception, + router: NinjaAPI, ) -> HttpResponse: return router.create_response( request, @@ -76,7 +82,9 @@ def handle_not_found_error( def handle_unknown_exception( - request: HttpRequest, exc: Exception, router: NinjaAPI + request: HttpRequest, + exc: Exception, + router: NinjaAPI, ) -> HttpResponse: logger.exception(exc) diff --git a/solution/apps/business/models.py b/solution/apps/business/models.py index f290fb5..8715d41 100644 --- a/solution/apps/business/models.py +++ b/solution/apps/business/models.py @@ -1,7 +1,10 @@ +from datetime import timedelta + import jwt from django.conf import settings from django.core.validators import MinLengthValidator, RegexValidator from django.db import models +from django.utils import timezone from apps.core.models import BaseModel @@ -17,7 +20,7 @@ class Business(BaseModel): max_length=60, validators=[ RegexValidator( - r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$" + r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$", ), ], ) @@ -31,6 +34,7 @@ class Business(BaseModel): { "business_id": str(self.id), "token_version": self.token_version, + "exp": timezone.now() + timedelta(hours=24), }, settings.SECRET_KEY, algorithm="HS256", diff --git a/solution/apps/core/models.py b/solution/apps/core/models.py index 1918505..9f4eabe 100644 --- a/solution/apps/core/models.py +++ b/solution/apps/core/models.py @@ -1,4 +1,5 @@ import uuid +from typing import Any from django.core.exceptions import ValidationError from django.db import models @@ -12,12 +13,35 @@ class BaseModel(models.Model): class Meta: abstract = True - def save(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003 - self.full_clean(validate_unique=False) - - try: - self.validate_unique() - except ValidationError as e: - raise UniqueConstraintError(e) from None + def save(self, *args: Any, **kwargs: Any) -> None: + self.validate() super().save(*args, **kwargs) + + def validate( + self, + validate_unique: bool = True, + validate_constraints: bool = True, + include: list[models.Field] | None = None, + ) -> None: + self.full_clean( + validate_unique=False, + validate_constraints=False, + exclude=( + field.name + for field in set(self._meta.get_fields()) - set(include) + ) + if include + else None, + ) + if validate_unique: + try: + self.validate_unique() + except ValidationError as e: + raise UniqueConstraintError(e) from None + + if validate_constraints: + try: + self.validate_constraints() + except ValidationError as e: + raise UniqueConstraintError(e) from None diff --git a/solution/apps/promo/__init__.py b/solution/apps/promo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/apps/promo/admin.py b/solution/apps/promo/admin.py new file mode 100644 index 0000000..07cf5d8 --- /dev/null +++ b/solution/apps/promo/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from apps.promo.models import Promocode, PromocodeTarget + +admin.site.register(Promocode) +admin.site.register(PromocodeTarget) diff --git a/solution/apps/promo/apps.py b/solution/apps/promo/apps.py new file mode 100644 index 0000000..c37c8a0 --- /dev/null +++ b/solution/apps/promo/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PromoConfig(AppConfig): + name = "apps.promo" + label = "promo" diff --git a/solution/apps/promo/migrations/0001_initial.py b/solution/apps/promo/migrations/0001_initial.py new file mode 100644 index 0000000..888fd19 --- /dev/null +++ b/solution/apps/promo/migrations/0001_initial.py @@ -0,0 +1,80 @@ +# Generated by Django 5.1.5 on 2025-01-21 14:30 + +import apps.promo.validators +import django.core.validators +import django.db.models.deletion +import django_countries.fields +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('business', '0001_initial'), + ('user', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='PromocodeTarget', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('age_from', models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])), + ('age_until', models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])), + ('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)), + ('categories', models.JSONField(blank=True, default=list, null=True, validators=[apps.promo.validators.TargetCategoriesValidator()])), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Promocode', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('description', models.TextField(max_length=300, validators=[django.core.validators.MinLengthValidator(10)])), + ('image_url', models.URLField(blank=True, max_length=350, null=True)), + ('max_count', models.PositiveIntegerField(validators=[django.core.validators.MaxValueValidator(100000000)])), + ('active_from', models.DateField(blank=True, null=True)), + ('active_until', models.DateField(blank=True, null=True)), + ('mode', models.CharField(choices=[('COMMON', 'Common'), ('UNIQUE', 'Unique')], max_length=6)), + ('promo_common', models.CharField(blank=True, max_length=30, null=True, validators=[django.core.validators.MinLengthValidator(5)])), + ('promo_unique', models.JSONField(blank=True, default=list, null=True, validators=[apps.promo.validators.PromocodeUniqueValidator()])), + ('promo_unique_activated', models.JSONField(blank=True, default=list, editable=False, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='promocodes', to='business.business')), + ('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='promocodes', to='promo.promocodetarget')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='PromocodeActivation', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('promocode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activations', to='promo.promocode')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activations', to='user.user')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='PromocodeComment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('text', models.TextField(max_length=1000, validators=[django.core.validators.MinLengthValidator(10)])), + ('date', models.DateTimeField(auto_now_add=True)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='user.user')), + ('promocode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='promo.promocode')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/solution/apps/promo/migrations/__init__.py b/solution/apps/promo/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/apps/promo/models.py b/solution/apps/promo/models.py new file mode 100644 index 0000000..46148ee --- /dev/null +++ b/solution/apps/promo/models.py @@ -0,0 +1,171 @@ +from django.core.exceptions import ValidationError +from django.core.validators import ( + MaxValueValidator, + MinLengthValidator, + MinValueValidator, +) +from django.db import models +from django.utils import timezone +from django_countries.fields import CountryField + +from apps.business.models import Business +from apps.core.models import BaseModel +from apps.promo.validators import ( + PromocodeDurationValidator, + PromocodeUniqueValidator, + TargetAgeValidator, + TargetCategoriesValidator, +) +from apps.user.models import User + + +class PromocodeTarget(BaseModel): + age_from = models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[MinValueValidator(0), MaxValueValidator(100)], + ) + age_until = models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[MinValueValidator(0), MaxValueValidator(100)], + ) + country = CountryField(blank=True, null=True) + categories = models.JSONField( + blank=True, + null=True, + default=list, + validators=[TargetCategoriesValidator()], + ) + + def __str__(self) -> str: + return str(self.id) + + def clean(self) -> None: + super().clean() + + TargetAgeValidator()(self) + + +class Promocode(BaseModel): + class ModeChoices(models.TextChoices): + COMMON = "COMMON" + UNIQUE = "UNIQUE" + + business = models.ForeignKey( + Business, + on_delete=models.CASCADE, + related_name="promocodes", + ) + description = models.TextField( + max_length=300, + validators=[MinLengthValidator(10)], + ) + image_url = models.URLField(max_length=350, blank=True, null=True) + target = models.ForeignKey( + PromocodeTarget, + on_delete=models.CASCADE, + related_name="promocodes", + ) + max_count = models.PositiveIntegerField( + validators=[MaxValueValidator(100000000)], + ) + active_from = models.DateField(blank=True, null=True) + active_until = models.DateField(blank=True, null=True) + mode = models.CharField(max_length=6, choices=ModeChoices) + promo_common = models.CharField( + max_length=30, + blank=True, + null=True, + validators=[MinLengthValidator(5)], + ) + promo_unique = models.JSONField( + blank=True, + null=True, + default=list, + validators=[PromocodeUniqueValidator()], + ) + promo_unique_activated = models.JSONField( + blank=True, + null=True, + default=list, + editable=False, + ) + created_at = models.DateTimeField(auto_now_add=True) + + def clean(self) -> None: + super().clean() + + if self.mode == self.ModeChoices.COMMON: + if not self.promo_common: + err = { + "promo_common": "Field is required for COMMON mode.", + } + raise ValidationError(err) + if self.promo_unique: + err = { + "promo_unique": "Field must be empty for COMMON mode.", + } + raise ValidationError(err) + elif self.mode == self.ModeChoices.UNIQUE: + if not self.promo_unique: + err = { + "promo_unique": "Field is required for UNIQUE mode.", + } + raise ValidationError(err) + if self.promo_common: + err = { + "promo_common": "Field must be empty for UNIQUE mode.", + } + raise ValidationError(err) + + PromocodeDurationValidator()(self) + + @property + def active(self) -> bool: + current_date = timezone.datetime.today().date() + + is_active_by_date = ( + self.active_from is None or self.active_from <= current_date + ) and (self.active_until is None or self.active_until >= current_date) + + if self.mode == self.ModeChoices.COMMON: + is_active_by_mode = self.activations.count() < self.max_count + elif self.mode == self.ModeChoices.UNIQUE: + is_active_by_mode = len(self.promo_unique) > len( + self.promo_unique_activated + ) + + return is_active_by_date and is_active_by_mode + + +class PromocodeActivation(BaseModel): + promocode = models.ForeignKey( + Promocode, + on_delete=models.CASCADE, + related_name="activations", + ) + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="activations", + ) + timestamp = models.DateTimeField(auto_now_add=True) + + +class PromocodeComment(BaseModel): + promocode = models.ForeignKey( + Promocode, + on_delete=models.CASCADE, + related_name="comments", + ) + author = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="comments", + ) + text = models.TextField( + max_length=1000, + validators=[MinLengthValidator(10)], + ) + date = models.DateTimeField(auto_now_add=True) diff --git a/solution/apps/promo/validators.py b/solution/apps/promo/validators.py new file mode 100644 index 0000000..d93cee7 --- /dev/null +++ b/solution/apps/promo/validators.py @@ -0,0 +1,82 @@ +from django.core.exceptions import ValidationError +from django.core.validators import BaseValidator +from django.utils.deconstruct import deconstructible + +MAX_CATEGORIES_LIST_LEN = 20 +MIN_CATEGORY_LEN = 2 +MAX_CATEGORY_LEN = 20 + +MIN_UNIQUE_PROMOCODES_LIST_LEN = 1 +MAX_UNIQUE_PROMOCODES_LIST_LEN = 5000 +MIN_UNIQUE_PROMOCODE_LEN = 3 +MAX_UNIQUE_PROMOCODE_LEN = 30 + + +class TargetAgeValidator: + def __call__(self, instance) -> None: # noqa: ANN001 + if ( + instance.age_from is not None + and instance.age_until is not None + and instance.age_from > instance.age_until + ): + err = "age_from can't be greater than age_until" + raise ValidationError(err) + + +class PromocodeDurationValidator: + def __call__(self, instance) -> None: # noqa: ANN001 + if ( + instance.active_from is not None + and instance.active_until is not None + and instance.active_from > instance.active_until + ): + err = "active_from can't be greater than active_until" + raise ValidationError(err) + + +@deconstructible +class TargetCategoriesValidator(BaseValidator): + def __init__(self) -> None: + pass + + def __call__(self, categories: list) -> None: + if not isinstance(categories, list): + err = "categories must be a list" + raise ValidationError(err) + + if len(categories) > MAX_CATEGORIES_LIST_LEN: + err = "max. categories length is 20" + raise ValidationError(err) + + for category in categories: + if not (MIN_CATEGORY_LEN <= len(category) <= MAX_CATEGORY_LEN): + err = "category name length must be >=2 and <=20" + raise ValidationError(err) + + +@deconstructible +class PromocodeUniqueValidator(BaseValidator): + def __init__(self) -> None: + pass + + def __call__(self, promocodes: list) -> None: + if not isinstance(promocodes, list): + err = "unque promocodes must be a list" + raise ValidationError(err) + + if not ( + MIN_UNIQUE_PROMOCODES_LIST_LEN + <= len(promocodes) + <= MAX_UNIQUE_PROMOCODES_LIST_LEN + ): + err = "unique promocodes length must be >=1 and <=5000" + raise ValidationError(err) + + for promocode in promocodes: + if not ( + MIN_UNIQUE_PROMOCODE_LEN + <= len(promocode) + <= MAX_UNIQUE_PROMOCODE_LEN + ): + err = "unique promocode length must be >=3 and <=30" + raise ValidationError(err) diff --git a/solution/apps/user/__init__.py b/solution/apps/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/apps/user/admin.py b/solution/apps/user/admin.py new file mode 100644 index 0000000..600fcb9 --- /dev/null +++ b/solution/apps/user/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from apps.user.models import User + +admin.site.register(User) diff --git a/solution/apps/user/apps.py b/solution/apps/user/apps.py new file mode 100644 index 0000000..51225ec --- /dev/null +++ b/solution/apps/user/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UserConfig(AppConfig): + name = "apps.user" + label = "user" diff --git a/solution/apps/user/migrations/0001_initial.py b/solution/apps/user/migrations/0001_initial.py new file mode 100644 index 0000000..762950d --- /dev/null +++ b/solution/apps/user/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.5 on 2025-01-21 11:05 + +import django.core.validators +import django_countries.fields +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, validators=[django.core.validators.MinLengthValidator(1)])), + ('surname', models.CharField(max_length=120, validators=[django.core.validators.MinLengthValidator(1)])), + ('email', models.EmailField(max_length=120, unique=True, validators=[django.core.validators.MinLengthValidator(8)])), + ('avatar_url', models.URLField(blank=True, max_length=350, null=True)), + ('age', models.PositiveSmallIntegerField(validators=[django.core.validators.MaxValueValidator(100)])), + ('country', django_countries.fields.CountryField(max_length=2)), + ('password', models.CharField(max_length=60, validators=[django.core.validators.RegexValidator('^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$')])), + ('token_version', models.BigIntegerField(default=0)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/solution/apps/user/migrations/__init__.py b/solution/apps/user/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/apps/user/models.py b/solution/apps/user/models.py new file mode 100644 index 0000000..aa307d9 --- /dev/null +++ b/solution/apps/user/models.py @@ -0,0 +1,53 @@ +from datetime import timedelta + +import jwt +from django.conf import settings +from django.core.validators import ( + MaxValueValidator, + MinLengthValidator, + RegexValidator, +) +from django.db import models +from django.utils import timezone +from django_countries.fields import CountryField + +from apps.core.models import BaseModel + + +class User(BaseModel): + name = models.CharField(max_length=100, validators=[MinLengthValidator(1)]) + surname = models.CharField( + max_length=120, + validators=[MinLengthValidator(1)], + ) + email = models.EmailField( + unique=True, + max_length=120, + validators=[MinLengthValidator(8)], + ) + avatar_url = models.URLField(max_length=350, blank=True, null=True) + age = models.PositiveSmallIntegerField(validators=[MaxValueValidator(100)]) + country = CountryField(max_length=2) + password = models.CharField( + max_length=60, + validators=[ + RegexValidator( + r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$", + ), + ], + ) + token_version = models.BigIntegerField(default=0) + + def __str__(self) -> str: + return f"{self.surname} {self.name}" + + def generate_token(self) -> str: + return jwt.encode( + { + "user_id": str(self.id), + "token_version": self.token_version, + "exp": timezone.now() + timedelta(hours=24), + }, + settings.SECRET_KEY, + algorithm="HS256", + ) diff --git a/solution/config/handlers.py b/solution/config/handlers.py index aa41f72..7adeead 100644 --- a/solution/config/handlers.py +++ b/solution/config/handlers.py @@ -4,7 +4,8 @@ from django.http import HttpRequest, JsonResponse def handler400( - request: HttpRequest, exception: Exception | None = None + request: HttpRequest, + exception: Exception | None = None, ) -> JsonResponse: return JsonResponse( status=status.BAD_REQUEST, @@ -13,7 +14,8 @@ def handler400( def handler403( - request: HttpRequest, exception: Exception | None = None + request: HttpRequest, + exception: Exception | None = None, ) -> JsonResponse: return JsonResponse( status=status.FORBIDDEN, @@ -22,7 +24,8 @@ def handler403( def handler404( - request: HttpRequest, exception: Exception | None = None + request: HttpRequest, + exception: Exception | None = None, ) -> JsonResponse: return JsonResponse( status=status.NOT_FOUND, @@ -31,7 +34,8 @@ def handler404( def handler500( - request: HttpRequest, exception: Exception | None = None + request: HttpRequest, + exception: Exception | None = None, ) -> JsonResponse: return JsonResponse( status=status.INTERNAL_SERVER_ERROR, diff --git a/solution/config/notifiers/telegram.py b/solution/config/notifiers/telegram.py index fd3e3b0..6ca3a65 100644 --- a/solution/config/notifiers/telegram.py +++ b/solution/config/notifiers/telegram.py @@ -69,7 +69,9 @@ class LoggingHandler(logging.Handler): for attempt in range(1, self.retries + 1): response = httpx.post( - self.api_url, data=payload, timeout=self.timeout + self.api_url, + data=payload, + timeout=self.timeout, ) if response.status_code != httpx.codes.OK: if attempt == self.retries: @@ -86,7 +88,8 @@ class LoggingHandler(logging.Handler): def format(self, record: logging.LogRecord) -> str: try: asctime = datetime.datetime.fromtimestamp( - record.created, tz=get_current_timezone() + record.created, + tz=get_current_timezone(), ).strftime("%Y-%m-%d %H:%M:%S %Z") level_emoji = LEVEL_EMOJIS.get(record.levelname, "") @@ -111,7 +114,8 @@ class LoggingHandler(logging.Handler): formatted_message += f" #{record.correlation_id}" except Exception as format_error: # noqa: BLE001 TELEGRAM_LOG_HANDLER.exception( - "Error formatting log record: %s", format_error + "Error formatting log record: %s", + format_error, ) return f"Error formatting log record: {format_error}" else: diff --git a/solution/config/settings.py b/solution/config/settings.py index ec23b17..7bc81c1 100644 --- a/solution/config/settings.py +++ b/solution/config/settings.py @@ -52,7 +52,7 @@ CACHES = { "LOCATION": REDIS_URI, "TIMEOUT": None, "KEY_PREFIX": "django", - } + }, } @@ -72,22 +72,22 @@ AUTH_PASSWORD_VALIDATORS = [ "NAME": ( "django.contrib.auth." "password_validation.UserAttributeSimilarityValidator" - ) + ), }, { "NAME": ( "django.contrib.auth.password_validation.MinimumLengthValidator" - ) + ), }, { "NAME": ( "django.contrib.auth.password_validation.CommonPasswordValidator" - ) + ), }, { "NAME": ( "django.contrib.auth.password_validation.NumericPasswordValidator" - ) + ), }, ] @@ -244,15 +244,18 @@ WSGI_APPLICATION = "config.wsgi.application" # Telegram NOTIFIER_TELEGRAM_BOT_TOKEN = env( - "DJANGO_NOTIFIER_TELEGRAM_BOT_TOKEN", default=None + "DJANGO_NOTIFIER_TELEGRAM_BOT_TOKEN", + default=None, ) NOTIFIER_TELEGRAM_CHAT_ID = env( - "DJANGO_NOTIFIER_TELEGRAM_CHAT_ID", default=None + "DJANGO_NOTIFIER_TELEGRAM_CHAT_ID", + default=None, ) NOTIFIER_TELEGRAM_THREAD_ID = env( - "DJANGO_NOTIFIER_TELEGRAM_THREAD_ID", default=None + "DJANGO_NOTIFIER_TELEGRAM_THREAD_ID", + default=None, ) @@ -416,6 +419,8 @@ INSTALLED_APPS = [ # Internal apps "apps.core", "apps.business", + "apps.user", + "apps.promo", # API v1 apps "api.v1.ping", "api.v1.business", @@ -531,7 +536,7 @@ TEMPLATES = [ "string_if_invalid": "", "file_charset": "utf-8", }, - } + }, ]