Skip to content

Commit 510c484

Browse files
feat: Implement user promo feed endpoint (/api/user/feed)
This commit introduces the `GET /api/user/feed` endpoint, enabling authenticated users to retrieve a personalized and paginated feed of promo. Key features include: - User-specific promo targeting based on their age and country attributes against promo targeting criteria (age_from, age_until, country). - Dynamic calculation of a promo's 'active' status, considering: - Validity period (`active_from`, `active_until`) relative to current date (UTC). - Usage counts (`used_count` vs `max_count`) for 'COMMON' type promos. - Availability of unactivated codes for 'UNIQUE' type promos. - Filtering capabilities via query parameters: - `active` (boolean): Filters promos based on their dynamic active status. - `category` (string): Allows case-insensitive filtering by promo category. - Standard limit/offset pagination, including an `X-Total-Count` header that reflects the total number of promos after all filters are applied. - Serialized promo data provides essential information (e.g., ID, company details, description, image URL) while intentionally omitting the actual promo code values to prevent premature activation. - Promos in the feed are sorted by their creation date (`created_at`) in descending order by default.
1 parent 3e3a843 commit 510c484

File tree

10 files changed

+330
-15
lines changed

10 files changed

+330
-15
lines changed

promo_code/business/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
# === Promo Common ===
2424
PROMO_COMMON_CODE_MIN_LENGTH = 5
2525
PROMO_COMMON_CODE_MAX_LENGTH = 30
26-
PROMO_COMMON_MIN_COUNT = 1
26+
PROMO_COMMON_MIN_COUNT = 0
2727
PROMO_COMMON_MAX_COUNT = 100_000_000
2828

2929

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 5.2 on 2025-05-03 00:17
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("business", "0001_initial"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="promo",
15+
name="used_count",
16+
field=models.PositiveIntegerField(default=0, editable=False),
17+
),
18+
migrations.AlterField(
19+
model_name="company",
20+
name="token_version",
21+
field=models.PositiveIntegerField(default=0),
22+
),
23+
migrations.AlterField(
24+
model_name="promo",
25+
name="max_count",
26+
field=models.PositiveIntegerField(),
27+
),
28+
]

promo_code/business/models.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class Company(django.contrib.auth.models.AbstractBaseUser):
2323
max_length=business.constants.COMPANY_NAME_MAX_LENGTH,
2424
)
2525

26-
token_version = django.db.models.IntegerField(default=0)
26+
token_version = django.db.models.PositiveIntegerField(default=0)
2727
created_at = django.db.models.DateTimeField(auto_now_add=True)
2828
is_active = django.db.models.BooleanField(default=True)
2929

@@ -59,7 +59,11 @@ class Promo(django.db.models.Model):
5959
null=True,
6060
)
6161
target = django.db.models.JSONField(default=dict)
62-
max_count = django.db.models.IntegerField()
62+
max_count = django.db.models.PositiveIntegerField()
63+
used_count = django.db.models.PositiveIntegerField(
64+
default=0,
65+
editable=False,
66+
)
6367
active_from = django.db.models.DateField(null=True, blank=True)
6468
active_until = django.db.models.DateField(null=True, blank=True)
6569
mode = django.db.models.CharField(
@@ -82,15 +86,17 @@ def __str__(self):
8286

8387
@property
8488
def is_active(self) -> bool:
85-
today = django.utils.timezone.timezone.now().date()
89+
today = django.utils.timezone.now().date()
8690
if self.active_from and self.active_from > today:
8791
return False
8892
if self.active_until and self.active_until < today:
8993
return False
9094

9195
if self.mode == business.constants.PROMO_MODE_UNIQUE:
9296
return self.unique_codes.filter(is_used=False).exists()
93-
# TODO: COMMON Promo
97+
if self.mode == business.constants.PROMO_MODE_COMMON:
98+
return self.used_count < self.max_count
99+
94100
return True
95101

96102
@property

promo_code/business/serializers.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,10 @@ class PromoDetailSerializer(rest_framework.serializers.ModelSerializer):
481481
source='get_used_codes_count',
482482
read_only=True,
483483
)
484+
active = rest_framework.serializers.BooleanField(
485+
source='is_active',
486+
read_only=True,
487+
)
484488

