Skip to content

Commit 90b6ce0

Browse files
refactor(user): Introduce PromoActivationService
This commit refactors the promo code activation logic by extracting it from the `PromoActivateView` into a dedicated `PromoActivationService`. Key changes: - Created `user/services.py` to house the new service class. - The `PromoActivationService` now encapsulates all business logic for activating a promo code, including: - User targeting validation (age, country). - Promotion active status check. - Anti-fraud system verification. - Atomic issuance of common or unique promo codes. - Introduced a set of custom, specific exceptions (`PromoActivationError`, `TargetingError`, etc.) to provide clearer error feedback to the API client. - The `PromoActivateView` is now significantly simplified, delegating all logic to the service and handling its success or failure responses.
1 parent 54c0317 commit 90b6ce0

File tree

2 files changed

+177
-121
lines changed

2 files changed

+177
-121
lines changed

promo_code/user/services.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import django.db.models
2+
import django.db.transaction
3+
import django.utils.timezone
4+
import rest_framework.exceptions
5+
6+
import business.constants
7+
import business.models
8+
import user.antifraud_service
9+
import user.models
10+
11+
12+
class PromoActivationError(rest_framework.exceptions.APIException):
13+
"""Base exception for all promo code activation errors."""
14+
15+
status_code = 403
16+
default_detail = 'Failed to activate promo code.'
17+
default_code = 'promo_activation_failed'
18+
19+
20+
class TargetingError(PromoActivationError):
21+
"""Error for targeting mismatch."""
22+
23+
default_detail = 'You do not meet the promotion requirements.'
24+
default_code = 'targeting_mismatch'
25+
26+
27+
class PromoInactiveError(PromoActivationError):
28+
"""Error if the promo code is inactive."""
29+
30+
default_detail = 'This promotion is currently inactive.'
31+
default_code = 'promo_inactive'
32+
33+
34+
class PromoUnavailableError(PromoActivationError):
35+
"""Error if all promo codes have been used."""
36+
37+
default_detail = (
38+
'Unfortunately, all codes for this promotion have been used.'
39+
)
40+
default_code = 'promo_unavailable'
41+
42+
43+
class AntiFraudError(PromoActivationError):
44+
"""Error from the anti-fraud system."""
45+
46+
default_detail = 'Activation is blocked by the security system.'
47+
default_code = 'antifraud_block'
48+
49+
50+
class PromoActivationService:
51+
"""Service to encapsulate promo code activation logic."""
52+
53+
def __init__(self, user: user.models.User, promo: business.models.Promo):
54+
self.user = user
55+
self.promo = promo
56+
57+
def activate(self) -> str:
58+
"""
59+
Main method that starts the validation and activation process.
60+
Returns the promo code on success.
61+
"""
62+
self._validate_targeting()
63+
self._validate_is_active()
64+
self._validate_antifraud()
65+
66+
return self._issue_promo_code()
67+
68+
def _validate_targeting(self):
69+
"""Checks if the user matches the promotion's targeting settings."""
70+
target = self.promo.target
71+
72+
user_age = self.user.other.get('age')
73+
user_country = (
74+
self.user.other.get('country', '').lower()
75+
if self.user.other.get('country')
76+
else None
77+
)
78+
79+
if target.get('country') and user_country != target['country'].lower():
80+
raise TargetingError('Country mismatch.')
81+
82+
if target.get('age_from') and (
83+
user_age is None or user_age < target['age_from']
84+
):
85+
raise TargetingError('Age mismatch.')
86+
87+
if target.get('age_until') and (
88+
user_age is None or user_age > target['age_until']
89+
):
90+
raise TargetingError('Age mismatch.')
91+
92+
def _validate_is_active(self):
93+
"""Checks if the promo is active and codes are available."""
94+
95+
if not self.promo.active or not self.promo.is_active:
96+
raise PromoInactiveError()
97+
98+
def _validate_antifraud(self):
99+
"""Sends a request to the anti-fraud system."""
100+
antifraud_response = (
101+
user.antifraud_service.antifraud_service.get_verdict(
102+
self.user.email,
103+
str(self.promo.id),
104+
)
105+
)
106+
if not antifraud_response.get('ok'):
107+
raise AntiFraudError()
108+
109+
def _issue_promo_code(self) -> str:
110+
"""
111+
Issues a promo code in an atomic transaction, updates counters,
112+
and creates a record in the history.
113+
"""
114+
try:
115+
with django.db.transaction.atomic():
116+
promo_locked = (
117+
business.models.Promo.objects.select_for_update().get(
118+
id=self.promo.id,
119+
)
120+
)
121+
promo_code_value = None
122+
123+
if promo_locked.mode == business.constants.PROMO_MODE_COMMON:
124+
if promo_locked.used_count < promo_locked.max_count:
125+
promo_locked.used_count = (
126+
django.db.models.F('used_count') + 1
127+
)
128+
promo_locked.save(update_fields=['used_count'])
129+
promo_code_value = promo_locked.promo_common
130+
else:
131+
raise PromoUnavailableError()
132+
133+
elif promo_locked.mode == business.constants.PROMO_MODE_UNIQUE:
134+
unique_code = promo_locked.unique_codes.filter(
135+
is_used=False,
136+
).first()
137+
if unique_code:
138+
unique_code.is_used = True
139+
unique_code.used_at = django.utils.timezone.now()
140+
unique_code.save(update_fields=['is_used', 'used_at'])
141+
promo_code_value = unique_code.code
142+
else:
143+
raise PromoUnavailableError()
144+
145+
if promo_code_value:
146+
user.models.PromoActivationHistory.objects.create(
147+
user=self.user,
148+
promo=promo_locked,
149+
)
150+
return promo_code_value
151+
152+
raise PromoActivationError('Invalid promotion type.')
153+
154+
except business.models.Promo.DoesNotExist:
155+
raise PromoActivationError('Promo not found.')

