Skip to content

Commit ec72605

Browse files
feat: Add promo code activation and history endpoints
This commit introduces two new endpoints for user interactions with promo codes: - `POST /api/user/promo/{id}/activate`: Allows an authenticated user to activate a specific promo code. The activation is subject to several validation checks, including promo targeting rules (age, country), promo activity status, and a new anti-fraud service verification. - `GET /api/user/promo/history`: Retrieves a paginated list of promo codes that the current user has previously activated. Key changes include: - A `PromoActivateView` that handles the logic for activating a promo code, including validation and atomic updates to the promo's usage count or unique code status. - A `PromoHistoryView` to display the user's activation history. - A new `antifraud_service` module to communicate with an external anti-fraud system, complete with caching and retry logic. - A `PromoActivationHistory` model to log each successful activation, linking users to the promos they've activated. - Updates to `UserPromoDetailSerializer` to include an `is_activated_by_user` field, indicating if the user has already activated the promo. - New URL patterns in `user/urls.py` to route to the new views.
1 parent 81950c3 commit ec72605

File tree

8 files changed

+376
-3
lines changed

8 files changed

+376
-3
lines changed

promo_code/business/validators.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def _get_full_data(self):
4242
'max_count': self.instance.max_count,
4343
'active_from': self.instance.active_from,
4444
'active_until': self.instance.active_until,
45+
'used_count': self.instance.used_count,
4546
'target': self.instance.target
4647
if self.instance.target
4748
else {},
@@ -56,6 +57,7 @@ def validate(self):
5657
promo_common = self.full_data.get('promo_common')
5758
promo_unique = self.full_data.get('promo_unique')
5859
max_count = self.full_data.get('max_count')
60+
used_count = self.full_data.get('used_count')
5961

6062
if mode not in [
6163
business.constants.PROMO_MODE_COMMON,
@@ -65,6 +67,11 @@ def validate(self):
6567
{'mode': 'Invalid mode.'},
6668
)
6769

70+
if used_count and used_count > max_count:
71+
raise rest_framework.exceptions.ValidationError(
72+
{'mode': 'Invalid max_count.'},
73+
)
74+
6875
if mode == business.constants.PROMO_MODE_COMMON:
6976
if not promo_common:
7077
raise rest_framework.exceptions.ValidationError(

promo_code/promo_code/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ def load_bool(name, default):
153153

154154
ANTIFRAUD_ADDRESS = f'{os.getenv("ANTIFRAUD_ADDRESS")}'
155155
ANTIFRAUD_VALIDATE_URL = f'{ANTIFRAUD_ADDRESS}/api/validate'
156+
ANTIFRAUD_SET_DELAY_URL = f'{ANTIFRAUD_ADDRESS}/internal/set_delay'
156157
ANTIFRAUD_UPDATE_USER_VERDICT_URL = (
157158
f'{ANTIFRAUD_ADDRESS}/internal/update_user_verdict'
158159
)

promo_code/user/antifraud_service.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import datetime
2+
import json
3+
import typing
4+
5+
import django.conf
6+
import django.core.cache
7+
import requests
8+
import requests.exceptions
9+
10+
11+
class AntiFraudService:
12+
"""
13+
A service class to interact with the anti-fraud system.
14+
15+
Encapsulates caching, HTTP requests, and error handling.
16+
"""
17+
18+
def __init__(
19+
self,
20+
base_url: str = django.conf.settings.ANTIFRAUD_VALIDATE_URL,
21+
timeout: int = 5,
22+
max_retries: int = 2,
23+
):
24+
self.base_url = base_url
25+
self.timeout = timeout
26+
self.max_retries = max_retries
27+
28+
def get_verdict(self, user_email: str, promo_id: str) -> typing.Dict:
29+
"""
30+
Retrieves the anti-fraud verdict for a given user and promo.
31+
32+
1. Checks the cache.
33+
2. If not in cache, fetches from the anti-fraud service.
34+
3. Caches the result if the service provides a 'cache_until' value.
35+
"""
36+
cache_key = f'antifraud_verdict_{user_email}'
37+
38+
if cached_verdict := django.core.cache.cache.get(cache_key):
39+
return cached_verdict
40+
41+
verdict = self._fetch_from_service(user_email, promo_id)
42+
43+
if verdict.get('ok'):
44+
timeout_seconds = self._calculate_cache_timeout(
45+
verdict.get('cache_until'),
46+
)
47+
if timeout_seconds:
48+
django.core.cache.cache.set(
49+
cache_key, verdict, timeout=timeout_seconds,
50+
)
51+
52+
return verdict
53+
54+
def _fetch_from_service(
55+
self, user_email: str, promo_id: str,
56+
) -> typing.Dict:
57+
"""
58+
Performs the actual HTTP request with a retry mechanism.
59+
"""
60+
payload = {'user_email': user_email, 'promo_id': promo_id}
61+
62+
for _ in range(self.max_retries):
63+
try:
64+
response = requests.post(
65+
self.base_url,
66+
json=payload,
67+
timeout=self.timeout,
68+
)
69+
response.raise_for_status()
70+
return response.json()
71+
except (
72+
requests.exceptions.RequestException,
73+
json.JSONDecodeError,
74+
):
75+
continue
76+
77+
return {'ok': False, 'error': 'Anti-fraud service unavailable'}
78+
79+
@staticmethod
80+
def _calculate_cache_timeout(
81+
cache_until_str: typing.Optional[str],
82+
) -> typing.Optional[int]:
83+
"""
84+
Safely parses an ISO format date string and returns a cache TTL in seconds.
85+
"""
86+
if not cache_until_str:
87+
return None
88+
89+
try:
90+
naive_dt = datetime.datetime.fromisoformat(cache_until_str)
91+
aware_dt = naive_dt.replace(tzinfo=datetime.timezone.utc)
92+
now = datetime.datetime.now(datetime.timezone.utc)
93+
94+
timeout_seconds = (aware_dt - now).total_seconds()
95+
return int(timeout_seconds) if timeout_seconds > 0 else None
96+
except (ValueError, TypeError):
97+
return None
98+
99+
100+
antifraud_service = AntiFraudService()
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Generated by Django 5.2 on 2025-07-01 11:16
2+
3+
import django.db.models.deletion
4+
import uuid
5+
from django.conf import settings
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
("business", "0004_promo_comment_count"),
13+
("user", "0003_promocomment"),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name="PromoActivationHistory",
19+
fields=[
20+
(
21+
"id",
22+
models.UUIDField(
23+
default=uuid.uuid4,
24+
editable=False,
25+
primary_key=True,
26+
serialize=False,
27+
verbose_name="UUID",
28+
),
29+
),
30+
("activated_at", models.DateTimeField(auto_now_add=True)),
31+
(
32+
"promo",
33+
models.ForeignKey(
34+
on_delete=django.db.models.deletion.CASCADE,
35+
related_name="activations_history",
36+
to="business.promo",
37+
),
38+
),
39+
(
40+
"user",
41+
models.ForeignKey(
42+
on_delete=django.db.models.deletion.CASCADE,
43+
related_name="promo_activations",
44+
to=settings.AUTH_USER_MODEL,
45+
),
46+
),
47+
],
48+
options={
49+
"ordering": ["-activated_at"],
50+
},
51+
),
52+
]

