feat: added promocode activation

also code reformatting and bug fixes
This commit is contained in:
ITQ
2025-01-26 11:33:03 +03:00
parent 43baa2af5c
commit 545201e4dd
11 changed files with 218 additions and 99 deletions
+3 -1
View File
@@ -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"
) )
+6 -6
View File
@@ -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),
) )
) )
+11 -5
View File
@@ -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
+2 -2
View File
@@ -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,
) ),
) )
+61 -10
View File
@@ -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)
+20
View File
@@ -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")
-73
View File
@@ -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}
+1 -1
View File
@@ -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')}"
) )
+1
View File
@@ -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",
] ]