feat: added promocode creation and view

This commit is contained in:
ITQ
2025-01-21 18:44:45 +03:00
parent 36275caf40
commit 5ff66261c3
22 changed files with 813 additions and 56 deletions
+30 -1
View File
@@ -7,6 +7,7 @@ from ninja.security import HttpBearer
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
import apps.business.models import apps.business.models
import apps.user.models
class BusinessToken(BaseModel): class BusinessToken(BaseModel):
@@ -28,7 +29,7 @@ class BusinessAuth(HttpBearer):
try: try:
business = apps.business.models.Business.objects.get( business = apps.business.models.Business.objects.get(
id=token_payload.business_id id=token_payload.business_id,
) )
except apps.business.models.Business.DoesNotExist: except apps.business.models.Business.DoesNotExist:
return None return None
@@ -37,3 +38,31 @@ class BusinessAuth(HttpBearer):
return None return None
return business 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
+78 -20
View File
@@ -1,20 +1,20 @@
import re import datetime
import uuid import uuid
from typing import ClassVar from typing import ClassVar, Literal
from ninja import ModelSchema, Schema from ninja import ModelSchema, Schema
from pydantic import EmailStr, field_validator from pydantic import Field
from apps.business.models import Business from apps.business.models import Business
from apps.promo.models import Promocode, PromocodeTarget
class BusinessSignUpIn(ModelSchema): class BusinessSignUpIn(ModelSchema):
email: EmailStr
class Meta: class Meta:
model = Business model = Business
fields: ClassVar[list[str]] = [ fields: ClassVar[list[str]] = [
Business.name.field.name, Business.name.field.name,
Business.email.field.name,
Business.password.field.name, Business.password.field.name,
] ]
@@ -24,27 +24,85 @@ class BusinessSignUpOut(Schema):
company_id: uuid.UUID company_id: uuid.UUID
class BusinessSignInIn(Schema): class BusinessSignInIn(ModelSchema):
email: EmailStr class Meta:
password: str model = Business
fields: ClassVar[list[str]] = [
@field_validator("password") Business.email.field.name,
def validate_password(cls, value: str) -> str: # noqa: N805 Business.password.field.name,
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 BusinessSignInOut(Schema): class BusinessSignInOut(Schema):
token: str 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__ = [ __all__ = [
"BusinessSignInIn", "BusinessSignInIn",
"BusinessSignInOut", "BusinessSignInOut",
+185 -7
View File
@@ -1,12 +1,18 @@
import datetime
from http import HTTPStatus as status from http import HTTPStatus as status
from django.http import HttpRequest from django.core.exceptions import ValidationError
from ninja import Router from django.db.models import Count, Q, Value
from ninja.errors import AuthenticationError 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 import schemas as global_schemas
from api.v1.auth import BusinessAuth
from api.v1.business import schemas from api.v1.business import schemas
from apps.business.models import Business from apps.business.models import Business
from apps.promo.models import Promocode, PromocodeTarget
router = Router(tags=["business"]) router = Router(tags=["business"])
@@ -20,13 +26,15 @@ router = Router(tags=["business"])
}, },
) )
def signup( def signup(
request: HttpRequest, business: schemas.BusinessSignUpIn request: HttpRequest,
business: schemas.BusinessSignUpIn,
) -> tuple[int, schemas.BusinessSignUpOut]: ) -> tuple[int, schemas.BusinessSignUpOut]:
business_obj = Business(**business.dict()) business_obj = Business(**business.dict())
business_obj.save() business_obj.save()
return status.OK, schemas.BusinessSignUpOut( 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( def signin(
request: HttpRequest, login_data: schemas.BusinessSignInIn request: HttpRequest,
login_data: schemas.BusinessSignInIn,
) -> tuple[int, schemas.BusinessSignInOut]: ) -> 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: try:
business_obj = Business.objects.get(email=login_data.email) business_obj = Business.objects.get(email=login_data.email)
except Business.DoesNotExist: except Business.DoesNotExist:
@@ -53,5 +69,167 @@ def signin(
business_obj.save() business_obj.save()
return status.OK, schemas.BusinessSignInOut( 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,
) )
+12 -4
View File
@@ -46,7 +46,9 @@ def handle_django_validation_error(
def handle_authentication_error( def handle_authentication_error(
request: HttpRequest, exc: errors.AuthenticationError, router: NinjaAPI request: HttpRequest,
exc: errors.AuthenticationError,
router: NinjaAPI,
) -> HttpResponse: ) -> HttpResponse:
return router.create_response( return router.create_response(
request, request,
@@ -56,7 +58,9 @@ def handle_authentication_error(
def handle_validation_error( def handle_validation_error(
request: HttpRequest, exc: errors.ValidationError, router: NinjaAPI request: HttpRequest,
exc: errors.ValidationError,
router: NinjaAPI,
) -> HttpResponse: ) -> HttpResponse:
return router.create_response( return router.create_response(
request, request,
@@ -66,7 +70,9 @@ def handle_validation_error(
def handle_not_found_error( def handle_not_found_error(
request: HttpRequest, exc: Exception, router: NinjaAPI request: HttpRequest,
exc: Exception,
router: NinjaAPI,
) -> HttpResponse: ) -> HttpResponse:
return router.create_response( return router.create_response(
request, request,
@@ -76,7 +82,9 @@ def handle_not_found_error(
def handle_unknown_exception( def handle_unknown_exception(
request: HttpRequest, exc: Exception, router: NinjaAPI request: HttpRequest,
exc: Exception,
router: NinjaAPI,
) -> HttpResponse: ) -> HttpResponse:
logger.exception(exc) logger.exception(exc)
+5 -1
View File
@@ -1,7 +1,10 @@
from datetime import timedelta
import jwt import jwt
from django.conf import settings from django.conf import settings
from django.core.validators import MinLengthValidator, RegexValidator from django.core.validators import MinLengthValidator, RegexValidator
from django.db import models from django.db import models
from django.utils import timezone
from apps.core.models import BaseModel from apps.core.models import BaseModel
@@ -17,7 +20,7 @@ class Business(BaseModel):
max_length=60, max_length=60,
validators=[ validators=[
RegexValidator( 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), "business_id": str(self.id),
"token_version": self.token_version, "token_version": self.token_version,
"exp": timezone.now() + timedelta(hours=24),
}, },
settings.SECRET_KEY, settings.SECRET_KEY,
algorithm="HS256", algorithm="HS256",
+31 -7
View File
@@ -1,4 +1,5 @@
import uuid import uuid
from typing import Any
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
@@ -12,12 +13,35 @@ class BaseModel(models.Model):
class Meta: class Meta:
abstract = True abstract = True
def save(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003 def save(self, *args: Any, **kwargs: Any) -> None:
self.full_clean(validate_unique=False) self.validate()
try:
self.validate_unique()
except ValidationError as e:
raise UniqueConstraintError(e) from None
super().save(*args, **kwargs) 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
View File
+6
View File
@@ -0,0 +1,6 @@
from django.contrib import admin
from apps.promo.models import Promocode, PromocodeTarget
admin.site.register(Promocode)
admin.site.register(PromocodeTarget)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class PromoConfig(AppConfig):
name = "apps.promo"
label = "promo"
@@ -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,
},
),
]
+171
View File
@@ -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)
+82
View File
@@ -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)
View File
+5
View File
@@ -0,0 +1,5 @@
from django.contrib import admin
from apps.user.models import User
admin.site.register(User)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class UserConfig(AppConfig):
name = "apps.user"
label = "user"
@@ -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,
},
),
]
+53
View File
@@ -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",
)
+8 -4
View File
@@ -4,7 +4,8 @@ from django.http import HttpRequest, JsonResponse
def handler400( def handler400(
request: HttpRequest, exception: Exception | None = None request: HttpRequest,
exception: Exception | None = None,
) -> JsonResponse: ) -> JsonResponse:
return JsonResponse( return JsonResponse(
status=status.BAD_REQUEST, status=status.BAD_REQUEST,
@@ -13,7 +14,8 @@ def handler400(
def handler403( def handler403(
request: HttpRequest, exception: Exception | None = None request: HttpRequest,
exception: Exception | None = None,
) -> JsonResponse: ) -> JsonResponse:
return JsonResponse( return JsonResponse(
status=status.FORBIDDEN, status=status.FORBIDDEN,
@@ -22,7 +24,8 @@ def handler403(
def handler404( def handler404(
request: HttpRequest, exception: Exception | None = None request: HttpRequest,
exception: Exception | None = None,
) -> JsonResponse: ) -> JsonResponse:
return JsonResponse( return JsonResponse(
status=status.NOT_FOUND, status=status.NOT_FOUND,
@@ -31,7 +34,8 @@ def handler404(
def handler500( def handler500(
request: HttpRequest, exception: Exception | None = None request: HttpRequest,
exception: Exception | None = None,
) -> JsonResponse: ) -> JsonResponse:
return JsonResponse( return JsonResponse(
status=status.INTERNAL_SERVER_ERROR, status=status.INTERNAL_SERVER_ERROR,
+7 -3
View File
@@ -69,7 +69,9 @@ class LoggingHandler(logging.Handler):
for attempt in range(1, self.retries + 1): for attempt in range(1, self.retries + 1):
response = httpx.post( 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 response.status_code != httpx.codes.OK:
if attempt == self.retries: if attempt == self.retries:
@@ -86,7 +88,8 @@ class LoggingHandler(logging.Handler):
def format(self, record: logging.LogRecord) -> str: def format(self, record: logging.LogRecord) -> str:
try: try:
asctime = datetime.datetime.fromtimestamp( asctime = datetime.datetime.fromtimestamp(
record.created, tz=get_current_timezone() record.created,
tz=get_current_timezone(),
).strftime("%Y-%m-%d %H:%M:%S %Z") ).strftime("%Y-%m-%d %H:%M:%S %Z")
level_emoji = LEVEL_EMOJIS.get(record.levelname, "") level_emoji = LEVEL_EMOJIS.get(record.levelname, "")
@@ -111,7 +114,8 @@ class LoggingHandler(logging.Handler):
formatted_message += f" #{record.correlation_id}" formatted_message += f" #{record.correlation_id}"
except Exception as format_error: # noqa: BLE001 except Exception as format_error: # noqa: BLE001
TELEGRAM_LOG_HANDLER.exception( 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}" return f"Error formatting log record: {format_error}"
else: else:
+14 -9
View File
@@ -52,7 +52,7 @@ CACHES = {
"LOCATION": REDIS_URI, "LOCATION": REDIS_URI,
"TIMEOUT": None, "TIMEOUT": None,
"KEY_PREFIX": "django", "KEY_PREFIX": "django",
} },
} }
@@ -72,22 +72,22 @@ AUTH_PASSWORD_VALIDATORS = [
"NAME": ( "NAME": (
"django.contrib.auth." "django.contrib.auth."
"password_validation.UserAttributeSimilarityValidator" "password_validation.UserAttributeSimilarityValidator"
) ),
}, },
{ {
"NAME": ( "NAME": (
"django.contrib.auth.password_validation.MinimumLengthValidator" "django.contrib.auth.password_validation.MinimumLengthValidator"
) ),
}, },
{ {
"NAME": ( "NAME": (
"django.contrib.auth.password_validation.CommonPasswordValidator" "django.contrib.auth.password_validation.CommonPasswordValidator"
) ),
}, },
{ {
"NAME": ( "NAME": (
"django.contrib.auth.password_validation.NumericPasswordValidator" "django.contrib.auth.password_validation.NumericPasswordValidator"
) ),
}, },
] ]
@@ -244,15 +244,18 @@ WSGI_APPLICATION = "config.wsgi.application"
# Telegram # Telegram
NOTIFIER_TELEGRAM_BOT_TOKEN = env( NOTIFIER_TELEGRAM_BOT_TOKEN = env(
"DJANGO_NOTIFIER_TELEGRAM_BOT_TOKEN", default=None "DJANGO_NOTIFIER_TELEGRAM_BOT_TOKEN",
default=None,
) )
NOTIFIER_TELEGRAM_CHAT_ID = env( NOTIFIER_TELEGRAM_CHAT_ID = env(
"DJANGO_NOTIFIER_TELEGRAM_CHAT_ID", default=None "DJANGO_NOTIFIER_TELEGRAM_CHAT_ID",
default=None,
) )
NOTIFIER_TELEGRAM_THREAD_ID = env( 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 # Internal apps
"apps.core", "apps.core",
"apps.business", "apps.business",
"apps.user",
"apps.promo",
# API v1 apps # API v1 apps
"api.v1.ping", "api.v1.ping",
"api.v1.business", "api.v1.business",
@@ -531,7 +536,7 @@ TEMPLATES = [
"string_if_invalid": "", "string_if_invalid": "",
"file_charset": "utf-8", "file_charset": "utf-8",
}, },
} },
] ]