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
import apps.business.models
import apps.user.models
class BusinessToken(BaseModel):
@@ -28,7 +29,7 @@ class BusinessAuth(HttpBearer):
try:
business = apps.business.models.Business.objects.get(
id=token_payload.business_id
id=token_payload.business_id,
)
except apps.business.models.Business.DoesNotExist:
return None
@@ -37,3 +38,31 @@ class BusinessAuth(HttpBearer):
return None
return business
class UserToken(BaseModel):
user_id: uuid.UUID
token_version: int
class UserAuth(HttpBearer):
def authenticate(self, request: HttpRequest, token: str) -> str | None:
try:
decoded_payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=["HS256"],
)
token_payload = UserToken(**decoded_payload)
except (jwt.PyJWTError, ValidationError):
return None
try:
user = apps.user.models.User.objects.get(id=token_payload.user_id)
except apps.user.models.User.DoesNotExist:
return None
if user.token_version != token_payload.token_version:
return None
return user
+78 -20
View File
@@ -1,20 +1,20 @@
import re
import datetime
import uuid
from typing import ClassVar
from typing import ClassVar, Literal
from ninja import ModelSchema, Schema
from pydantic import EmailStr, field_validator
from pydantic import Field
from apps.business.models import Business
from apps.promo.models import Promocode, PromocodeTarget
class BusinessSignUpIn(ModelSchema):
email: EmailStr
class Meta:
model = Business
fields: ClassVar[list[str]] = [
Business.name.field.name,
Business.email.field.name,
Business.password.field.name,
]
@@ -24,27 +24,85 @@ class BusinessSignUpOut(Schema):
company_id: uuid.UUID
class BusinessSignInIn(Schema):
email: EmailStr
password: str
@field_validator("password")
def validate_password(cls, value: str) -> str: # noqa: N805
pattern = r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,60}$" # noqa: E501
if not re.match(pattern, value):
e = (
"Password must contain at least 8 characters, one uppercase "
"letter, one lowercase letter, one number, and one special "
"character (@$!%*?&)."
)
raise ValueError(e)
return value
class BusinessSignInIn(ModelSchema):
class Meta:
model = Business
fields: ClassVar[list[str]] = [
Business.email.field.name,
Business.password.field.name,
]
class BusinessSignInOut(Schema):
token: str
class PromocodeTarget(ModelSchema):
categories: list[str] | None = None
class Meta:
model = PromocodeTarget
fields: ClassVar[list[str]] = [
PromocodeTarget.age_from.field.name,
PromocodeTarget.age_until.field.name,
PromocodeTarget.country.field.name,
PromocodeTarget.categories.field.name,
]
class CreatePromocodeIn(ModelSchema):
target: PromocodeTarget
promo_unique: list[str] | None = None
class Meta:
model = Promocode
fields: ClassVar[list[str]] = [
Promocode.description.field.name,
Promocode.image_url.field.name,
Promocode.max_count.field.name,
Promocode.active_from.field.name,
Promocode.active_until.field.name,
Promocode.mode.field.name,
Promocode.promo_common.field.name,
]
class CreatePromocodeOut(Schema):
id: uuid.UUID
class PromocodeListFilters(Schema):
limit: int = 10
offset: int = 0
query: Literal["active_from", "active_until", None] = None
country__in: list[str] = Field(None, alias="country")
class PromocodeTargetViewOut(Schema):
age_from: int | None
age_until: int | None
country: str | None
categories: list[str] | None
class PromocodeViewOut(Schema):
promo_id: uuid.UUID
company_id: uuid.UUID
description: str
image_url: str | None
target: PromocodeTargetViewOut
max_count: int
active_from: datetime.date | None
active_until: datetime.date | None
mode: str
promo_common: str | None
promo_unique: list[str] | None
company_name: str
like_count: int
used_count: int
active: bool
__all__ = [
"BusinessSignInIn",
"BusinessSignInOut",
+185 -7
View File
@@ -1,12 +1,18 @@
import datetime
from http import HTTPStatus as status
from django.http import HttpRequest
from ninja import Router
from ninja.errors import AuthenticationError
from django.core.exceptions import ValidationError
from django.db.models import Count, Q, Value
from django.db.models.functions import Coalesce
from django.http import HttpRequest, HttpResponse
from ninja import Query, Router
from ninja.errors import AuthenticationError, HttpError
from api.v1 import schemas as global_schemas
from api.v1.auth import BusinessAuth
from api.v1.business import schemas
from apps.business.models import Business
from apps.promo.models import Promocode, PromocodeTarget
router = Router(tags=["business"])
@@ -20,13 +26,15 @@ router = Router(tags=["business"])
},
)
def signup(
request: HttpRequest, business: schemas.BusinessSignUpIn
request: HttpRequest,
business: schemas.BusinessSignUpIn,
) -> tuple[int, schemas.BusinessSignUpOut]:
business_obj = Business(**business.dict())
business_obj.save()
return status.OK, schemas.BusinessSignUpOut(
token=business_obj.generate_token(), company_id=business_obj.id
token=business_obj.generate_token(),
company_id=business_obj.id,
)
@@ -39,8 +47,16 @@ def signup(
},
)
def signin(
request: HttpRequest, login_data: schemas.BusinessSignInIn
request: HttpRequest,
login_data: schemas.BusinessSignInIn,
) -> tuple[int, schemas.BusinessSignInOut]:
business_obj = Business(**dict(login_data))
business_obj.validate(
include=[Business.email.field, Business.password.field],
validate_unique=False,
validate_constraints=False,
)
try:
business_obj = Business.objects.get(email=login_data.email)
except Business.DoesNotExist:
@@ -53,5 +69,167 @@ def signin(
business_obj.save()
return status.OK, schemas.BusinessSignInOut(
token=business_obj.generate_token()
token=business_obj.generate_token(),
)
@router.post(
"/promo",
auth=BusinessAuth(),
response={
status.CREATED: schemas.CreatePromocodeOut,
status.BAD_REQUEST: global_schemas.ValidationError,
status.UNAUTHORIZED: global_schemas.UnauthorizedError,
},
)
def create_promocode(
request: HttpRequest,
promocode: schemas.CreatePromocodeIn,
) -> schemas.CreatePromocodeOut:
business = request.auth
promocode = dict(promocode)
target = dict(promocode.pop("target"))
target_obj = PromocodeTarget(**target)
target_obj.save()
promocode_obj = Promocode(
business=business,
target=target_obj,
**promocode,
)
try:
promocode_obj.save()
except ValidationError as e:
target_obj.delete()
raise e # noqa: TRY201
return status.CREATED, schemas.CreatePromocodeOut(id=promocode_obj.id)
@router.get(
"/promo",
auth=BusinessAuth(),
response={
status.OK: list[schemas.PromocodeViewOut],
status.BAD_REQUEST: global_schemas.ValidationError,
},
exclude_none=True,
)
def list_promocode(
request: HttpRequest,
filters: Query[schemas.PromocodeListFilters],
response: HttpResponse,
) -> list[schemas.PromocodeViewOut]:
business = request.auth
promocodes = Promocode.objects.filter(business=business)
if filters.country__in:
promocodes = promocodes.filter(
Q(target__country__in=filters.country__in)
| Q(target__country__isnull=True)
)
if filters.query == "active_from":
promocodes = promocodes.annotate(
active_from_sort=Coalesce("active_from", Value(datetime.min))
).order_by("-active_from_sort")
elif filters.query == "active_until":
promocodes = promocodes.annotate(
active_until_sort=Coalesce("active_until", Value(datetime.max))
).order_by("-active_until_sort")
else:
promocodes = promocodes.order_by("-created_at")
promocodes = promocodes.annotate(
used_count=Count("activations"),
like_count=Count("comments"),
)
response["X-Total-Count"] = promocodes.count()
promocodes = promocodes[filters.offset : filters.offset + filters.limit]
return [
schemas.PromocodeViewOut(
promo_id=promocode.id,
company_id=promocode.business.id,
company_name=promocode.business.name,
description=promocode.description,
image_url=promocode.image_url,
target=schemas.PromocodeTargetViewOut(
age_from=promocode.target.age_from,
age_until=promocode.target.age_until,
country=promocode.target.country.code
if promocode.target.country
else None,
categories=promocode.target.categories,
),
max_count=promocode.max_count,
active_from=promocode.active_from,
active_until=promocode.active_until,
mode=promocode.mode,
promo_common=promocode.promo_common,
promo_unique=promocode.promo_unique,
like_count=promocode.like_count,
used_count=promocode.used_count,
active=promocode.active,
)
for promocode in promocodes
]
@router.get(
"/promo/{promocode_id}",
auth=BusinessAuth(),
response={
status.OK: schemas.PromocodeViewOut,
status.NOT_FOUND: global_schemas.NotFoundError,
},
exclude_none=True,
)
def product_get(
request: HttpRequest, promocode_id: str
) -> schemas.PromocodeViewOut:
business = request.auth
promocodes = Promocode.objects.filter(id=promocode_id)
if len(promocodes) == 0:
raise HttpError(status.NOT_FOUND, status.NOT_FOUND.phrase)
promocodes = promocodes.annotate(
used_count=Count("activations"),
like_count=Count("comments"),
)
promocode = promocodes[0]
if promocode.business != business:
raise HttpError(status.FORBIDDEN, status.FORBIDDEN.phrase)
return schemas.PromocodeViewOut(
promo_id=promocode.id,
company_id=promocode.business.id,
company_name=promocode.business.name,
description=promocode.description,
image_url=promocode.image_url,
target=schemas.PromocodeTargetViewOut(
age_from=promocode.target.age_from,
age_until=promocode.target.age_until,
country=promocode.target.country.code
if promocode.target.country
else None,
categories=promocode.target.categories,
),
max_count=promocode.max_count,
active_from=promocode.active_from,
active_until=promocode.active_until,
mode=promocode.mode,
promo_common=promocode.promo_common,
promo_unique=promocode.promo_unique,
like_count=promocode.like_count,
used_count=promocode.used_count,
active=promocode.active,
)
+12 -4
View File
@@ -46,7 +46,9 @@ def handle_django_validation_error(
def handle_authentication_error(
request: HttpRequest, exc: errors.AuthenticationError, router: NinjaAPI
request: HttpRequest,
exc: errors.AuthenticationError,
router: NinjaAPI,
) -> HttpResponse:
return router.create_response(
request,
@@ -56,7 +58,9 @@ def handle_authentication_error(
def handle_validation_error(
request: HttpRequest, exc: errors.ValidationError, router: NinjaAPI
request: HttpRequest,
exc: errors.ValidationError,
router: NinjaAPI,
) -> HttpResponse:
return router.create_response(
request,
@@ -66,7 +70,9 @@ def handle_validation_error(
def handle_not_found_error(
request: HttpRequest, exc: Exception, router: NinjaAPI
request: HttpRequest,
exc: Exception,
router: NinjaAPI,
) -> HttpResponse:
return router.create_response(
request,
@@ -76,7 +82,9 @@ def handle_not_found_error(
def handle_unknown_exception(
request: HttpRequest, exc: Exception, router: NinjaAPI
request: HttpRequest,
exc: Exception,
router: NinjaAPI,
) -> HttpResponse:
logger.exception(exc)
+5 -1
View File
@@ -1,7 +1,10 @@
from datetime import timedelta
import jwt
from django.conf import settings
from django.core.validators import MinLengthValidator, RegexValidator
from django.db import models
from django.utils import timezone
from apps.core.models import BaseModel
@@ -17,7 +20,7 @@ class Business(BaseModel):
max_length=60,
validators=[
RegexValidator(
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$"
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$",
),
],
)
@@ -31,6 +34,7 @@ class Business(BaseModel):
{
"business_id": str(self.id),
"token_version": self.token_version,
"exp": timezone.now() + timedelta(hours=24),
},
settings.SECRET_KEY,
algorithm="HS256",
+31 -7
View File
@@ -1,4 +1,5 @@
import uuid
from typing import Any
from django.core.exceptions import ValidationError
from django.db import models
@@ -12,12 +13,35 @@ class BaseModel(models.Model):
class Meta:
abstract = True
def save(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003
self.full_clean(validate_unique=False)
try:
self.validate_unique()
except ValidationError as e:
raise UniqueConstraintError(e) from None
def save(self, *args: Any, **kwargs: Any) -> None:
self.validate()
super().save(*args, **kwargs)
def validate(
self,
validate_unique: bool = True,
validate_constraints: bool = True,
include: list[models.Field] | None = None,
) -> None:
self.full_clean(
validate_unique=False,
validate_constraints=False,
exclude=(
field.name
for field in set(self._meta.get_fields()) - set(include)
)
if include
else None,
)
if validate_unique:
try:
self.validate_unique()
except ValidationError as e:
raise UniqueConstraintError(e) from None
if validate_constraints:
try:
self.validate_constraints()
except ValidationError as e:
raise UniqueConstraintError(e) from None
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(
request: HttpRequest, exception: Exception | None = None
request: HttpRequest,
exception: Exception | None = None,
) -> JsonResponse:
return JsonResponse(
status=status.BAD_REQUEST,
@@ -13,7 +14,8 @@ def handler400(
def handler403(
request: HttpRequest, exception: Exception | None = None
request: HttpRequest,
exception: Exception | None = None,
) -> JsonResponse:
return JsonResponse(
status=status.FORBIDDEN,
@@ -22,7 +24,8 @@ def handler403(
def handler404(
request: HttpRequest, exception: Exception | None = None
request: HttpRequest,
exception: Exception | None = None,
) -> JsonResponse:
return JsonResponse(
status=status.NOT_FOUND,
@@ -31,7 +34,8 @@ def handler404(
def handler500(
request: HttpRequest, exception: Exception | None = None
request: HttpRequest,
exception: Exception | None = None,
) -> JsonResponse:
return JsonResponse(
status=status.INTERNAL_SERVER_ERROR,
+7 -3
View File
@@ -69,7 +69,9 @@ class LoggingHandler(logging.Handler):
for attempt in range(1, self.retries + 1):
response = httpx.post(
self.api_url, data=payload, timeout=self.timeout
self.api_url,
data=payload,
timeout=self.timeout,
)
if response.status_code != httpx.codes.OK:
if attempt == self.retries:
@@ -86,7 +88,8 @@ class LoggingHandler(logging.Handler):
def format(self, record: logging.LogRecord) -> str:
try:
asctime = datetime.datetime.fromtimestamp(
record.created, tz=get_current_timezone()
record.created,
tz=get_current_timezone(),
).strftime("%Y-%m-%d %H:%M:%S %Z")
level_emoji = LEVEL_EMOJIS.get(record.levelname, "")
@@ -111,7 +114,8 @@ class LoggingHandler(logging.Handler):
formatted_message += f" #{record.correlation_id}"
except Exception as format_error: # noqa: BLE001
TELEGRAM_LOG_HANDLER.exception(
"Error formatting log record: %s", format_error
"Error formatting log record: %s",
format_error,
)
return f"Error formatting log record: {format_error}"
else:
+14 -9
View File
@@ -52,7 +52,7 @@ CACHES = {
"LOCATION": REDIS_URI,
"TIMEOUT": None,
"KEY_PREFIX": "django",
}
},
}
@@ -72,22 +72,22 @@ AUTH_PASSWORD_VALIDATORS = [
"NAME": (
"django.contrib.auth."
"password_validation.UserAttributeSimilarityValidator"
)
),
},
{
"NAME": (
"django.contrib.auth.password_validation.MinimumLengthValidator"
)
),
},
{
"NAME": (
"django.contrib.auth.password_validation.CommonPasswordValidator"
)
),
},
{
"NAME": (
"django.contrib.auth.password_validation.NumericPasswordValidator"
)
),
},
]
@@ -244,15 +244,18 @@ WSGI_APPLICATION = "config.wsgi.application"
# Telegram
NOTIFIER_TELEGRAM_BOT_TOKEN = env(
"DJANGO_NOTIFIER_TELEGRAM_BOT_TOKEN", default=None
"DJANGO_NOTIFIER_TELEGRAM_BOT_TOKEN",
default=None,
)
NOTIFIER_TELEGRAM_CHAT_ID = env(
"DJANGO_NOTIFIER_TELEGRAM_CHAT_ID", default=None
"DJANGO_NOTIFIER_TELEGRAM_CHAT_ID",
default=None,
)
NOTIFIER_TELEGRAM_THREAD_ID = env(
"DJANGO_NOTIFIER_TELEGRAM_THREAD_ID", default=None
"DJANGO_NOTIFIER_TELEGRAM_THREAD_ID",
default=None,
)
@@ -416,6 +419,8 @@ INSTALLED_APPS = [
# Internal apps
"apps.core",
"apps.business",
"apps.user",
"apps.promo",
# API v1 apps
"api.v1.ping",
"api.v1.business",
@@ -531,7 +536,7 @@ TEMPLATES = [
"string_if_invalid": "",
"file_charset": "utf-8",
},
}
},
]