diff --git a/promo_code/business/validators.py b/promo_code/business/validators.py index 88a10f6..6bcd45b 100644 --- a/promo_code/business/validators.py +++ b/promo_code/business/validators.py @@ -42,6 +42,7 @@ def _get_full_data(self): 'max_count': self.instance.max_count, 'active_from': self.instance.active_from, 'active_until': self.instance.active_until, + 'used_count': self.instance.used_count, 'target': self.instance.target if self.instance.target else {}, @@ -56,6 +57,7 @@ def validate(self): promo_common = self.full_data.get('promo_common') promo_unique = self.full_data.get('promo_unique') max_count = self.full_data.get('max_count') + used_count = self.full_data.get('used_count') if mode not in [ business.constants.PROMO_MODE_COMMON, @@ -65,6 +67,11 @@ def validate(self): {'mode': 'Invalid mode.'}, ) + if used_count and used_count > max_count: + raise rest_framework.exceptions.ValidationError( + {'mode': 'Invalid max_count.'}, + ) + if mode == business.constants.PROMO_MODE_COMMON: if not promo_common: raise rest_framework.exceptions.ValidationError( diff --git a/promo_code/promo_code/settings.py b/promo_code/promo_code/settings.py index 016a9d7..edc06d3 100644 --- a/promo_code/promo_code/settings.py +++ b/promo_code/promo_code/settings.py @@ -153,6 +153,7 @@ def load_bool(name, default): ANTIFRAUD_ADDRESS = f'{os.getenv("ANTIFRAUD_ADDRESS")}' ANTIFRAUD_VALIDATE_URL = f'{ANTIFRAUD_ADDRESS}/api/validate' +ANTIFRAUD_SET_DELAY_URL = f'{ANTIFRAUD_ADDRESS}/internal/set_delay' ANTIFRAUD_UPDATE_USER_VERDICT_URL = ( f'{ANTIFRAUD_ADDRESS}/internal/update_user_verdict' ) diff --git a/promo_code/user/antifraud_service.py b/promo_code/user/antifraud_service.py new file mode 100644 index 0000000..c5cc2fe --- /dev/null +++ b/promo_code/user/antifraud_service.py @@ -0,0 +1,105 @@ +import datetime +import json +import typing + +import django.conf +import django.core.cache +import requests +import requests.exceptions + + +class AntiFraudService: + """ + A service class to interact with the anti-fraud system. + + Encapsulates caching, HTTP requests, and error handling. + """ + + def __init__( + self, + base_url: str = django.conf.settings.ANTIFRAUD_VALIDATE_URL, + timeout: int = 5, + max_retries: int = 2, + ): + self.base_url = base_url + self.timeout = timeout + self.max_retries = max_retries + + def get_verdict(self, user_email: str, promo_id: str) -> typing.Dict: + """ + Retrieves the anti-fraud verdict for a given user and promo. + + 1. Checks the cache. + 2. If not in cache, fetches from the anti-fraud service. + 3. Caches the result if the service provides a 'cache_until' value. + """ + cache_key = f'antifraud_verdict_{user_email}' + + if cached_verdict := django.core.cache.cache.get(cache_key): + return cached_verdict + + verdict = self._fetch_from_service(user_email, promo_id) + + if verdict.get('ok'): + timeout_seconds = self._calculate_cache_timeout( + verdict.get('cache_until'), + ) + if timeout_seconds: + django.core.cache.cache.set( + cache_key, + verdict, + timeout=timeout_seconds, + ) + + return verdict + + def _fetch_from_service( + self, + user_email: str, + promo_id: str, + ) -> typing.Dict: + """ + Performs the actual HTTP request with a retry mechanism. + """ + payload = {'user_email': user_email, 'promo_id': promo_id} + + for _ in range(self.max_retries): + try: + response = requests.post( + self.base_url, + json=payload, + timeout=self.timeout, + ) + response.raise_for_status() + return response.json() + except ( + requests.exceptions.RequestException, + json.JSONDecodeError, + ): + continue + + return {'ok': False, 'error': 'Anti-fraud service unavailable'} + + @staticmethod + def _calculate_cache_timeout( + cache_until_str: typing.Optional[str], + ) -> typing.Optional[int]: + """ + Safely parses an ISO format date string + and returns a cache TTL in seconds. + """ + if not cache_until_str: + return None + + try: + naive_dt = datetime.datetime.fromisoformat(cache_until_str) + aware_dt = naive_dt.replace(tzinfo=datetime.timezone.utc) + now = datetime.datetime.now(datetime.timezone.utc) + + timeout_seconds = (aware_dt - now).total_seconds() + return int(timeout_seconds) if timeout_seconds > 0 else None + except (ValueError, TypeError): + return None + + +antifraud_service = AntiFraudService() diff --git a/promo_code/user/migrations/0004_promoactivationhistory.py b/promo_code/user/migrations/0004_promoactivationhistory.py new file mode 100644 index 0000000..2cd1c61 --- /dev/null +++ b/promo_code/user/migrations/0004_promoactivationhistory.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2 on 2025-07-01 11:16 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("business", "0004_promo_comment_count"), + ("user", "0003_promocomment"), + ] + + operations = [ + migrations.CreateModel( + name="PromoActivationHistory", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + verbose_name="UUID", + ), + ), + ("activated_at", models.DateTimeField(auto_now_add=True)), + ( + "promo", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="activations_history", + to="business.promo", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="promo_activations", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-activated_at"], + }, + ), + ] diff --git a/promo_code/user/models.py b/promo_code/user/models.py index 8a25de9..e76bc79 100644 --- a/promo_code/user/models.py +++ b/promo_code/user/models.py @@ -148,3 +148,29 @@ class Meta: def __str__(self): return f'Comment by {self.author.email} on promo {self.promo.id}' + + +class PromoActivationHistory(django.db.models.Model): + id = django.db.models.UUIDField( + 'UUID', + primary_key=True, + default=uuid.uuid4, + editable=False, + ) + user = django.db.models.ForeignKey( + User, + on_delete=django.db.models.CASCADE, + related_name='promo_activations', + ) + promo = django.db.models.ForeignKey( + business.models.Promo, + on_delete=django.db.models.CASCADE, + related_name='activations_history', + ) + activated_at = django.db.models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-activated_at'] + + def __str__(self): + return f'{self.user} activated {self.promo.id} at {self.activated_at}' diff --git a/promo_code/user/serializers.py b/promo_code/user/serializers.py index a6a504e..72199f3 100644 --- a/promo_code/user/serializers.py +++ b/promo_code/user/serializers.py @@ -449,9 +449,22 @@ def get_is_liked_by_user(self, obj: business.models.Promo) -> bool: ).exists() return False - def get_is_activated_by_user(self, obj) -> bool: - # TODO: - return False + def get_is_activated_by_user(self, obj: business.models.Promo) -> bool: + """ + Checks whether the current user has activated this promo code. + """ + request = self.context.get('request') + if not ( + request + and hasattr(request, 'user') + and request.user.is_authenticated + ): + return False + + return user.models.PromoActivationHistory.objects.filter( + promo=obj, + user=request.user, + ).exists() class UserAuthorSerializer(rest_framework.serializers.ModelSerializer): @@ -514,3 +527,11 @@ class CommentUpdateSerializer(rest_framework.serializers.ModelSerializer): class Meta: model = user.models.PromoComment fields = ('text',) + + +class PromoActivationSerializer(rest_framework.serializers.Serializer): + """ + Serializer for the response upon successful activation. + """ + + promo = rest_framework.serializers.CharField() diff --git a/promo_code/user/urls.py b/promo_code/user/urls.py index 00b9b01..2c5dc3d 100644 --- a/promo_code/user/urls.py +++ b/promo_code/user/urls.py @@ -52,4 +52,14 @@ user.views.PromoCommentDetailView.as_view(), name='user-promo-comment-detail', ), + django.urls.path( + 'promo//activate', + user.views.PromoActivateView.as_view(), + name='user-promo-activate', + ), + django.urls.path( + 'promo/history', + user.views.PromoHistoryView.as_view(), + name='user-promo-history', + ), ] diff --git a/promo_code/user/views.py b/promo_code/user/views.py index e72d23f..70bd8d2 100644 --- a/promo_code/user/views.py +++ b/promo_code/user/views.py @@ -1,4 +1,5 @@ import django.db.models +import django.db.transaction import django.shortcuts import django.utils.timezone import rest_framework.exceptions @@ -12,6 +13,7 @@ import business.constants import business.models +import user.antifraud_service import user.models import user.pagination import user.permissions @@ -391,3 +393,157 @@ def destroy(self, request, *args, **kwargs): {'status': 'ok'}, status=rest_framework.status.HTTP_200_OK, ) + + +class PromoActivateView(rest_framework.views.APIView): + permission_classes = [rest_framework.permissions.IsAuthenticated] + allowed_methods = ['post', 'options', 'head'] + + def _validate_targeting(self, user_, promo): + user_age = user_.other.get('age') + user_country = user_.other.get('country').lower() + target = promo.target + + if not target: + return None + + if target.get('country') and user_country != target['country'].lower(): + return rest_framework.response.Response( + {'error': 'Targeting mismatch: country.'}, + status=rest_framework.status.HTTP_403_FORBIDDEN, + ) + if target.get('age_from') and ( + user_age is None or user_age < target['age_from'] + ): + return rest_framework.response.Response( + {'error': 'Targeting mismatch: age.'}, + status=rest_framework.status.HTTP_403_FORBIDDEN, + ) + if target.get('age_until') and ( + user_age is None or user_age > target['age_until'] + ): + return rest_framework.response.Response( + {'error': 'Targeting mismatch: age.'}, + status=rest_framework.status.HTTP_403_FORBIDDEN, + ) + return None + + def _validate_is_active(self, promo): + if not promo.active or not promo.is_active: + return rest_framework.response.Response( + {'error': 'Promo is not active.'}, + status=rest_framework.status.HTTP_403_FORBIDDEN, + ) + return None + + def _validate_antifraud(self, user_, promo): + antifraud_response = ( + user.antifraud_service.antifraud_service.get_verdict( + user_.email, + str(promo.id), + ) + ) + if not antifraud_response.get('ok'): + return rest_framework.response.Response( + {'error': 'Activation forbidden by anti-fraud system.'}, + status=rest_framework.status.HTTP_403_FORBIDDEN, + ) + return None + + def _activate_code(self, user_, promo): + try: + with django.db.transaction.atomic(): + promo_for_update = ( + business.models.Promo.objects.select_for_update().get( + id=promo.id, + ) + ) + promo_code_value = None + + if ( + promo_for_update.mode + == business.constants.PROMO_MODE_COMMON + ): + if ( + promo_for_update.used_count + < promo_for_update.max_count + ): + promo_for_update.used_count += 1 + promo_for_update.save(update_fields=['used_count']) + promo_code_value = promo_for_update.promo_common + else: + raise ValueError('No common codes left.') + + elif ( + promo_for_update.mode + == business.constants.PROMO_MODE_UNIQUE + ): + unique_code = promo_for_update.unique_codes.filter( + is_used=False, + ).first() + if unique_code: + unique_code.is_used = True + unique_code.used_at = django.utils.timezone.now() + unique_code.save(update_fields=['is_used', 'used_at']) + promo_code_value = unique_code.code + else: + raise ValueError('No unique codes left.') + + if promo_code_value: + user.models.PromoActivationHistory.objects.create( + user=user_, + promo=promo, + ) + serializer = user.serializers.PromoActivationSerializer( + data={'promo': promo_code_value}, + ) + serializer.is_valid(raise_exception=True) + return rest_framework.response.Response( + serializer.data, + status=rest_framework.status.HTTP_200_OK, + ) + + raise ValueError('Promo code could not be activated.') + + except ValueError as e: + return rest_framework.response.Response( + {'error': str(e)}, + status=rest_framework.status.HTTP_403_FORBIDDEN, + ) + + def post(self, request, id): + promo = django.shortcuts.get_object_or_404( + business.models.Promo, + id=id, + ) + user_ = request.user + + if (response := self._validate_targeting(user_, promo)) is not None: + return response + + if (response := self._validate_is_active(promo)) is not None: + return response + + if (response := self._validate_antifraud(user_, promo)) is not None: + return response + + return self._activate_code(user_, promo) + + +class PromoHistoryView(rest_framework.generics.ListAPIView): + """ + Returns the history of activated promo codes for the current user. + """ + + serializer_class = user.serializers.UserPromoDetailSerializer + permission_classes = [rest_framework.permissions.IsAuthenticated] + pagination_class = user.pagination.UserFeedPagination + + def get_queryset(self): + user = self.request.user + + queryset = business.models.Promo.objects.filter( + activations_history__user=user, + ).order_by('-activations_history__activated_at') + + return queryset # noqa: RET504