promo_code/user/views.py

Lines changed: 22 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import user.models
1515
import user.permissions
1616
import user.serializers
17+
import user.services
1718

1819

1920
class UserSignUpView(
@@ -296,138 +297,38 @@ def destroy(self, request, *args, **kwargs):
296297

297298

298299
class PromoActivateView(rest_framework.views.APIView):
299-
permission_classes = [rest_framework.permissions.IsAuthenticated]
300-
allowed_methods = ['post', 'options', 'head']
301-
302-
def _validate_targeting(self, user_, promo):
303-
user_age = user_.other.get('age')
304-
user_country = user_.other.get('country').lower()
305-
target = promo.target
306-
307-
if not target:
308-
return None
309-
310-
if target.get('country') and user_country != target['country'].lower():
311-
return rest_framework.response.Response(
312-
{'error': 'Targeting mismatch: country.'},
313-
status=rest_framework.status.HTTP_403_FORBIDDEN,
314-
)
315-
if target.get('age_from') and (
316-
user_age is None or user_age < target['age_from']
317-
):
318-
return rest_framework.response.Response(
319-
{'error': 'Targeting mismatch: age.'},
320-
status=rest_framework.status.HTTP_403_FORBIDDEN,
321-
)
322-
if target.get('age_until') and (
323-
user_age is None or user_age > target['age_until']
324-
):
325-
return rest_framework.response.Response(
326-
{'error': 'Targeting mismatch: age.'},
327-
status=rest_framework.status.HTTP_403_FORBIDDEN,
328-
)
329-
return None
330-
331-
def _validate_is_active(self, promo):
332-
if not promo.active or not promo.is_active:
333-
return rest_framework.response.Response(
334-
{'error': 'Promo is not active.'},
335-
status=rest_framework.status.HTTP_403_FORBIDDEN,
336-
)
337-
return None
338-
339-
def _validate_antifraud(self, user_, promo):
340-
antifraud_response = (
341-
user.antifraud_service.antifraud_service.get_verdict(
342-
user_.email,
343-
str(promo.id),
344-
)
345-
)
346-
if not antifraud_response.get('ok'):
347-
return rest_framework.response.Response(
348-
{'error': 'Activation forbidden by anti-fraud system.'},
349-
status=rest_framework.status.HTTP_403_FORBIDDEN,
350-
)
351-
return None
300+
"""
301+
Activates a promo code for the user.
302+
All business logic is encapsulated in PromoActivationService.
303+
"""
352304

353-
def _activate_code(self, user_, promo):
354-
try:
355-
with django.db.transaction.atomic():
356-
promo_for_update = (
357-
business.models.Promo.objects.select_for_update().get(
358-
id=promo.id,
359-
)
360-
)
361-
promo_code_value = None
362-
363-
if (
364-
promo_for_update.mode
365-
== business.constants.PROMO_MODE_COMMON
366-
):
367-
if (
368-
promo_for_update.used_count
369-
< promo_for_update.max_count
370-
):
371-
promo_for_update.used_count += 1
372-
promo_for_update.save(update_fields=['used_count'])
373-
promo_code_value = promo_for_update.promo_common
374-
else:
375-
raise ValueError('No common codes left.')
376-
377-
elif (
378-
promo_for_update.mode
379-
== business.constants.PROMO_MODE_UNIQUE
380-
):
381-
unique_code = promo_for_update.unique_codes.filter(
382-
is_used=False,
383-
).first()
384-
if unique_code:
385-
unique_code.is_used = True
386-
unique_code.used_at = django.utils.timezone.now()
387-
unique_code.save(update_fields=['is_used', 'used_at'])
388-
promo_code_value = unique_code.code
389-
else:
390-
raise ValueError('No unique codes left.')
391-
392-
if promo_code_value:
393-
user.models.PromoActivationHistory.objects.create(
394-
user=user_,
395-
promo=promo,
396-
)
397-
serializer = user.serializers.PromoActivationSerializer(
398-
data={'promo': promo_code_value},
399-
)
400-
serializer.is_valid(raise_exception=True)
401-
return rest_framework.response.Response(
402-
serializer.data,
403-
status=rest_framework.status.HTTP_200_OK,
404-
)
405-
406-
raise ValueError('Promo code could not be activated.')
407-
408-
except ValueError as e:
409-
return rest_framework.response.Response(
410-
{'error': str(e)},
411-
status=rest_framework.status.HTTP_403_FORBIDDEN,
412-
)
305+
permission_classes = [rest_framework.permissions.IsAuthenticated]
413306

414307
def post(self, request, id):
415308
promo = django.shortcuts.get_object_or_404(
416309
business.models.Promo,
417310
id=id,
418311
)
419-
user_ = request.user
420312

421-
if (response := self._validate_targeting(user_, promo)) is not None:
422-
return response
313+
service = user.services.PromoActivationService(
314+
user=request.user,
315+
promo=promo,
316+
)
423317

424-
if (response := self._validate_is_active(promo)) is not None:
425-
return response
318+
try:
319+
promo_code = service.activate()
426320

427-
if (response := self._validate_antifraud(user_, promo)) is not None:
428-
return response
321+
serializer = user.serializers.PromoActivationSerializer(
322+
data={'promo': promo_code},
323+
)
324+
serializer.is_valid(raise_exception=True)
325+
return rest_framework.response.Response(serializer.data)
429326

430-
return self._activate_code(user_, promo)
327+
except user.services.PromoActivationError as e:
328+
return rest_framework.response.Response(
329+
{'error': e.detail},
330+
status=e.status_code,
331+
)
431332

432333

433334
class PromoHistoryView(rest_framework.generics.ListAPIView):

0 commit comments

Comments
 (0)