Skip to content

Commit 6a39a5c

Browse files
refactor(feed): Simplify promo feed logic and related views
This commit refactors the promo feed generation and comment handling to improve code structure, efficiency, and maintainability. Key changes: - Moved the complex feed generation logic from `UserFeedView` into a new `PromoManager.get_feed_for_user` method. This centralizes business logic in the manager layer, simplifying the view and making the query logic reusable. - The new manager method encapsulates filtering for active promos and targeting by user age and country. - Optimized category filtering to use a direct `__icontains` database query, which is more efficient than the previous Python-based filtering. - Introduced a `PromoObjectMixin` to reduce code duplication in comment-related views (`PromoCommentListCreateView`, `PromoCommentDetailView`) by abstracting the common task of retrieving the associated promo object.
1 parent c018132 commit 6a39a5c

File tree

2 files changed

+139
-151
lines changed

2 files changed

+139
-151
lines changed

promo_code/business/managers.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import django.contrib.auth.models
22
import django.db.models
3+
import django.utils.timezone
34

45
import business.constants
56
import business.models
@@ -52,6 +53,98 @@ def with_related(self):
5253
def for_company(self, user):
5354
return self.with_related().filter(company=user)
5455

56+
def get_feed_for_user(
57+
self, user, active_filter=None, user_country=None, user_age=None,
58+
):
59+
"""
60+
Retrieve a queryset of Promo objects for a given user, filtered
61+
and ordered according to specified criteria.
62+
"""
63+
today = django.utils.timezone.now().date()
64+
65+
qs = (
66+
self.get_queryset()
67+
.select_related('company')
68+
.annotate(_has_unique_codes=self._q_has_unique_codes())
69+
.filter(self._q_is_targeted(user_country, user_age))
70+
)
71+
72+
if active_filter is not None:
73+
is_active = active_filter.lower() == 'true'
74+
active_q = self._q_is_active(today)
75+
qs = qs.filter(active_q) if is_active else qs.exclude(active_q)
76+
77+
return qs.order_by('-created_at')
78+
79+
def _q_is_active(self, today):
80+
"""
81+
Build a Q expression that checks whether a promo
82+
is active on the given date.
83+
"""
84+
85+
qt = django.db.models.Q(active_from__lte=today) | django.db.models.Q(
86+
active_from__isnull=True,
87+
)
88+
tu = django.db.models.Q(active_until__gte=today) | django.db.models.Q(
89+
active_until__isnull=True,
90+
)
91+
92+
common = django.db.models.Q(
93+
mode=business.constants.PROMO_MODE_COMMON,
94+
used_count__lt=django.db.models.F('max_count'),
95+
)
96+
unique = django.db.models.Q(
97+
mode=business.constants.PROMO_MODE_UNIQUE, _has_unique_codes=True,
98+
)
99+
100+
return qt & tu & (common | unique)
101+
102+
def _q_has_unique_codes(self):
103+
"""
104+
Annotate whether there are unused unique codes remaining
105+
for each promo.
106+
"""
107+
subq = business.models.PromoCode.objects.filter(
108+
promo=django.db.models.OuterRef('pk'), is_used=False,
109+
)
110+
return django.db.models.Exists(subq)
111+
112+
def _q_is_targeted(self, country, age):
113+
"""
114+
Build a Q expression that checks whether a promo targets the given
115+
country and age, or is not targeted.
116+
"""
117+
empty = django.db.models.Q(target={})
118+
119+
if country:
120+
match_country = django.db.models.Q(target__country__iexact=country)
121+
else:
122+
match_country = django.db.models.Q()
123+
no_country = ~django.db.models.Q(
124+
target__has_key='country',
125+
) | django.db.models.Q(target__country__isnull=True)
126+
country_ok = match_country | no_country
127+
128+
no_age_limits = ~django.db.models.Q(
129+
target__has_key='age_from',
130+
) & ~django.db.models.Q(target__has_key='age_until')
131+
if age is None:
132+
age_ok = no_age_limits
133+
else:
134+
from_ok = (
135+
~django.db.models.Q(target__has_key='age_from')
136+
| django.db.models.Q(target__age_from__isnull=True)
137+
| django.db.models.Q(target__age_from__lte=age)
138+
)
139+
until_ok = (
140+
~django.db.models.Q(target__has_key='age_until')
141+
| django.db.models.Q(target__age_until__isnull=True)
142+
| django.db.models.Q(target__age_until__gte=age)
143+
)
144+
age_ok = no_age_limits | (from_ok & until_ok)
145+
146+
return empty | (country_ok & age_ok)
147+
55148
@django.db.transaction.atomic
56149
def create_promo(
57150
self,

promo_code/user/views.py

Lines changed: 46 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import django.db.models
22
import django.db.transaction
33
import django.shortcuts
4-
import django.utils.timezone
5-
import rest_framework.exceptions
64
import rest_framework.generics
75
import rest_framework.permissions
86
import rest_framework.response
@@ -11,10 +9,8 @@
119
import rest_framework_simplejwt.tokens
1210
import rest_framework_simplejwt.views
1311

14-
import business.constants
1512
import business.models
1613
import core.pagination
17-
import user.antifraud_service
1814
import user.models
1915
import user.permissions
2016
import user.serializers
@@ -76,25 +72,19 @@ class UserPromoDetailView(rest_framework.generics.RetrieveAPIView):
7672
Retrieve (GET) information about the promo without receiving a promo code.
7773
"""
7874

79-
queryset = (
80-
business.models.Promo.objects.select_related('company')
81-
.prefetch_related(
82-
'unique_codes',
83-
)
84-
.only(
85-
'id',
86-
'company__id',
87-
'company__name',
88-
'description',
89-
'image_url',
90-
'active',
91-
'active_from',
92-
'active_until',
93-
'mode',
94-
'used_count',
95-
'like_count',
96-
'comment_count',
97-
)
75+
queryset = business.models.Promo.objects.select_related('company').only(
76+
'id',
77+
'company__id',
78+
'company__name',
79+
'description',
80+
'image_url',
81+
'active',
82+
'active_from',
83+
'active_until',
84+
'mode',
85+
'used_count',
86+
'like_count',
87+
'comment_count',
9888
)
9989

10090
serializer_class = user.serializers.UserPromoDetailSerializer
@@ -113,116 +103,26 @@ class UserFeedView(rest_framework.generics.ListAPIView):
113103

114104
def get_queryset(self):
115105
user = self.request.user
116-
user_age = user.other.get('age')
117-
user_country_raw = user.other.get('country')
118-
user_country = user_country_raw.lower() if user_country_raw else None
119-
120-
queryset = business.models.Promo.objects.select_related('company')
121-
122-
today_utc = django.utils.timezone.now().date()
123-
124-
q_active_time = (
125-
django.db.models.Q(active_from__lte=today_utc)
126-
| django.db.models.Q(active_from__isnull=True)
127-
) & (
128-
django.db.models.Q(active_until__gte=today_utc)
129-
| django.db.models.Q(active_until__isnull=True)
130-
)
131-
132-
q_common_active = django.db.models.Q(
133-
mode=business.constants.PROMO_MODE_COMMON,
134-
used_count__lt=django.db.models.F('max_count'),
135-
)
136-
137-
has_available_unique_codes = business.models.PromoCode.objects.filter(
138-
promo=django.db.models.OuterRef('pk'),
139-
is_used=False,
140-
)
141-
142-
queryset = queryset.annotate(
143-
_has_available_unique_codes=django.db.models.Exists(
144-
has_available_unique_codes,
145-
),
146-
)
147-
q_unique_active = django.db.models.Q(
148-
mode=business.constants.PROMO_MODE_UNIQUE,
149-
_has_available_unique_codes=True,
150-
)
151-
152-
q_is_active_by_rules = q_active_time & (
153-
q_common_active | q_unique_active
154-
)
155-
156-
q_target_empty = django.db.models.Q(target={})
157-
158-
q_country_target_matches = django.db.models.Q()
159-
if user_country:
160-
q_country_target_matches = django.db.models.Q(
161-
target__country__iexact=user_country,
162-
)
163-
164-
q_country_target_not_set_or_empty = ~django.db.models.Q(
165-
target__has_key='country',
166-
) | django.db.models.Q(target__country__isnull=True)
167-
q_user_meets_country_target = (
168-
q_country_target_matches | q_country_target_not_set_or_empty
169-
)
170106

171-
q_age_target_not_set = ~django.db.models.Q(
172-
target__has_key='age_from',
173-
) & ~django.db.models.Q(target__has_key='age_until')
174-
q_user_meets_age_target = q_age_target_not_set
175-
176-
if user_age is not None:
177-
q_age_from_ok = (
178-
~django.db.models.Q(target__has_key='age_from')
179-
| django.db.models.Q(target__age_from__isnull=True)
180-
| django.db.models.Q(target__age_from__lte=user_age)
181-
)
182-
q_age_until_ok = (
183-
~django.db.models.Q(target__has_key='age_until')
184-
| django.db.models.Q(target__age_until__isnull=True)
185-
| django.db.models.Q(target__age_until__gte=user_age)
186-
)
187-
q_user_age_in_defined_range = q_age_from_ok & q_age_until_ok
188-
q_user_meets_age_target = (
189-
q_age_target_not_set | q_user_age_in_defined_range
190-
)
191-
192-
q_user_is_targeted = q_target_empty | (
193-
q_user_meets_country_target & q_user_meets_age_target
107+
user_age = user.other.get('age')
108+
user_country = user.other.get('country').lower()
109+
active_filter = self.request.query_params.get('active')
110+
111+
return business.models.Promo.objects.get_feed_for_user(
112+
user,
113+
active_filter=active_filter,
114+
user_country=user_country,
115+
user_age=user_age,
194116
)
195117

196-
queryset = queryset.filter(q_user_is_targeted)
197-
198-
active_param_str = self.request.query_params.get('active')
199-
if active_param_str is not None:
200-
active_param_bool = active_param_str.lower() == 'true'
201-
if active_param_bool:
202-
queryset = queryset.filter(q_is_active_by_rules)
203-
else:
204-
queryset = queryset.exclude(q_is_active_by_rules)
205-
206-
return queryset.order_by('-created_at')
207-
208118
def filter_queryset(self, queryset):
209119
queryset = super().filter_queryset(queryset)
210-
211120
category_param = self.request.query_params.get('category')
121+
212122
if category_param:
213-
category_param = category_param.lower()
214-
if category_param:
215-
filtered_pks = []
216-
for promo in queryset:
217-
target_categories = promo.target.get('categories')
218-
if not isinstance(target_categories, list):
219-
continue
220-
if any(
221-
cat_name.lower() == category_param
222-
for cat_name in target_categories
223-
):
224-
filtered_pks.append(promo.pk)
225-
queryset = queryset.filter(pk__in=filtered_pks)
123+
needle = f'"{category_param.lower()}"'
124+
queryset = queryset.filter(target__categories__icontains=needle)
125+
226126
return queryset
227127

228128
def list(self, request, *args, **kwargs):
@@ -288,7 +188,20 @@ def delete(self, request, id):
288188
)
289189

290190

291-
class PromoCommentListCreateView(rest_framework.generics.ListCreateAPIView):
191+
class PromoObjectMixin:
192+
"""Mixin for retrieving the Promo object and saving it to self.promo"""
193+
194+
def dispatch(self, request, *args, **kwargs):
195+
self.promo = django.shortcuts.get_object_or_404(
196+
business.models.Promo.objects.select_for_update(),
197+
pk=self.kwargs.get('promo_id'),
198+
)
199+
return super().dispatch(request, *args, **kwargs)
200+
201+
202+
class PromoCommentListCreateView(
203+
PromoObjectMixin, rest_framework.generics.ListCreateAPIView,
204+
):
292205
permission_classes = [rest_framework.permissions.IsAuthenticated]
293206

294207
pagination_class = core.pagination.CustomLimitOffsetPagination
@@ -299,28 +212,14 @@ def get_serializer_class(self):
299212
return user.serializers.CommentSerializer
300213

301214
def get_queryset(self):
302-
promo_id = self.kwargs.get('promo_id')
303-
try:
304-
promo = business.models.Promo.objects.get(pk=promo_id)
305-
except business.models.Promo.DoesNotExist:
306-
raise rest_framework.exceptions.NotFound(detail='Promo not found.')
307-
308215
return user.models.PromoComment.objects.filter(
309-
promo=promo,
216+
promo=self.promo,
310217
).select_related('author')
311218

312219
def perform_create(self, serializer):
313-
promo_id = self.kwargs.get('promo_id')
314-
try:
315-
promo = business.models.Promo.objects.get(pk=promo_id)
316-
except business.models.Promo.DoesNotExist:
317-
raise rest_framework.exceptions.ValidationError(
318-
{'promo_id': 'Promo not found.'},
319-
)
320-
321-
serializer.save(author=self.request.user, promo=promo)
322-
promo.comment_count = django.db.models.F('comment_count') + 1
323-
promo.save(update_fields=['comment_count'])
220+
serializer.save(author=self.request.user, promo=self.promo)
221+
self.promo.comment_count = django.db.models.F('comment_count') + 1
222+
self.promo.save(update_fields=['comment_count'])
324223

325224
def create(self, request, *args, **kwargs):
326225
create_serializer = self.get_serializer(data=request.data)
@@ -346,6 +245,7 @@ def list(self, request, *args, **kwargs):
346245

347246

348247
class PromoCommentDetailView(
248+
PromoObjectMixin,
349249
rest_framework.generics.RetrieveUpdateDestroyAPIView,
350250
):
351251
permission_classes = [
@@ -362,13 +262,8 @@ def get_serializer_class(self):
362262
return user.serializers.CommentSerializer
363263

364264
def get_queryset(self):
365-
promo_id = self.kwargs.get('promo_id')
366-
try:
367-
promo = business.models.Promo.objects.get(pk=promo_id)
368-
except business.models.Promo.DoesNotExist:
369-
raise rest_framework.exceptions.NotFound(detail='Promo not found.')
370265
return user.models.PromoComment.objects.filter(
371-
promo=promo,
266+
promo=self.promo,
372267
).select_related('author')
373268

374269
def update(self, request, *args, **kwargs):

0 commit comments

Comments
 (0)