feat: added patch promocode, promocode stat, user signup/signin, user profile get/patch, user feed

also bug fixes and improvements
This commit is contained in:
ITQ
2025-01-23 21:40:34 +03:00
parent 2c10be8cf2
commit b7d7334fe5
21 changed files with 565 additions and 98 deletions
+25 -11
View File
@@ -4,6 +4,7 @@ from typing import ClassVar, Literal
from ninja import ModelSchema, Schema from ninja import ModelSchema, Schema
from pydantic import Field from pydantic import Field
from pydantic_extra_types.country import CountryAlpha2
from apps.business.models import Business from apps.business.models import Business
from apps.promo.models import Promocode, PromocodeTarget from apps.promo.models import Promocode, PromocodeTarget
@@ -39,13 +40,13 @@ class BusinessSignInOut(Schema):
class PromocodeTarget(ModelSchema): class PromocodeTarget(ModelSchema):
categories: list[str] | None = None categories: list[str] | None = None
country: str | None = None
class Meta: class Meta:
model = PromocodeTarget model = PromocodeTarget
fields: ClassVar[list[str]] = [ fields: ClassVar[list[str]] = [
PromocodeTarget.age_from.field.name, PromocodeTarget.age_from.field.name,
PromocodeTarget.age_until.field.name, PromocodeTarget.age_until.field.name,
PromocodeTarget.country.field.name,
PromocodeTarget.categories.field.name, PromocodeTarget.categories.field.name,
] ]
@@ -72,10 +73,12 @@ class CreatePromocodeOut(Schema):
class PromocodeListFilters(Schema): class PromocodeListFilters(Schema):
limit: int = 10 limit: int = Field(10, gt=0, description="Limit must be greater than 0")
offset: int = 0 offset: int = Field(
query: Literal["active_from", "active_until", None] = None 0, ge=0, description="Offset must be greater than or equal to 0"
country__in: list[str] = Field(None, alias="country") )
sort_by: Literal["active_from", "active_until", None] = None
country__in: list[CountryAlpha2] = Field(None, alias="country")
class PromocodeTargetViewOut(Schema): class PromocodeTargetViewOut(Schema):
@@ -103,9 +106,20 @@ class PromocodeViewOut(Schema):
active: bool active: bool
__all__ = [ class PatchPromocodeIn(Schema):
"BusinessSignInIn", description: str | None = None
"BusinessSignInOut", image_url: str | None = None
"BusinessSignUpIn", target: PromocodeTarget | None = None
"BusinessSignUpOut", 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
+29
View File
@@ -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,
)
+126 -63
View File
@@ -1,4 +1,5 @@
import datetime import datetime
from collections import Counter
from http import HTTPStatus as status from http import HTTPStatus as status
from django.core.exceptions import ValidationError 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 import schemas as global_schemas
from api.v1.auth import BusinessAuth 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.business.models import Business
from apps.promo.models import Promocode, PromocodeTarget from apps.promo.models import Promocode, PromocodeTarget
@@ -90,7 +91,7 @@ def create_promocode(
promocode = dict(promocode) promocode = dict(promocode)
target = dict(promocode.pop("target")) target = dict(promocode.pop("target"))
target_obj = PromocodeTarget(**target) target_obj = PromocodeTarget(**target, country_raw=target["country"])
target_obj.save() target_obj.save()
promocode_obj = Promocode( promocode_obj = Promocode(
@@ -123,7 +124,9 @@ def list_promocode(
) -> list[schemas.PromocodeViewOut]: ) -> list[schemas.PromocodeViewOut]:
business = request.auth business = request.auth
promocodes = Promocode.objects.filter(business=business) promocodes = Promocode.objects.filter(business=business).select_related(
"target", "business"
)
if filters.country__in: if filters.country__in:
promocodes = promocodes.filter( promocodes = promocodes.filter(
@@ -131,52 +134,31 @@ def list_promocode(
| Q(target__country__isnull=True) | 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( 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") ).order_by("-active_from_sort")
elif filters.query == "active_until": elif filters.sort_by == "active_until":
promocodes = promocodes.annotate( 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") ).order_by("-active_until_sort")
else: else:
promocodes = promocodes.order_by("-created_at") promocodes = promocodes.order_by("-created_at")
promocodes = promocodes.annotate( promocodes = promocodes.annotate(
used_count=Count("activations"), 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] promocodes = promocodes[filters.offset : filters.offset + filters.limit]
return [ return [
schemas.PromocodeViewOut( utils.map_promocode_to_schema(promocode) for promocode in promocodes
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
] ]
@@ -189,47 +171,128 @@ def list_promocode(
}, },
exclude_none=True, exclude_none=True,
) )
def product_get( def get_promocode(
request: HttpRequest, promocode_id: str request: HttpRequest, promocode_id: str
) -> schemas.PromocodeViewOut: ) -> schemas.PromocodeViewOut:
business = request.auth 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) raise HttpError(status.NOT_FOUND, status.NOT_FOUND.phrase)
promocodes = promocodes.annotate( promocodes = promocodes.annotate(
used_count=Count("activations"), used_count=Count("activations"),
like_count=Count("comments"), like_count=Count("likes"),
) )
promocode = promocodes[0] promocode = promocodes.first()
if promocode.business != business: if promocode.business != business:
raise HttpError(status.FORBIDDEN, status.FORBIDDEN.phrase) raise HttpError(status.FORBIDDEN, status.FORBIDDEN.phrase)
return schemas.PromocodeViewOut( return utils.map_promocode_to_schema(promocode)
promo_id=promocode.id,
company_id=promocode.business.id,
company_name=promocode.business.name, @router.patch(
description=promocode.description, "/promo/{promocode_id}",
image_url=promocode.image_url, auth=BusinessAuth(),
target=schemas.PromocodeTargetViewOut( response={
age_from=promocode.target.age_from, status.OK: schemas.PromocodeViewOut,
age_until=promocode.target.age_until, status.NOT_FOUND: global_schemas.NotFoundError,
country=promocode.target.country.code },
if promocode.target.country exclude_none=True,
else None, )
categories=promocode.target.categories, def patch_promocode(
), request: HttpRequest,
max_count=promocode.max_count, promocode_id: str,
active_from=promocode.active_from, patched_fields: schemas.PatchPromocodeIn,
active_until=promocode.active_until, ) -> schemas.PromocodeViewOut:
mode=promocode.mode, business = request.auth
promo_common=promocode.promo_common,
promo_unique=promocode.promo_unique, promocodes = Promocode.objects.filter(id=promocode_id).select_related(
like_count=promocode.like_count, "target", "business"
used_count=promocode.used_count, )
active=promocode.active,
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,
) )
-3
View File
@@ -3,6 +3,3 @@ from ninja import Schema
class PingOut(Schema): class PingOut(Schema):
message_from_basement: str message_from_basement: str
__all__ = ["PingOut"]
+5
View File
@@ -5,6 +5,7 @@ from ninja import NinjaAPI
from api.v1 import handlers from api.v1 import handlers
from api.v1.business.views import router as business_router from api.v1.business.views import router as business_router
from api.v1.ping.views import router as ping_router from api.v1.ping.views import router as ping_router
from api.v1.user.views import router as user_router
router = NinjaAPI( router = NinjaAPI(
title="Promocode API", title="Promocode API",
@@ -25,6 +26,10 @@ router.add_router(
"business", "business",
business_router, business_router,
) )
router.add_router(
"user",
user_router,
)
# Register exception handlers # Register exception handlers
-8
View File
@@ -17,11 +17,3 @@ class ValidationError(Schema):
class UniqueConstraintError(Schema): class UniqueConstraintError(Schema):
detail: str detail: str
__all__ = [
"NotFoundError",
"UnauthorizedError",
"UniqueConstraintError",
"ValidationError",
]
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class PingConfig(AppConfig):
name = "api.v1.user"
label = "api_v1_user"
+89
View File
@@ -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
+31
View File
@@ -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,
)
+171
View File
@@ -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
]
+1
View File
@@ -34,6 +34,7 @@ class BaseModel(models.Model):
if include if include
else None, else None,
) )
if validate_unique: if validate_unique:
try: try:
self.validate_unique() self.validate_unique()
+10 -1
View File
@@ -1,6 +1,15 @@
from django.contrib import admin 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(Promocode)
admin.site.register(PromocodeTarget) admin.site.register(PromocodeTarget)
admin.site.register(PromocodeActivation)
admin.site.register(PromocodeComment)
admin.site.register(PromocodeLike)
+14 -2
View File
@@ -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 apps.promo.validators
import django.core.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_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)])), ('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', 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()])), ('categories', models.JSONField(blank=True, default=list, null=True, validators=[apps.promo.validators.TargetCategoriesValidator()])),
], ],
options={ options={
@@ -43,7 +44,7 @@ class Migration(migrations.Migration):
('mode', models.CharField(choices=[('COMMON', 'Common'), ('UNIQUE', 'Unique')], max_length=6)), ('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_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', 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)), ('created_at', models.DateTimeField(auto_now_add=True)),
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='promocodes', to='business.business')), ('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')), ('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, '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,
},
),
] ]
+42 -2
View File
@@ -31,6 +31,7 @@ class PromocodeTarget(BaseModel):
validators=[MinValueValidator(0), MaxValueValidator(100)], validators=[MinValueValidator(0), MaxValueValidator(100)],
) )
country = CountryField(blank=True, null=True) country = CountryField(blank=True, null=True)
country_raw = models.CharField(max_length=2, blank=True, null=True)
categories = models.JSONField( categories = models.JSONField(
blank=True, blank=True,
null=True, null=True,
@@ -61,7 +62,11 @@ class Promocode(BaseModel):
max_length=300, max_length=300,
validators=[MinLengthValidator(10)], 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( target = models.ForeignKey(
PromocodeTarget, PromocodeTarget,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@@ -89,13 +94,21 @@ class Promocode(BaseModel):
blank=True, blank=True,
null=True, null=True,
default=list, default=list,
editable=False,
) )
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
def __str__(self) -> str:
return str(self.id)
def clean(self) -> None: def clean(self) -> None:
super().clean() super().clean()
if self.image_url == "":
err = {
"image_url": "Field cannot be blank.",
}
raise ValidationError(err)
if self.mode == self.ModeChoices.COMMON: if self.mode == self.ModeChoices.COMMON:
if not self.promo_common: if not self.promo_common:
err = { err = {
@@ -107,6 +120,11 @@ class Promocode(BaseModel):
"promo_unique": "Field must be empty for COMMON mode.", "promo_unique": "Field must be empty for COMMON mode.",
} }
raise ValidationError(err) 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: elif self.mode == self.ModeChoices.UNIQUE:
if not self.promo_unique: if not self.promo_unique:
err = { err = {
@@ -118,6 +136,11 @@ class Promocode(BaseModel):
"promo_common": "Field must be empty for UNIQUE mode.", "promo_common": "Field must be empty for UNIQUE mode.",
} }
raise ValidationError(err) raise ValidationError(err)
if self.max_count != 1:
err = {
"max_count": "Field must be 1 for UNIQUE mode.",
}
raise ValidationError(err)
PromocodeDurationValidator()(self) PromocodeDurationValidator()(self)
@@ -134,6 +157,8 @@ class Promocode(BaseModel):
elif self.mode == self.ModeChoices.UNIQUE: elif self.mode == self.ModeChoices.UNIQUE:
is_active_by_mode = len(self.promo_unique) > len( is_active_by_mode = len(self.promo_unique) > len(
self.promo_unique_activated self.promo_unique_activated
if self.promo_unique_activated
else []
) )
return is_active_by_date and is_active_by_mode return is_active_by_date and is_active_by_mode
@@ -152,6 +177,9 @@ class PromocodeActivation(BaseModel):
) )
timestamp = models.DateTimeField(auto_now_add=True) timestamp = models.DateTimeField(auto_now_add=True)
def __str__(self) -> str:
return f"{self.promocode.id} | {self.user.id}"
class PromocodeComment(BaseModel): class PromocodeComment(BaseModel):
promocode = models.ForeignKey( promocode = models.ForeignKey(
@@ -169,3 +197,15 @@ class PromocodeComment(BaseModel):
validators=[MinLengthValidator(10)], validators=[MinLengthValidator(10)],
) )
date = models.DateTimeField(auto_now_add=True) 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"
)
+1 -1
View File
@@ -61,7 +61,7 @@ class PromocodeUniqueValidator(BaseValidator):
def __call__(self, promocodes: list) -> None: def __call__(self, promocodes: list) -> None:
if not isinstance(promocodes, list): if not isinstance(promocodes, list):
err = "unque promocodes must be a list" err = "unique promocodes must be a list"
raise ValidationError(err) raise ValidationError(err)
if not ( if not (
@@ -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.core.validators
import django_countries.fields import django_countries.fields
@@ -24,6 +24,7 @@ class Migration(migrations.Migration):
('avatar_url', models.URLField(blank=True, max_length=350, null=True)), ('avatar_url', models.URLField(blank=True, max_length=350, null=True)),
('age', models.PositiveSmallIntegerField(validators=[django.core.validators.MaxValueValidator(100)])), ('age', models.PositiveSmallIntegerField(validators=[django.core.validators.MaxValueValidator(100)])),
('country', django_countries.fields.CountryField(max_length=2)), ('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,}$')])), ('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)), ('token_version', models.BigIntegerField(default=0)),
], ],
+11
View File
@@ -2,6 +2,7 @@ from datetime import timedelta
import jwt import jwt
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import ( from django.core.validators import (
MaxValueValidator, MaxValueValidator,
MinLengthValidator, MinLengthValidator,
@@ -28,6 +29,7 @@ class User(BaseModel):
avatar_url = models.URLField(max_length=350, blank=True, null=True) avatar_url = models.URLField(max_length=350, blank=True, null=True)
age = models.PositiveSmallIntegerField(validators=[MaxValueValidator(100)]) age = models.PositiveSmallIntegerField(validators=[MaxValueValidator(100)])
country = CountryField(max_length=2) country = CountryField(max_length=2)
country_raw = models.CharField(max_length=2)
password = models.CharField( password = models.CharField(
max_length=60, max_length=60,
validators=[ validators=[
@@ -41,6 +43,15 @@ class User(BaseModel):
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.surname} {self.name}" 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: def generate_token(self) -> str:
return jwt.encode( return jwt.encode(
{ {
-3
View File
@@ -4,6 +4,3 @@ from django.core.exceptions import ValidationError
class UniqueConstraintError(Exception): class UniqueConstraintError(Exception):
def __init__(self, validation_error: ValidationError) -> None: def __init__(self, validation_error: ValidationError) -> None:
self.validation_error = validation_error self.validation_error = validation_error
__all__ = ["UniqueConstraintError"]
@@ -18,6 +18,3 @@ class AntifraudHealthCheck(BaseHealthCheckBackend):
def identifier(self) -> str: def identifier(self) -> str:
return self.__class__.__name__ return self.__class__.__name__
__all__ = ["AntifraudHealthCheck"]
+2
View File
@@ -15,6 +15,8 @@ dependencies = [
"gunicorn>=23.0.0", "gunicorn>=23.0.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"psycopg2-binary>=2.9.10", "psycopg2-binary>=2.9.10",
"pycountry>=24.6.1",
"pydantic-extra-types>=2.10.2",
"pydantic>=2.10.5", "pydantic>=2.10.5",
"pydantic[email]>=2.10.5", "pydantic[email]>=2.10.5",
"pyjwt>=2.10.1", "pyjwt>=2.10.1",