promo_code/user/models.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,29 @@ class Meta:
148148

149149
def __str__(self):
150150
return f'Comment by {self.author.email} on promo {self.promo.id}'
151+
152+
153+
class PromoActivationHistory(django.db.models.Model):
154+
id = django.db.models.UUIDField(
155+
'UUID',
156+
primary_key=True,
157+
default=uuid.uuid4,
158+
editable=False,
159+
)
160+
user = django.db.models.ForeignKey(
161+
User,
162+
on_delete=django.db.models.CASCADE,
163+
related_name='promo_activations',
164+
)
165+
promo = django.db.models.ForeignKey(
166+
business.models.Promo,
167+
on_delete=django.db.models.CASCADE,
168+
related_name='activations_history',
169+
)
170+
activated_at = django.db.models.DateTimeField(auto_now_add=True)
171+
172+
class Meta:
173+
ordering = ['-activated_at']
174+
175+
def __str__(self):
176+
return f'{self.user} activated {self.promo.id} at {self.activated_at}'

promo_code/user/serializers.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -449,9 +449,22 @@ def get_is_liked_by_user(self, obj: business.models.Promo) -> bool:
449449
).exists()
450450
return False
451451

452-
def get_is_activated_by_user(self, obj) -> bool:
453-
# TODO:
454-
return False
452+
def get_is_activated_by_user(self, obj: business.models.Promo) -> bool:
453+
"""
454+
Checks whether the current user has activated this promo code.
455+
"""
456+
request = self.context.get('request')
457+
if not (
458+
request
459+
and hasattr(request, 'user')
460+
and request.user.is_authenticated
461+
):
462+
return False
463+
464+
return user.models.PromoActivationHistory.objects.filter(
465+
promo=obj,
466+
user=request.user,
467+
).exists()
455468

456469

457470
class UserAuthorSerializer(rest_framework.serializers.ModelSerializer):
@@ -514,3 +527,11 @@ class CommentUpdateSerializer(rest_framework.serializers.ModelSerializer):
514527
class Meta:
515528
model = user.models.PromoComment
516529
fields = ('text',)
530+
531+
532+
class PromoActivationSerializer(rest_framework.serializers.Serializer):
533+
"""
534+
Serializer for the response upon successful activation.
535+
"""
536+
537+
promo = rest_framework.serializers.CharField()

promo_code/user/urls.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,14 @@
5252
user.views.PromoCommentDetailView.as_view(),
5353
name='user-promo-comment-detail',
5454
),
55+
django.urls.path(
56+
'promo/<uuid:id>/activate',
57+
user.views.PromoActivateView.as_view(),
58+
name='user-promo-activate',
59+
),
60+
django.urls.path(
61+
'promo/history',
62+
user.views.PromoHistoryView.as_view(),
63+
name='user-promo-history',
64+
),
5565
]

0 commit comments

Comments
 (0)