diff --git a/solution/api/v1/business/schemas.py b/solution/api/v1/business/schemas.py index 7f14fc2..c79611d 100644 --- a/solution/api/v1/business/schemas.py +++ b/solution/api/v1/business/schemas.py @@ -4,6 +4,7 @@ from typing import ClassVar, Literal from ninja import ModelSchema, Schema from pydantic import Field +from pydantic_extra_types.country import CountryAlpha2 from apps.business.models import Business from apps.promo.models import Promocode, PromocodeTarget @@ -39,13 +40,13 @@ class BusinessSignInOut(Schema): class PromocodeTarget(ModelSchema): categories: list[str] | None = None + country: 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, ] @@ -72,10 +73,12 @@ class CreatePromocodeOut(Schema): 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") + limit: int = Field(10, gt=0, description="Limit must be greater than 0") + offset: int = Field( + 0, ge=0, description="Offset must be greater than or equal to 0" + ) + sort_by: Literal["active_from", "active_until", None] = None + country__in: list[CountryAlpha2] = Field(None, alias="country") class PromocodeTargetViewOut(Schema): @@ -103,9 +106,20 @@ class PromocodeViewOut(Schema): active: bool -__all__ = [ - "BusinessSignInIn", - "BusinessSignInOut", - "BusinessSignUpIn", - "BusinessSignUpOut", -] +class PatchPromocodeIn(Schema): + description: str | None = None + image_url: str | None = None + target: PromocodeTarget | None = None + max_count: int | None = None + active_from: datetime.date | None = None + active_until: datetime.date | None = None + + +class PromocodeStatsForCountry(Schema): + country: str + activations_count: int + + +class PromocodeStats(Schema): + activations_count: int + countries: list[PromocodeStatsForCountry] | None = None diff --git a/solution/api/v1/business/utils.py b/solution/api/v1/business/utils.py new file mode 100644 index 0000000..8555ada --- /dev/null +++ b/solution/api/v1/business/utils.py @@ -0,0 +1,29 @@ +from api.v1.business import schemas +from apps.promo.models import Promocode + + +def map_promocode_to_schema(promocode: Promocode) -> schemas.PromocodeViewOut: + 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_raw + if promocode.target.country_raw + 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/business/views.py b/solution/api/v1/business/views.py index aa39bdb..4cbd8f6 100644 --- a/solution/api/v1/business/views.py +++ b/solution/api/v1/business/views.py @@ -1,4 +1,5 @@ import datetime +from collections import Counter from http import HTTPStatus as status from django.core.exceptions import ValidationError @@ -10,7 +11,7 @@ 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 api.v1.business import schemas, utils from apps.business.models import Business from apps.promo.models import Promocode, PromocodeTarget @@ -90,7 +91,7 @@ def create_promocode( promocode = dict(promocode) target = dict(promocode.pop("target")) - target_obj = PromocodeTarget(**target) + target_obj = PromocodeTarget(**target, country_raw=target["country"]) target_obj.save() promocode_obj = Promocode( @@ -123,7 +124,9 @@ def list_promocode( ) -> list[schemas.PromocodeViewOut]: business = request.auth - promocodes = Promocode.objects.filter(business=business) + promocodes = Promocode.objects.filter(business=business).select_related( + "target", "business" + ) if filters.country__in: promocodes = promocodes.filter( @@ -131,52 +134,31 @@ def list_promocode( | Q(target__country__isnull=True) ) - if filters.query == "active_from": + response["X-Total-Count"] = promocodes.count() + + min_datetime = datetime.date(datetime.MINYEAR, 1, 1) + max_datetime = datetime.date(datetime.MAXYEAR, 1, 1) + + if filters.sort_by == "active_from": promocodes = promocodes.annotate( - active_from_sort=Coalesce("active_from", Value(datetime.min)) + active_from_sort=Coalesce("active_from", Value(min_datetime)) ).order_by("-active_from_sort") - elif filters.query == "active_until": + elif filters.sort_by == "active_until": promocodes = promocodes.annotate( - active_until_sort=Coalesce("active_until", Value(datetime.max)) + active_until_sort=Coalesce("active_until", Value(max_datetime)) ).order_by("-active_until_sort") else: promocodes = promocodes.order_by("-created_at") promocodes = promocodes.annotate( used_count=Count("activations"), - like_count=Count("comments"), + like_count=Count("likes"), ) - 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 + utils.map_promocode_to_schema(promocode) for promocode in promocodes ] @@ -189,47 +171,128 @@ def list_promocode( }, exclude_none=True, ) -def product_get( +def get_promocode( request: HttpRequest, promocode_id: str ) -> schemas.PromocodeViewOut: business = request.auth - promocodes = Promocode.objects.filter(id=promocode_id) + promocodes = Promocode.objects.filter(id=promocode_id).select_related( + "target", "business" + ) - if len(promocodes) == 0: + if not promocodes.exists(): raise HttpError(status.NOT_FOUND, status.NOT_FOUND.phrase) promocodes = promocodes.annotate( used_count=Count("activations"), - like_count=Count("comments"), + like_count=Count("likes"), ) - promocode = promocodes[0] + promocode = promocodes.first() 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, + return utils.map_promocode_to_schema(promocode) + + +@router.patch( + "/promo/{promocode_id}", + auth=BusinessAuth(), + response={ + status.OK: schemas.PromocodeViewOut, + status.NOT_FOUND: global_schemas.NotFoundError, + }, + exclude_none=True, +) +def patch_promocode( + request: HttpRequest, + promocode_id: str, + patched_fields: schemas.PatchPromocodeIn, +) -> schemas.PromocodeViewOut: + business = request.auth + + promocodes = Promocode.objects.filter(id=promocode_id).select_related( + "target", "business" + ) + + if not promocodes.exists(): + raise HttpError(status.NOT_FOUND, status.NOT_FOUND.phrase) + + promocodes = promocodes.annotate( + used_count=Count("activations"), + like_count=Count("likes"), + ) + + promocode = promocodes.first() + + if promocode.business != business: + raise HttpError(status.FORBIDDEN, status.FORBIDDEN.phrase) + + patch_data = patched_fields.dict(exclude_unset=True) + target_data = patch_data.pop("target", None) + + for field, value in patch_data.items(): + setattr(promocode, field, value) + + if target_data: + for field, value in target_data.items(): + setattr(promocode.target, field, value) + if "country" in target_data: + promocode.target.country_raw = target_data["country"] + promocode.target.save() + + promocode.save() + + return utils.map_promocode_to_schema(promocode) + + +@router.get( + "/promo/{promocode_id}/stat", + auth=BusinessAuth(), + response={ + status.OK: schemas.PromocodeStats, + status.NOT_FOUND: global_schemas.NotFoundError, + }, + exclude_none=True, +) +def promocode_stat( + request: HttpRequest, promocode_id: str +) -> schemas.PromocodeStats: + business = request.auth + + promocodes = Promocode.objects.filter(id=promocode_id).prefetch_related( + "activations", + ) + + if not promocodes.exists(): + raise HttpError(status.NOT_FOUND, status.NOT_FOUND.phrase) + + promocodes.prefetch_related("activations__user") + promocode = promocodes.first() + + if promocode.business != business: + raise HttpError(status.FORBIDDEN, status.FORBIDDEN.phrase) + + activations = promocode.activations.all() + activations_count = activations.count() + + country_activations = Counter( + activation.user.country.code + for activation in activations + if activation.user.country + ) + + sorted_countries = sorted(country_activations.items(), key=lambda x: x[0]) + + return status.OK, schemas.PromocodeStats( + activations_count=activations_count, + countries=[ + schemas.PromocodeStatsForCountry( + country=country, activations_count=count + ) + for country, count in sorted_countries + ] + if country_activations.items() + else None, ) diff --git a/solution/api/v1/ping/schemas.py b/solution/api/v1/ping/schemas.py index b507b0d..191fe5c 100644 --- a/solution/api/v1/ping/schemas.py +++ b/solution/api/v1/ping/schemas.py @@ -3,6 +3,3 @@ from ninja import Schema class PingOut(Schema): message_from_basement: str - - -__all__ = ["PingOut"] diff --git a/solution/api/v1/router.py b/solution/api/v1/router.py index 3a1acdc..cbfebee 100644 --- a/solution/api/v1/router.py +++ b/solution/api/v1/router.py @@ -5,6 +5,7 @@ from ninja import NinjaAPI from api.v1 import handlers from api.v1.business.views import router as business_router from api.v1.ping.views import router as ping_router +from api.v1.user.views import router as user_router router = NinjaAPI( title="Promocode API", @@ -25,6 +26,10 @@ router.add_router( "business", business_router, ) +router.add_router( + "user", + user_router, +) # Register exception handlers diff --git a/solution/api/v1/schemas.py b/solution/api/v1/schemas.py index 96aea26..7587160 100644 --- a/solution/api/v1/schemas.py +++ b/solution/api/v1/schemas.py @@ -17,11 +17,3 @@ class ValidationError(Schema): class UniqueConstraintError(Schema): detail: str - - -__all__ = [ - "NotFoundError", - "UnauthorizedError", - "UniqueConstraintError", - "ValidationError", -] diff --git a/solution/api/v1/user/__init__.py b/solution/api/v1/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/api/v1/user/apps.py b/solution/api/v1/user/apps.py new file mode 100644 index 0000000..ec5e656 --- /dev/null +++ b/solution/api/v1/user/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PingConfig(AppConfig): + name = "api.v1.user" + label = "api_v1_user" diff --git a/solution/api/v1/user/schemas.py b/solution/api/v1/user/schemas.py new file mode 100644 index 0000000..9ebdfb3 --- /dev/null +++ b/solution/api/v1/user/schemas.py @@ -0,0 +1,89 @@ +import uuid +from typing import ClassVar + +from ninja import ModelSchema, Schema +from pydantic import Field + +from apps.user.models import User + + +class UserTarget(ModelSchema): + class Meta: + model = User + fields: ClassVar[list[str]] = [ + User.age.field.name, + User.country.field.name, + ] + + +class UserSignUpIn(ModelSchema): + other: UserTarget + + class Meta: + model = User + fields: ClassVar[list[str]] = [ + User.name.field.name, + User.surname.field.name, + User.email.field.name, + User.avatar_url.field.name, + User.password.field.name, + ] + + +class UserSignUpOut(Schema): + token: str + + +class UserSignInIn(ModelSchema): + class Meta: + model = User + fields: ClassVar[list[str]] = [ + User.email.field.name, + User.password.field.name, + ] + + +class UserSignInOut(Schema): + token: str + + +class ViewUserOut(ModelSchema): + other: UserTarget + + class Meta: + model = User + fields: ClassVar[list[str]] = [ + User.name.field.name, + User.surname.field.name, + User.email.field.name, + User.avatar_url.field.name, + ] + + +class PatchUserIn(Schema): + name: str | None = None + surname: str | None = None + avatar_url: str | None = None + password: str | None = None + + +class PromocodeFeedFilters(Schema): + limit: int = Field(10, gt=0, description="Limit must be greater than 0") + offset: int = Field( + 0, ge=0, description="Offset must be greater than or equal to 0" + ) + category: str | None = None + active: bool | None = None + + +class PromocodeViewOut(Schema): + promo_id: uuid.UUID + company_id: uuid.UUID + company_name: str + description: str + image_url: str | None + active: bool + is_activated_by_user: bool + is_liked_by_user: bool + like_count: int + comment_count: int diff --git a/solution/api/v1/user/utils.py b/solution/api/v1/user/utils.py new file mode 100644 index 0000000..603b548 --- /dev/null +++ b/solution/api/v1/user/utils.py @@ -0,0 +1,31 @@ +from api.v1.user import schemas +from apps.promo.models import Promocode +from apps.user.models import User + + +def map_user_to_schema(user: User) -> schemas.ViewUserOut: + return schemas.ViewUserOut( + name=user.name, + surname=user.surname, + email=user.email, + avatar_url=user.avatar_url, + other=schemas.UserTarget( + age=user.age, + country=user.country_raw, + ), + ) + + +def map_promocode_to_schema(promocode: Promocode) -> schemas.PromocodeViewOut: + 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, + is_activated_by_user=promocode.is_activated_by_user, + is_liked_by_user=promocode.is_liked_by_user, + like_count=promocode.like_count, + comment_count=promocode.comment_count, + active=promocode.active, + ) diff --git a/solution/api/v1/user/views.py b/solution/api/v1/user/views.py new file mode 100644 index 0000000..17deabe --- /dev/null +++ b/solution/api/v1/user/views.py @@ -0,0 +1,171 @@ +from http import HTTPStatus as status + +from django.db.models import Count, Exists, OuterRef, Q +from django.http import HttpRequest, HttpResponse +from ninja import Query, Router +from ninja.errors import AuthenticationError + +from api.v1 import schemas as global_schemas +from api.v1.auth import UserAuth +from api.v1.user import schemas, utils +from apps.promo.models import Promocode, PromocodeActivation, PromocodeLike +from apps.user.models import User + +router = Router(tags=["user"]) + + +@router.post( + "/auth/sign-up", + response={ + status.OK: schemas.UserSignUpOut, + status.BAD_REQUEST: global_schemas.ValidationError, + status.CONFLICT: global_schemas.UniqueConstraintError, + }, +) +def signup( + request: HttpRequest, + user: schemas.UserSignUpIn, +) -> tuple[int, schemas.UserSignUpOut]: + user_obj = User( + **user.dict(exclude={"other"}), + **user.other.dict(), + country_raw=user.other.dict()["country"], + ) + user_obj.save() + + return status.OK, schemas.UserSignUpOut( + token=user_obj.generate_token(), + ) + + +@router.post( + "/auth/sign-in", + response={ + status.OK: schemas.UserSignInOut, + status.BAD_REQUEST: global_schemas.ValidationError, + status.UNAUTHORIZED: global_schemas.UnauthorizedError, + }, +) +def signin( + request: HttpRequest, + login_data: schemas.UserSignInIn, +) -> tuple[int, schemas.UserSignInOut]: + user_obj = User(**dict(login_data)) + user_obj.validate( + include=[User.email.field, User.password.field], + validate_unique=False, + validate_constraints=False, + ) + + try: + user_obj = User.objects.get(email=login_data.email) + except User.DoesNotExist: + raise AuthenticationError from None + + if user_obj.password != login_data.password: + raise AuthenticationError + + user_obj.token_version += 1 + user_obj.save() + + return status.OK, schemas.UserSignInOut( + token=user_obj.generate_token(), + ) + + +@router.get( + "/profile", + auth=UserAuth(), + response={ + status.OK: schemas.ViewUserOut, + }, + exclude_none=True, +) +def get_profile(request: HttpRequest) -> schemas.ViewUserOut: + user = request.auth + + return utils.map_user_to_schema(user) + + +@router.patch( + "/profile", + auth=UserAuth(), + response={ + status.OK: schemas.ViewUserOut, + status.BAD_REQUEST: global_schemas.ValidationError, + }, + exclude_none=True, +) +def patch_profile( + request: HttpRequest, patched_fields: schemas.PatchUserIn +) -> schemas.ViewUserOut: + user = request.auth + + patch_data = patched_fields.dict(exclude_unset=True) + for field, value in patch_data.items(): + setattr(user, field, value) + + user.save() + + return utils.map_user_to_schema(user) + + +@router.get( + "/feed", + auth=UserAuth(), + response={ + status.OK: list[schemas.PromocodeViewOut], + status.BAD_REQUEST: global_schemas.ValidationError, + }, + exclude_none=True, +) +def feed( + request: HttpRequest, + filters: Query[schemas.PromocodeFeedFilters], + response: HttpResponse, +) -> list[schemas.PromocodeViewOut]: + user: User = request.auth + + promocodes = Promocode.objects + + promocodes = promocodes.filter( + Q( + Q(target__age_from__isnull=True) + | Q(target__age_from__lte=user.age), + Q(target__age_until__isnull=True) + | Q(target__age_until__gte=user.age), + Q(target__country__isnull=True) | Q(target__country=user.country), + ) + ) + + promocodes = promocodes.annotate( + like_count=Count("likes"), + comment_count=Count("comments"), + is_liked_by_user=Exists( + PromocodeLike.objects.filter(promocode=OuterRef("pk"), user=user) + ), + is_activated_by_user=Exists( + PromocodeActivation.objects.filter( + promocode=OuterRef("pk"), user=user + ) + ), + ) + + promocodes = promocodes.order_by("-created_at") + + if filters.category: + category_lower = filters.category.lower() + promocodes = promocodes.filter( + Q(target__categories__icontains=category_lower) + ) + + if filters.active is not None: + promocodes = [p for p in promocodes if p.active == filters.active] + + response["X-Total-Count"] = promocodes.count() + + promocodes = promocodes[filters.offset : filters.offset + filters.limit] + + return [ + utils.map_promocode_to_schema(promocode) for promocode in promocodes + ] diff --git a/solution/apps/core/models.py b/solution/apps/core/models.py index 9f4eabe..1c522f9 100644 --- a/solution/apps/core/models.py +++ b/solution/apps/core/models.py @@ -34,6 +34,7 @@ class BaseModel(models.Model): if include else None, ) + if validate_unique: try: self.validate_unique() diff --git a/solution/apps/promo/admin.py b/solution/apps/promo/admin.py index 07cf5d8..78a1261 100644 --- a/solution/apps/promo/admin.py +++ b/solution/apps/promo/admin.py @@ -1,6 +1,15 @@ from django.contrib import admin -from apps.promo.models import Promocode, PromocodeTarget +from apps.promo.models import ( + Promocode, + PromocodeActivation, + PromocodeComment, + PromocodeLike, + PromocodeTarget, +) admin.site.register(Promocode) admin.site.register(PromocodeTarget) +admin.site.register(PromocodeActivation) +admin.site.register(PromocodeComment) +admin.site.register(PromocodeLike) diff --git a/solution/apps/promo/migrations/0001_initial.py b/solution/apps/promo/migrations/0001_initial.py index 888fd19..7e6aea5 100644 --- a/solution/apps/promo/migrations/0001_initial.py +++ b/solution/apps/promo/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.5 on 2025-01-21 14:30 +# Generated by Django 5.1.5 on 2025-01-23 17:54 import apps.promo.validators import django.core.validators @@ -25,6 +25,7 @@ class Migration(migrations.Migration): ('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)), + ('country_raw', models.CharField(blank=True, max_length=2, null=True)), ('categories', models.JSONField(blank=True, default=list, null=True, validators=[apps.promo.validators.TargetCategoriesValidator()])), ], options={ @@ -43,7 +44,7 @@ class Migration(migrations.Migration): ('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)), + ('promo_unique_activated', models.JSONField(blank=True, default=list, 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')), @@ -77,4 +78,15 @@ class Migration(migrations.Migration): 'abstract': False, }, ), + migrations.CreateModel( + name='PromocodeLike', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('promocode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to='promo.promocode')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='liked_promocodes', to='user.user')), + ], + options={ + 'abstract': False, + }, + ), ] diff --git a/solution/apps/promo/models.py b/solution/apps/promo/models.py index 46148ee..7812a35 100644 --- a/solution/apps/promo/models.py +++ b/solution/apps/promo/models.py @@ -31,6 +31,7 @@ class PromocodeTarget(BaseModel): validators=[MinValueValidator(0), MaxValueValidator(100)], ) country = CountryField(blank=True, null=True) + country_raw = models.CharField(max_length=2, blank=True, null=True) categories = models.JSONField( blank=True, null=True, @@ -61,7 +62,11 @@ class Promocode(BaseModel): max_length=300, validators=[MinLengthValidator(10)], ) - image_url = models.URLField(max_length=350, blank=True, null=True) + image_url = models.URLField( + max_length=350, + blank=True, + null=True, + ) target = models.ForeignKey( PromocodeTarget, on_delete=models.CASCADE, @@ -89,13 +94,21 @@ class Promocode(BaseModel): blank=True, null=True, default=list, - editable=False, ) created_at = models.DateTimeField(auto_now_add=True) + def __str__(self) -> str: + return str(self.id) + def clean(self) -> None: super().clean() + if self.image_url == "": + err = { + "image_url": "Field cannot be blank.", + } + raise ValidationError(err) + if self.mode == self.ModeChoices.COMMON: if not self.promo_common: err = { @@ -107,6 +120,11 @@ class Promocode(BaseModel): "promo_unique": "Field must be empty for COMMON mode.", } raise ValidationError(err) + if self.max_count < self.activations.count(): + err = { + "max_count": "Activations count is bigger than max_count", + } + raise ValidationError(err) elif self.mode == self.ModeChoices.UNIQUE: if not self.promo_unique: err = { @@ -118,6 +136,11 @@ class Promocode(BaseModel): "promo_common": "Field must be empty for UNIQUE mode.", } raise ValidationError(err) + if self.max_count != 1: + err = { + "max_count": "Field must be 1 for UNIQUE mode.", + } + raise ValidationError(err) PromocodeDurationValidator()(self) @@ -134,6 +157,8 @@ class Promocode(BaseModel): elif self.mode == self.ModeChoices.UNIQUE: is_active_by_mode = len(self.promo_unique) > len( self.promo_unique_activated + if self.promo_unique_activated + else [] ) return is_active_by_date and is_active_by_mode @@ -152,6 +177,9 @@ class PromocodeActivation(BaseModel): ) timestamp = models.DateTimeField(auto_now_add=True) + def __str__(self) -> str: + return f"{self.promocode.id} | {self.user.id}" + class PromocodeComment(BaseModel): promocode = models.ForeignKey( @@ -169,3 +197,15 @@ class PromocodeComment(BaseModel): validators=[MinLengthValidator(10)], ) date = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return f"{self.promocode.id} | {self.author.id}" + + +class PromocodeLike(BaseModel): + promocode = models.ForeignKey( + Promocode, on_delete=models.CASCADE, related_name="likes" + ) + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="liked_promocodes" + ) diff --git a/solution/apps/promo/validators.py b/solution/apps/promo/validators.py index d93cee7..f932ca1 100644 --- a/solution/apps/promo/validators.py +++ b/solution/apps/promo/validators.py @@ -61,7 +61,7 @@ class PromocodeUniqueValidator(BaseValidator): def __call__(self, promocodes: list) -> None: if not isinstance(promocodes, list): - err = "unque promocodes must be a list" + err = "unique promocodes must be a list" raise ValidationError(err) if not ( diff --git a/solution/apps/user/migrations/0001_initial.py b/solution/apps/user/migrations/0001_initial.py index 762950d..94b1e1f 100644 --- a/solution/apps/user/migrations/0001_initial.py +++ b/solution/apps/user/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.5 on 2025-01-21 11:05 +# Generated by Django 5.1.5 on 2025-01-23 17:54 import django.core.validators import django_countries.fields @@ -24,6 +24,7 @@ class Migration(migrations.Migration): ('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)), + ('country_raw', models.CharField(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)), ], diff --git a/solution/apps/user/models.py b/solution/apps/user/models.py index aa307d9..812a1ac 100644 --- a/solution/apps/user/models.py +++ b/solution/apps/user/models.py @@ -2,6 +2,7 @@ from datetime import timedelta import jwt from django.conf import settings +from django.core.exceptions import ValidationError from django.core.validators import ( MaxValueValidator, MinLengthValidator, @@ -28,6 +29,7 @@ class User(BaseModel): avatar_url = models.URLField(max_length=350, blank=True, null=True) age = models.PositiveSmallIntegerField(validators=[MaxValueValidator(100)]) country = CountryField(max_length=2) + country_raw = models.CharField(max_length=2) password = models.CharField( max_length=60, validators=[ @@ -41,6 +43,15 @@ class User(BaseModel): def __str__(self) -> str: return f"{self.surname} {self.name}" + def clean(self) -> None: + super().clean() + + if self.avatar_url == "": + err = { + "avatar_url": "Field cannot be blank.", + } + raise ValidationError(err) + def generate_token(self) -> str: return jwt.encode( { diff --git a/solution/config/errors.py b/solution/config/errors.py index f87d57e..784ea17 100644 --- a/solution/config/errors.py +++ b/solution/config/errors.py @@ -4,6 +4,3 @@ from django.core.exceptions import ValidationError class UniqueConstraintError(Exception): def __init__(self, validation_error: ValidationError) -> None: self.validation_error = validation_error - - -__all__ = ["UniqueConstraintError"] diff --git a/solution/config/integrations/antifraud/healthcheck.py b/solution/config/integrations/antifraud/healthcheck.py index a2291e7..ff206b0 100644 --- a/solution/config/integrations/antifraud/healthcheck.py +++ b/solution/config/integrations/antifraud/healthcheck.py @@ -18,6 +18,3 @@ class AntifraudHealthCheck(BaseHealthCheckBackend): def identifier(self) -> str: return self.__class__.__name__ - - -__all__ = ["AntifraudHealthCheck"] diff --git a/solution/pyproject.toml b/solution/pyproject.toml index 724b549..2380afc 100644 --- a/solution/pyproject.toml +++ b/solution/pyproject.toml @@ -15,6 +15,8 @@ dependencies = [ "gunicorn>=23.0.0", "httpx>=0.28.1", "psycopg2-binary>=2.9.10", + "pycountry>=24.6.1", + "pydantic-extra-types>=2.10.2", "pydantic>=2.10.5", "pydantic[email]>=2.10.5", "pyjwt>=2.10.1",