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 promocode activation
also code reformatting and bug fixes
This commit is contained in:
@@ -70,7 +70,9 @@ class CreatePromocodeOut(Schema):
|
|||||||
|
|
||||||
|
|
||||||
class PromocodeListFilters(Schema):
|
class PromocodeListFilters(Schema):
|
||||||
limit: int = Field(10, gt=0, description="Limit must be greater than 0")
|
limit: int = Field(
|
||||||
|
10, ge=0, description="Limit must be greater than or equal 0"
|
||||||
|
)
|
||||||
offset: int = Field(
|
offset: int = Field(
|
||||||
0, ge=0, description="Offset must be greater than or equal to 0"
|
0, ge=0, description="Offset must be greater than or equal to 0"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -172,8 +172,8 @@ def list_promocode(
|
|||||||
promocodes = promocodes.order_by("-created_at")
|
promocodes = promocodes.order_by("-created_at")
|
||||||
|
|
||||||
promocodes = promocodes.prefetch_related("activations", "likes").annotate(
|
promocodes = promocodes.prefetch_related("activations", "likes").annotate(
|
||||||
used_count=Count("activations"),
|
used_count=Count("activations", distinct=True),
|
||||||
like_count=Count("likes"),
|
like_count=Count("likes", distinct=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
promocodes = promocodes[filters.offset : filters.offset + filters.limit]
|
promocodes = promocodes[filters.offset : filters.offset + filters.limit]
|
||||||
@@ -213,8 +213,8 @@ def get_promocode(
|
|||||||
promocodes.select_related("target")
|
promocodes.select_related("target")
|
||||||
.prefetch_related("activations", "likes")
|
.prefetch_related("activations", "likes")
|
||||||
.annotate(
|
.annotate(
|
||||||
used_count=Count("activations"),
|
used_count=Count("activations", distinct=True),
|
||||||
like_count=Count("likes"),
|
like_count=Count("likes", distinct=True),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -255,8 +255,8 @@ def patch_promocode(
|
|||||||
promocodes.select_related("target")
|
promocodes.select_related("target")
|
||||||
.prefetch_related("activations", "likes")
|
.prefetch_related("activations", "likes")
|
||||||
.annotate(
|
.annotate(
|
||||||
used_count=Count("activations"),
|
used_count=Count("activations", distinct=True),
|
||||||
like_count=Count("likes"),
|
like_count=Count("likes", distinct=True),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ class PatchUserIn(Schema):
|
|||||||
|
|
||||||
|
|
||||||
class PromocodeFeedFilters(Schema):
|
class PromocodeFeedFilters(Schema):
|
||||||
limit: int = Field(10, gt=0, description="Limit must be greater than 0")
|
limit: int = Field(
|
||||||
|
10, ge=0, description="Limit must be greater than or equal 0"
|
||||||
|
)
|
||||||
offset: int = Field(
|
offset: int = Field(
|
||||||
0, ge=0, description="Offset must be greater than or equal to 0"
|
0, ge=0, description="Offset must be greater than or equal to 0"
|
||||||
)
|
)
|
||||||
@@ -101,7 +103,9 @@ class PromocodeRemoveLikeOut(Schema):
|
|||||||
|
|
||||||
|
|
||||||
class PromocodeCommentsFilters(Schema):
|
class PromocodeCommentsFilters(Schema):
|
||||||
limit: int = Field(10, gt=0, description="Limit must be greater than 0")
|
limit: int = Field(
|
||||||
|
10, ge=0, description="Limit must be greater than or equal 0"
|
||||||
|
)
|
||||||
offset: int = Field(
|
offset: int = Field(
|
||||||
0, ge=0, description="Offset must be greater than or equal to 0"
|
0, ge=0, description="Offset must be greater than or equal to 0"
|
||||||
)
|
)
|
||||||
@@ -110,9 +114,7 @@ class PromocodeCommentsFilters(Schema):
|
|||||||
class CommentIn(ModelSchema):
|
class CommentIn(ModelSchema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PromocodeComment
|
model = PromocodeComment
|
||||||
fields: ClassVar[list[str]] = [
|
fields: ClassVar[list[str]] = [PromocodeComment.text.field.name]
|
||||||
PromocodeComment.text.field.name
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class CommentAuthor(Schema):
|
class CommentAuthor(Schema):
|
||||||
@@ -130,3 +132,7 @@ class CommentOut(Schema):
|
|||||||
|
|
||||||
class CommentDeletedOut(Schema):
|
class CommentDeletedOut(Schema):
|
||||||
status: str = "ok"
|
status: str = "ok"
|
||||||
|
|
||||||
|
|
||||||
|
class PromocodeActivateOut(Schema):
|
||||||
|
promo: str
|
||||||
|
|||||||
@@ -39,6 +39,6 @@ def map_comment_to_schema(comment: PromocodeComment) -> schemas.CommentOut:
|
|||||||
author=schemas.CommentAuthor(
|
author=schemas.CommentAuthor(
|
||||||
name=comment.author.name,
|
name=comment.author.name,
|
||||||
surname=comment.author.surname,
|
surname=comment.author.surname,
|
||||||
avatar_url=comment.author.avatar_url
|
avatar_url=comment.author.avatar_url,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from apps.promo.models import (
|
|||||||
)
|
)
|
||||||
from apps.user.models import User
|
from apps.user.models import User
|
||||||
from config.errors import UniqueConstraintError
|
from config.errors import UniqueConstraintError
|
||||||
|
from config.integrations.antifraud.interactor import AntifraudServiceInteractor
|
||||||
|
|
||||||
router = Router(tags=["user"])
|
router = Router(tags=["user"])
|
||||||
|
|
||||||
@@ -146,8 +147,8 @@ def feed(
|
|||||||
)
|
)
|
||||||
|
|
||||||
promocodes = promocodes.prefetch_related("likes", "comments").annotate(
|
promocodes = promocodes.prefetch_related("likes", "comments").annotate(
|
||||||
like_count=Count("likes"),
|
like_count=Count("likes", distinct=True),
|
||||||
comment_count=Count("comments"),
|
comment_count=Count("comments", distinct=True),
|
||||||
is_liked_by_user=Exists(
|
is_liked_by_user=Exists(
|
||||||
PromocodeLike.objects.filter(promocode=OuterRef("pk"), user=user)
|
PromocodeLike.objects.filter(promocode=OuterRef("pk"), user=user)
|
||||||
),
|
),
|
||||||
@@ -203,8 +204,8 @@ def get_promocode(
|
|||||||
promocodes.select_related("business")
|
promocodes.select_related("business")
|
||||||
.prefetch_related("likes", "comments")
|
.prefetch_related("likes", "comments")
|
||||||
.annotate(
|
.annotate(
|
||||||
like_count=Count("likes"),
|
like_count=Count("likes", distinct=True),
|
||||||
comment_count=Count("comments"),
|
comment_count=Count("comments", distinct=True),
|
||||||
is_liked_by_user=Exists(
|
is_liked_by_user=Exists(
|
||||||
PromocodeLike.objects.filter(
|
PromocodeLike.objects.filter(
|
||||||
promocode=OuterRef("pk"), user=user
|
promocode=OuterRef("pk"), user=user
|
||||||
@@ -234,7 +235,7 @@ def get_promocode(
|
|||||||
)
|
)
|
||||||
def add_like(
|
def add_like(
|
||||||
request: HttpRequest, promocode_id: str
|
request: HttpRequest, promocode_id: str
|
||||||
) -> tuple[status.OK, schemas.PromocodeViewOut]:
|
) -> tuple[status.OK, schemas.PromocodeLikeOut]:
|
||||||
user: User = request.auth
|
user: User = request.auth
|
||||||
|
|
||||||
promocodes = Promocode.objects.filter(id=promocode_id)
|
promocodes = Promocode.objects.filter(id=promocode_id)
|
||||||
@@ -259,7 +260,7 @@ def add_like(
|
|||||||
)
|
)
|
||||||
def delete_like(
|
def delete_like(
|
||||||
request: HttpRequest, promocode_id: str
|
request: HttpRequest, promocode_id: str
|
||||||
) -> tuple[status.OK, schemas.PromocodeViewOut]:
|
) -> tuple[status.OK, schemas.PromocodeRemoveLikeOut]:
|
||||||
user: User = request.auth
|
user: User = request.auth
|
||||||
|
|
||||||
promocodes = Promocode.objects.filter(id=promocode_id)
|
promocodes = Promocode.objects.filter(id=promocode_id)
|
||||||
@@ -272,7 +273,7 @@ def delete_like(
|
|||||||
promocode=promocodes.first(), user=user
|
promocode=promocodes.first(), user=user
|
||||||
).delete()
|
).delete()
|
||||||
|
|
||||||
return status.OK, schemas.PromocodeLikeOut()
|
return status.OK, schemas.PromocodeRemoveLikeOut()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -315,8 +316,8 @@ def list_comments(
|
|||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
filters: Query[schemas.PromocodeCommentsFilters],
|
filters: Query[schemas.PromocodeCommentsFilters],
|
||||||
promocode_id: str,
|
promocode_id: str,
|
||||||
response: HttpResponse
|
response: HttpResponse,
|
||||||
) -> tuple[int, schemas.CommentOut]:
|
) -> tuple[int, list[schemas.CommentOut]]:
|
||||||
promocodes = Promocode.objects.filter(id=promocode_id)
|
promocodes = Promocode.objects.filter(id=promocode_id)
|
||||||
|
|
||||||
if not promocodes.exists():
|
if not promocodes.exists():
|
||||||
@@ -412,7 +413,7 @@ def delete_comment(
|
|||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
promocode_id: str,
|
promocode_id: str,
|
||||||
comment_id: str,
|
comment_id: str,
|
||||||
) -> tuple[int, schemas.CommentOut]:
|
) -> tuple[int, schemas.CommentDeletedOut]:
|
||||||
user: User = request.auth
|
user: User = request.auth
|
||||||
|
|
||||||
commnets = PromocodeComment.objects.filter(
|
commnets = PromocodeComment.objects.filter(
|
||||||
@@ -432,3 +433,53 @@ def delete_comment(
|
|||||||
comment_obj.delete()
|
comment_obj.delete()
|
||||||
|
|
||||||
return status.OK, schemas.CommentDeletedOut()
|
return status.OK, schemas.CommentDeletedOut()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/promo/{promocode_id}/activate",
|
||||||
|
auth=UserAuth(),
|
||||||
|
response={
|
||||||
|
status.OK: schemas.PromocodeActivateOut,
|
||||||
|
status.BAD_REQUEST: global_schemas.BadRequestError,
|
||||||
|
status.NOT_FOUND: global_schemas.NotFoundError,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def activate_promocode(
|
||||||
|
request: HttpRequest,
|
||||||
|
promocode_id: str,
|
||||||
|
) -> tuple[int, schemas.PromocodeActivateOut]:
|
||||||
|
user: User = request.auth
|
||||||
|
|
||||||
|
promocodes = Promocode.objects.filter(id=promocode_id)
|
||||||
|
|
||||||
|
if not promocodes.exists():
|
||||||
|
raise HttpError(status.NOT_FOUND, status.NOT_FOUND.phrase)
|
||||||
|
|
||||||
|
promocodes = promocodes.select_related("target").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),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not promocodes.exists():
|
||||||
|
raise HttpError(status.FORBIDDEN, status.FORBIDDEN.phrase)
|
||||||
|
|
||||||
|
promocode = promocodes.first()
|
||||||
|
|
||||||
|
if not promocode.active:
|
||||||
|
raise HttpError(status.FORBIDDEN, status.FORBIDDEN.phrase)
|
||||||
|
|
||||||
|
antifraud_result = AntifraudServiceInteractor().validate(
|
||||||
|
user_email=user.email, promo_id=str(promocode.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not antifraud_result["ok"]:
|
||||||
|
raise HttpError(status.FORBIDDEN, status.FORBIDDEN.phrase)
|
||||||
|
|
||||||
|
promo = promocode.activate_promocode(user)
|
||||||
|
|
||||||
|
return status.OK, schemas.PromocodeActivateOut(promo=promo)
|
||||||
|
|||||||
@@ -144,6 +144,23 @@ class Promocode(BaseModel):
|
|||||||
|
|
||||||
PromocodeDurationValidator()(self)
|
PromocodeDurationValidator()(self)
|
||||||
|
|
||||||
|
def activate_promocode(self, user: User) -> str:
|
||||||
|
promocode: str | None = None
|
||||||
|
|
||||||
|
if self.mode == self.ModeChoices.COMMON:
|
||||||
|
promocode = self.promo_common
|
||||||
|
elif self.mode == self.ModeChoices.UNIQUE:
|
||||||
|
unused_promocodes = self.promo_unique[
|
||||||
|
len(self.promo_unique_activated) : :
|
||||||
|
]
|
||||||
|
promocode = unused_promocodes[0]
|
||||||
|
self.promo_unique_activated.append(promocode)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
PromocodeActivation.objects.create(promocode=self, user=user)
|
||||||
|
|
||||||
|
return promocode
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def active(self) -> bool:
|
def active(self) -> bool:
|
||||||
current_date = timezone.datetime.today().date()
|
current_date = timezone.datetime.today().date()
|
||||||
@@ -210,5 +227,8 @@ class PromocodeLike(BaseModel):
|
|||||||
User, on_delete=models.CASCADE, related_name="liked_promocodes"
|
User, on_delete=models.CASCADE, related_name="liked_promocodes"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.promocode.id} | {self.user.id}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ("promocode", "user")
|
unique_together = ("promocode", "user")
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from http import HTTPStatus as status
|
|
||||||
from typing import ClassVar
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from django.core.cache import cache
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
|
|
||||||
class AntifraudServiceInteractor:
|
|
||||||
HEADERS: ClassVar[dict[str, str]] = {"Content-Type": "application/json"}
|
|
||||||
CACHE_PREFIX = "antifraud_cache"
|
|
||||||
|
|
||||||
def __init__(self, endpoint: str) -> None:
|
|
||||||
self.antifraud_endpoint = f"{endpoint}/api/validate"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_cache_key(cls, user_email: str, promo_id: str) -> None:
|
|
||||||
return f"{cls.CACHE_PREFIX}:{user_email}:{promo_id}"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def is_cache_valid(cls, cache_until: str) -> bool:
|
|
||||||
if cache_until:
|
|
||||||
return (
|
|
||||||
datetime.fromisoformat(cache_until).replace(
|
|
||||||
tzinfo=timezone.utc,
|
|
||||||
)
|
|
||||||
> timezone.now()
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def validate(cls, user_email: str, promo_id: str) -> dict[str, bool | str]:
|
|
||||||
cache_key = cls.get_cache_key(user_email, promo_id)
|
|
||||||
cached_result = cache.get(cache_key)
|
|
||||||
|
|
||||||
if cached_result and cls.is_cache_valid(
|
|
||||||
cached_result.get("cache_until"),
|
|
||||||
):
|
|
||||||
return cached_result
|
|
||||||
|
|
||||||
payload = {"user_email": user_email, "promo_id": promo_id}
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=5) as client:
|
|
||||||
response = client.post(
|
|
||||||
cls.antifraud_endpoint,
|
|
||||||
json=payload,
|
|
||||||
headers=cls.HEADERS,
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == status.OK:
|
|
||||||
result = response.json()
|
|
||||||
|
|
||||||
if "cache_until" in result:
|
|
||||||
cache.set(cache_key, result)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
retry_response = client.post(
|
|
||||||
cls.antifraud_endpoint,
|
|
||||||
json=payload,
|
|
||||||
headers=cls.HEADERS,
|
|
||||||
)
|
|
||||||
if retry_response.status_code == status.OK:
|
|
||||||
result = retry_response.json()
|
|
||||||
if "cache_until" in result:
|
|
||||||
cache.set(cache_key, result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
except httpx.HTTPError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {"ok": False}
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class AntifraudHealthCheck(BaseHealthCheckBackend):
|
|||||||
|
|
||||||
def check_status(self) -> None:
|
def check_status(self) -> None:
|
||||||
try:
|
try:
|
||||||
response = httpx.get(f"{settings.ANTIFRAUD_ENDPOINT}/api/ping")
|
response = httpx.get(f"{settings.ANTIFRAUD_ADDRESS}/api/ping")
|
||||||
if response.status_code >= status.INTERNAL_SERVER_ERROR:
|
if response.status_code >= status.INTERNAL_SERVER_ERROR:
|
||||||
self.add_error("Antifraud service is unaccessible")
|
self.add_error("Antifraud service is unaccessible")
|
||||||
except httpx.HTTPError:
|
except httpx.HTTPError:
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from http import HTTPStatus as status
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.utils import timezone
|
||||||
|
from pytz import timezone as tz
|
||||||
|
|
||||||
|
logger = settings.LOGGER
|
||||||
|
|
||||||
|
|
||||||
|
class AntifraudServiceInteractor:
|
||||||
|
HEADERS: ClassVar[dict[str, str]] = {"Content-Type": "application/json"}
|
||||||
|
CACHE_PREFIX = "antifraud_cache"
|
||||||
|
ANTIFRAUD_ENDPOINT = f"{settings.ANTIFRAUD_ADDRESS}/api/validate"
|
||||||
|
RETRY_COUNT: ClassVar[int] = 2
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_cache_key(cls, user_email: str, promo_id: str) -> str:
|
||||||
|
return f"{cls.CACHE_PREFIX}:{user_email}:{promo_id}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_cache_valid(cls, cache_until: str) -> bool:
|
||||||
|
if cache_until:
|
||||||
|
try:
|
||||||
|
cache_expiry = datetime.fromisoformat(cache_until).astimezone(
|
||||||
|
tz(settings.TIME_ZONE)
|
||||||
|
)
|
||||||
|
return cache_expiry > timezone.now()
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_request(
|
||||||
|
client: httpx.Client,
|
||||||
|
url: str,
|
||||||
|
payload: dict[str, str],
|
||||||
|
headers: dict[str, str],
|
||||||
|
retries: int,
|
||||||
|
) -> httpx.Response | None:
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
response = client.post(url, json=payload, headers=headers)
|
||||||
|
request_time = time.time() - start_time
|
||||||
|
logger.info(
|
||||||
|
"Attempt %d: Request to %s took %s seconds",
|
||||||
|
attempt,
|
||||||
|
url,
|
||||||
|
request_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == status.OK:
|
||||||
|
return response
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Attempt %d failed with status %d",
|
||||||
|
attempt,
|
||||||
|
response.status_code,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError:
|
||||||
|
logger.exception(
|
||||||
|
"Attempt %d: HTTP error during request to %s",
|
||||||
|
attempt,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.exception("All %d attempts to %s failed", retries, url)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate(cls, user_email: str, promo_id: str) -> dict[str, bool | str]:
|
||||||
|
cache_key = cls.get_cache_key(user_email, promo_id)
|
||||||
|
cached_result = cache.get(cache_key)
|
||||||
|
|
||||||
|
if cached_result and cls.is_cache_valid(
|
||||||
|
cached_result.get("cache_until")
|
||||||
|
):
|
||||||
|
return cached_result
|
||||||
|
|
||||||
|
payload = {"user_email": user_email, "promo_id": promo_id}
|
||||||
|
try:
|
||||||
|
logger.info("Trying to validate antifraud")
|
||||||
|
with httpx.Client(timeout=5) as client:
|
||||||
|
response = cls._make_request(
|
||||||
|
client,
|
||||||
|
cls.ANTIFRAUD_ENDPOINT,
|
||||||
|
payload,
|
||||||
|
cls.HEADERS,
|
||||||
|
retries=cls.RETRY_COUNT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response:
|
||||||
|
logger.info("Antifraud works perfectly")
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
if "cache_until" in result:
|
||||||
|
cache.set(cache_key, result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(
|
||||||
|
"Unexpected error during antifraud validation: %s",
|
||||||
|
e, # noqa: TRY401
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"ok": False}
|
||||||
@@ -28,7 +28,7 @@ ALLOWED_HOSTS = env(
|
|||||||
|
|
||||||
# Integrations
|
# Integrations
|
||||||
|
|
||||||
ANTIFRAUD_ENDPOINT = (
|
ANTIFRAUD_ADDRESS = (
|
||||||
f"http://{env('ANTIFRAUD_ADDRESS', default='http://localhost:9090')}"
|
f"http://{env('ANTIFRAUD_ADDRESS', default='http://localhost:9090')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ dependencies = [
|
|||||||
"pydantic[email]>=2.10.5",
|
"pydantic[email]>=2.10.5",
|
||||||
"pyjwt>=2.10.1",
|
"pyjwt>=2.10.1",
|
||||||
"python-json-logger>=3.2.1",
|
"python-json-logger>=3.2.1",
|
||||||
|
"pytz>=2024.2",
|
||||||
"redis>=5.2.1",
|
"redis>=5.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user