485489
class Meta:
486490
model = business.models.Promo
@@ -496,6 +500,7 @@ class Meta:
496500
'promo_common',
497501
'promo_unique',
498502
'company_name',
503+
'active',
499504
'like_count',
500505
'used_count',
501506
)

promo_code/business/validators.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,6 @@ def validate(self):
5656
promo_common = self.full_data.get('promo_common')
5757
promo_unique = self.full_data.get('promo_unique')
5858
max_count = self.full_data.get('max_count')
59-
active_from = self.full_data.get('active_from')
60-
active_until = self.full_data.get('active_until')
6159

6260
if mode not in [
6361
business.constants.PROMO_MODE_COMMON,
@@ -86,7 +84,7 @@ def validate(self):
8684
)
8785
if max_count is None or not (
8886
business.constants.PROMO_COMMON_MIN_COUNT
89-
< max_count
87+
<= max_count
9088
<= business.constants.PROMO_COMMON_MAX_COUNT
9189
):
9290
raise rest_framework.exceptions.ValidationError(
@@ -112,9 +110,4 @@ def validate(self):
112110
{'max_count': 'Must be 1 for UNIQUE mode.'},
113111
)
114112

115-
if active_from and active_until and active_from > active_until:
116-
raise rest_framework.exceptions.ValidationError(
117-
{'active_until': 'Must be after or equal to active_from.'},
118-
)
119-
120113
return self.full_data

promo_code/user/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,6 @@
1616
EMAIL_MAX_LENGTH = 120
1717

1818
AVATAR_URL_MAX_LENGTH = 350
19+
20+
TARGET_CATEGORY_MIN_LENGTH = 2
21+
TARGET_CATEGORY_MAX_LENGTH = 20

promo_code/user/pagination.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import rest_framework.pagination
2+
import rest_framework.response
3+
4+
5+
class UserFeedPagination(rest_framework.pagination.LimitOffsetPagination):
6+
default_limit = 10
7+
max_limit = 100
8+
9+
def get_limit(self, request):
10+
raw_limit = request.query_params.get(self.limit_query_param)
11+
12+
if raw_limit is None:
13+
return self.default_limit
14+
15+
limit = int(raw_limit)
16+
17+
# Allow 0, otherwise cut by max_limit
18+
return 0 if limit == 0 else min(limit, self.max_limit)
19+
20+
def get_paginated_response(self, data):
21+
return rest_framework.response.Response(
22+
data,
23+
headers={'X-Total-Count': str(self.count)},
24+
)

promo_code/user/serializers.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import rest_framework_simplejwt.token_blacklist.models as tb_models
1010
import rest_framework_simplejwt.tokens
1111

12+
import business.constants
1213
import business.models
1314
import user.constants
1415
import user.models
@@ -247,6 +248,124 @@ def to_representation(self, instance):
247248
return data
248249

249250

