You've already forked Promocode-API
mirror of
https://github.com/devitq/Promocode-API.git
synced 2026-05-22 22:07:12 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,6 +3,3 @@ from ninja import Schema
|
|||||||
|
|
||||||
class PingOut(Schema):
|
class PingOut(Schema):
|
||||||
message_from_basement: str
|
message_from_basement: str
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["PingOut"]
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -17,11 +17,3 @@ class ValidationError(Schema):
|
|||||||
|
|
||||||
class UniqueConstraintError(Schema):
|
class UniqueConstraintError(Schema):
|
||||||
detail: str
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"NotFoundError",
|
|
||||||
"UnauthorizedError",
|
|
||||||
"UniqueConstraintError",
|
|
||||||
"ValidationError",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PingConfig(AppConfig):
|
||||||
|
name = "api.v1.user"
|
||||||
|
label = "api_v1_user"
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user