diff --git a/solution/api/v1/business/schemas.py b/solution/api/v1/business/schemas.py index 0730c9d..25ad8f3 100644 --- a/solution/api/v1/business/schemas.py +++ b/solution/api/v1/business/schemas.py @@ -70,7 +70,9 @@ class CreatePromocodeOut(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( 0, ge=0, description="Offset must be greater than or equal to 0" ) diff --git a/solution/api/v1/business/views.py b/solution/api/v1/business/views.py index cd53f71..d39daf4 100644 --- a/solution/api/v1/business/views.py +++ b/solution/api/v1/business/views.py @@ -172,8 +172,8 @@ def list_promocode( promocodes = promocodes.order_by("-created_at") promocodes = promocodes.prefetch_related("activations", "likes").annotate( - used_count=Count("activations"), - like_count=Count("likes"), + used_count=Count("activations", distinct=True), + like_count=Count("likes", distinct=True), ) promocodes = promocodes[filters.offset : filters.offset + filters.limit] @@ -213,8 +213,8 @@ def get_promocode( promocodes.select_related("target") .prefetch_related("activations", "likes") .annotate( - used_count=Count("activations"), - like_count=Count("likes"), + used_count=Count("activations", distinct=True), + like_count=Count("likes", distinct=True), ) ) @@ -255,8 +255,8 @@ def patch_promocode( promocodes.select_related("target") .prefetch_related("activations", "likes") .annotate( - used_count=Count("activations"), - like_count=Count("likes"), + used_count=Count("activations", distinct=True), + like_count=Count("likes", distinct=True), ) ) diff --git a/solution/api/v1/user/schemas.py b/solution/api/v1/user/schemas.py index 9314b37..8e6bc6b 100644 --- a/solution/api/v1/user/schemas.py +++ b/solution/api/v1/user/schemas.py @@ -71,7 +71,9 @@ class PatchUserIn(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( 0, ge=0, description="Offset must be greater than or equal to 0" ) @@ -101,7 +103,9 @@ class PromocodeRemoveLikeOut(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( 0, ge=0, description="Offset must be greater than or equal to 0" ) @@ -110,9 +114,7 @@ class PromocodeCommentsFilters(Schema): class CommentIn(ModelSchema): class Meta: model = PromocodeComment - fields: ClassVar[list[str]] = [ - PromocodeComment.text.field.name - ] + fields: ClassVar[list[str]] = [PromocodeComment.text.field.name] class CommentAuthor(Schema): @@ -130,3 +132,7 @@ class CommentOut(Schema): class CommentDeletedOut(Schema): status: str = "ok" + + +class PromocodeActivateOut(Schema): + promo: str diff --git a/solution/api/v1/user/utils.py b/solution/api/v1/user/utils.py index 22d230b..638db61 100644 --- a/solution/api/v1/user/utils.py +++ b/solution/api/v1/user/utils.py @@ -39,6 +39,6 @@ def map_comment_to_schema(comment: PromocodeComment) -> schemas.CommentOut: author=schemas.CommentAuthor( name=comment.author.name, surname=comment.author.surname, - avatar_url=comment.author.avatar_url - ) + avatar_url=comment.author.avatar_url, + ), ) diff --git a/solution/api/v1/user/views.py b/solution/api/v1/user/views.py index 8ed9197..4a491a3 100644 --- a/solution/api/v1/user/views.py +++ b/solution/api/v1/user/views.py @@ -17,6 +17,7 @@ from apps.promo.models import ( ) from apps.user.models import User from config.errors import UniqueConstraintError +from config.integrations.antifraud.interactor import AntifraudServiceInteractor router = Router(tags=["user"]) @@ -146,8 +147,8 @@ def feed( ) promocodes = promocodes.prefetch_related("likes", "comments").annotate( - like_count=Count("likes"), - comment_count=Count("comments"), + like_count=Count("likes", distinct=True), + comment_count=Count("comments", distinct=True), is_liked_by_user=Exists( PromocodeLike.objects.filter(promocode=OuterRef("pk"), user=user) ), @@ -203,8 +204,8 @@ def get_promocode( promocodes.select_related("business") .prefetch_related("likes", "comments") .annotate( - like_count=Count("likes"), - comment_count=Count("comments"), + like_count=Count("likes", distinct=True), + comment_count=Count("comments", distinct=True), is_liked_by_user=Exists( PromocodeLike.objects.filter( promocode=OuterRef("pk"), user=user @@ -234,7 +235,7 @@ def get_promocode( ) def add_like( request: HttpRequest, promocode_id: str -) -> tuple[status.OK, schemas.PromocodeViewOut]: +) -> tuple[status.OK, schemas.PromocodeLikeOut]: user: User = request.auth promocodes = Promocode.objects.filter(id=promocode_id) @@ -259,7 +260,7 @@ def add_like( ) def delete_like( request: HttpRequest, promocode_id: str -) -> tuple[status.OK, schemas.PromocodeViewOut]: +) -> tuple[status.OK, schemas.PromocodeRemoveLikeOut]: user: User = request.auth promocodes = Promocode.objects.filter(id=promocode_id) @@ -272,7 +273,7 @@ def delete_like( promocode=promocodes.first(), user=user ).delete() - return status.OK, schemas.PromocodeLikeOut() + return status.OK, schemas.PromocodeRemoveLikeOut() @router.post( @@ -315,8 +316,8 @@ def list_comments( request: HttpRequest, filters: Query[schemas.PromocodeCommentsFilters], promocode_id: str, - response: HttpResponse -) -> tuple[int, schemas.CommentOut]: + response: HttpResponse, +) -> tuple[int, list[schemas.CommentOut]]: promocodes = Promocode.objects.filter(id=promocode_id) if not promocodes.exists(): @@ -412,7 +413,7 @@ def delete_comment( request: HttpRequest, promocode_id: str, comment_id: str, -) -> tuple[int, schemas.CommentOut]: +) -> tuple[int, schemas.CommentDeletedOut]: user: User = request.auth commnets = PromocodeComment.objects.filter( @@ -432,3 +433,53 @@ def delete_comment( comment_obj.delete() 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) diff --git a/solution/apps/promo/models.py b/solution/apps/promo/models.py index 83c0fca..7b8d9f7 100644 --- a/solution/apps/promo/models.py +++ b/solution/apps/promo/models.py @@ -144,6 +144,23 @@ class Promocode(BaseModel): 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 def active(self) -> bool: current_date = timezone.datetime.today().date() @@ -210,5 +227,8 @@ class PromocodeLike(BaseModel): User, on_delete=models.CASCADE, related_name="liked_promocodes" ) + def __str__(self) -> str: + return f"{self.promocode.id} | {self.user.id}" + class Meta: unique_together = ("promocode", "user") diff --git a/solution/config/integrations/__init__.py b/solution/config/integrations/__init__.py index 6e5ba8d..e69de29 100644 --- a/solution/config/integrations/__init__.py +++ b/solution/config/integrations/__init__.py @@ -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} diff --git a/solution/config/integrations/antifraud/healthcheck.py b/solution/config/integrations/antifraud/healthcheck.py index ff206b0..5e63256 100644 --- a/solution/config/integrations/antifraud/healthcheck.py +++ b/solution/config/integrations/antifraud/healthcheck.py @@ -10,7 +10,7 @@ class AntifraudHealthCheck(BaseHealthCheckBackend): def check_status(self) -> None: 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: self.add_error("Antifraud service is unaccessible") except httpx.HTTPError: diff --git a/solution/config/integrations/antifraud/interactor.py b/solution/config/integrations/antifraud/interactor.py new file mode 100644 index 0000000..c98f66d --- /dev/null +++ b/solution/config/integrations/antifraud/interactor.py @@ -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} diff --git a/solution/config/settings.py b/solution/config/settings.py index 7bc81c1..a58cabf 100644 --- a/solution/config/settings.py +++ b/solution/config/settings.py @@ -28,7 +28,7 @@ ALLOWED_HOSTS = env( # Integrations -ANTIFRAUD_ENDPOINT = ( +ANTIFRAUD_ADDRESS = ( f"http://{env('ANTIFRAUD_ADDRESS', default='http://localhost:9090')}" ) diff --git a/solution/pyproject.toml b/solution/pyproject.toml index 2380afc..c79bdc4 100644 --- a/solution/pyproject.toml +++ b/solution/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "pydantic[email]>=2.10.5", "pyjwt>=2.10.1", "python-json-logger>=3.2.1", + "pytz>=2024.2", "redis>=5.2.1", ]