251+
class UserFeedQuerySerializer(rest_framework.serializers.Serializer):
252+
"""
253+
Serializer for validating query parameters of promo feed requests.
254+
"""
255+
256+
limit = rest_framework.serializers.CharField(
257+
required=False,
258+
allow_blank=True,
259+
)
260+
offset = rest_framework.serializers.CharField(
261+
required=False,
262+
allow_blank=True,
263+
)
264+
category = rest_framework.serializers.CharField(
265+
min_length=business.constants.TARGET_CATEGORY_MIN_LENGTH,
266+
max_length=business.constants.TARGET_CATEGORY_MAX_LENGTH,
267+
required=False,
268+
allow_blank=True,
269+
)
270+
active = rest_framework.serializers.BooleanField(
271+
required=False,
272+
allow_null=True,
273+
)
274+
275+
_allowed_params = None
276+
277+
def get_allowed_params(self):
278+
if self._allowed_params is None:
279+
self._allowed_params = set(self.fields.keys())
280+
return self._allowed_params
281+
282+
def validate(self, attrs):
283+
query_params = self.initial_data
284+
allowed_params = self.get_allowed_params()
285+
286+
unexpected_params = set(query_params.keys()) - allowed_params
287+
if unexpected_params:
288+
raise rest_framework.exceptions.ValidationError('Invalid params.')
289+
290+
field_errors = {}
291+
292+
attrs = self._validate_int_field('limit', attrs, field_errors)
293+
attrs = self._validate_int_field('offset', attrs, field_errors)
294+
295+
if field_errors:
296+
raise rest_framework.exceptions.ValidationError(field_errors)
297+
298+
return attrs
299+
300+
def _validate_int_field(self, field_name, attrs, field_errors):
301+
value_str = self.initial_data.get(field_name)
302+
if value_str is None:
303+
return attrs
304+
305+
if value_str == '':
306+
raise rest_framework.exceptions.ValidationError(
307+
f'Invalid {field_name} format.',
308+
)
309+
310+
try:
311+
value_int = int(value_str)
312+
if value_int < 0:
313+
raise rest_framework.exceptions.ValidationError(
314+
f'{field_name.capitalize()} cannot be negative.',
315+
)
316+
attrs[field_name] = value_int
317+
except (ValueError, TypeError):
318+
raise rest_framework.exceptions.ValidationError(
319+
f'Invalid {field_name} format.',
320+
)
321+
322+
return attrs
323+
324+
325+
class PromoFeedSerializer(rest_framework.serializers.ModelSerializer):
326+
promo_id = rest_framework.serializers.UUIDField(source='id')
327+
company_id = rest_framework.serializers.UUIDField(source='company.id')
328+
company_name = rest_framework.serializers.CharField(source='company.name')
329+
active = rest_framework.serializers.BooleanField(source='is_active')
330+
is_activated_by_user = rest_framework.serializers.SerializerMethodField()
331+
is_liked_by_user = rest_framework.serializers.SerializerMethodField()
332+
like_count = rest_framework.serializers.SerializerMethodField()
333+
comment_count = rest_framework.serializers.SerializerMethodField()
334+
335+
class Meta:
336+
model = business.models.Promo
337+
fields = [
338+
'promo_id',
339+
'company_id',
340+
'company_name',
341+
'description',
342+
'image_url',
343+
'active',
344+
'is_activated_by_user',
345+
'like_count',
346+
'is_liked_by_user',
347+
'comment_count',
348+
]
349+
350+
read_only_fields = fields
351+
352+
def get_is_activated_by_user(self, obj) -> bool:
353+
# TODO:
354+
return False
355+
356+
def get_like_count(self, obj) -> int:
357+
# TODO:
358+
return 0
359+
360+
def get_is_liked_by_user(self, obj) -> bool:
361+
# TODO:
362+
return False
363+
364+
def get_comment_count(self, obj) -> int:
365+
# TODO:
366+
return 0
367+
368+
250369
class UserPromoDetailSerializer(rest_framework.serializers.ModelSerializer):
251370
"""
252371
Serializer for detailed promo-code information

promo_code/user/urls.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@
2828
name='user-profile',
2929
),
3030
django.urls.path(
31-
'promo/<uuid:id>/',
31+
'feed',
32+
user.views.UserFeedView.as_view(),
33+
name='user-feed',
34+
),
35+
django.urls.path(
36+
'promo/<uuid:id>',
3237
user.views.UserPromoDetailView.as_view(),
3338
name='user-promo-detail',
3439
),

0 commit comments

Comments
 (0)