Skip to content

Commit 1d49c7c

Browse files
feat: Implement promo creation endpoint with validations and company association.
- Add Promo and PromoCode models to handle COMMON/UNIQUE promo modes - Create PromoCreateSerializer with nested TargetSerializer for validation - Validate age ranges, country codes, promo mode consistency, and date logic - Handle company association via authenticated user (Company model) - Add PromoCreateView with POST method and IsAuthenticated and IsCompanyUser permissions
1 parent 5ad3fbf commit 1d49c7c

File tree

6 files changed

+388
-0
lines changed

6 files changed

+388
-0
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Generated by Django 5.2b1 on 2025-03-28 16:03
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('business', '0001_initial'),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name='Promo',
16+
fields=[
17+
(
18+
'id',
19+
models.BigAutoField(
20+
auto_created=True,
21+
primary_key=True,
22+
serialize=False,
23+
verbose_name='ID',
24+
),
25+
),
26+
('description', models.CharField(max_length=300)),
27+
(
28+
'image_url',
29+
models.URLField(blank=True, max_length=350, null=True),
30+
),
31+
('target', models.JSONField(default=dict)),
32+
('max_count', models.IntegerField()),
33+
('active_from', models.DateField(blank=True, null=True)),
34+
('active_until', models.DateField(blank=True, null=True)),
35+
(
36+
'mode',
37+
models.CharField(
38+
choices=[('COMMON', 'Common'), ('UNIQUE', 'Unique')],
39+
max_length=10,
40+
),
41+
),
42+
(
43+
'promo_common',
44+
models.CharField(blank=True, max_length=30, null=True),
45+
),
46+
('active', models.BooleanField(default=True)),
47+
('created_at', models.DateTimeField(auto_now_add=True)),
48+
(
49+
'company',
50+
models.ForeignKey(
51+
blank=True,
52+
null=True,
53+
on_delete=django.db.models.deletion.CASCADE,
54+
to='business.company',
55+
),
56+
),
57+
],
58+
),
59+
migrations.CreateModel(
60+
name='PromoCode',
61+
fields=[
62+
(
63+
'id',
64+
models.BigAutoField(
65+
auto_created=True,
66+
primary_key=True,
67+
serialize=False,
68+
verbose_name='ID',
69+
),
70+
),
71+
('code', models.CharField(max_length=30)),
72+
('is_used', models.BooleanField(default=False)),
73+
('used_at', models.DateTimeField(blank=True, null=True)),
74+
(
75+
'promo',
76+
models.ForeignKey(
77+
on_delete=django.db.models.deletion.CASCADE,
78+
related_name='unique_codes',
79+
to='business.promo',
80+
),
81+
),
82+
],
83+
options={
84+
'unique_together': {('promo', 'code')},
85+
},
86+
),
87+
]

promo_code/business/models.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,58 @@ class Company(django.contrib.auth.models.AbstractBaseUser):
3636

3737
def __str__(self):
3838
return self.name
39+
40+
41+
class Promo(django.db.models.Model):
42+
MODE_COMMON = 'COMMON'
43+
MODE_UNIQUE = 'UNIQUE'
44+
MODE_CHOICES = [
45+
(MODE_COMMON, 'Common'),
46+
(MODE_UNIQUE, 'Unique'),
47+
]
48+
49+
company = django.db.models.ForeignKey(
50+
Company,
51+
on_delete=django.db.models.CASCADE,
52+
null=True,
53+
blank=True,
54+
)
55+
description = django.db.models.CharField(max_length=300)
56+
image_url = django.db.models.URLField(
57+
max_length=350,
58+
blank=True,
59+
null=True,
60+
)
61+
target = django.db.models.JSONField(default=dict)
62+
max_count = django.db.models.IntegerField()
63+
active_from = django.db.models.DateField(null=True, blank=True)
64+
active_until = django.db.models.DateField(null=True, blank=True)
65+
mode = django.db.models.CharField(max_length=10, choices=MODE_CHOICES)
66+
promo_common = django.db.models.CharField(
67+
max_length=30,
68+
blank=True,
69+
null=True,
70+
)
71+
active = django.db.models.BooleanField(default=True)
72+
73+
created_at = django.db.models.DateTimeField(auto_now_add=True)
74+
75+
def __str__(self):
76+
return f'Promo {self.id} ({self.mode})'
77+
78+
79+
class PromoCode(django.db.models.Model):
80+
promo = django.db.models.ForeignKey(
81+
Promo,
82+
on_delete=django.db.models.CASCADE,
83+
related_name='unique_codes',
84+
)
85+
code = django.db.models.CharField(max_length=30)
86+
is_used = django.db.models.BooleanField(default=False)
87+
used_at = django.db.models.DateTimeField(null=True, blank=True)
88+
89+
class Meta:
90+
unique_together = ('promo', 'code')
91+
92+
def __str__(self):
93+
return self.code

promo_code/business/permissions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import business.models
2+
import rest_framework.permissions
3+
4+
5+
class IsCompanyUser(rest_framework.permissions.BasePermission):
6+
def has_permission(self, request, view):
7+
return isinstance(request.user, business.models.Company)

promo_code/business/serializers.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import django.contrib.auth.password_validation
44
import django.core.exceptions
55
import django.core.validators
6+
import pycountry
67
import rest_framework.exceptions
78
import rest_framework.serializers
89
import rest_framework.status
@@ -130,3 +131,209 @@ def validate(self, attrs):
130131
)
131132

132133
return super().validate(attrs)
134+
135+
136+
class TargetSerializer(rest_framework.serializers.Serializer):
137+
age_from = rest_framework.serializers.IntegerField(
138+
min_value=0,
139+
max_value=100,
140+
required=False,
141+
allow_null=True,
142+
)
143+
age_until = rest_framework.serializers.IntegerField(
144+
min_value=0,
145+
max_value=100,
146+
required=False,
147+
allow_null=True,
148+
)
149+
country = rest_framework.serializers.CharField(
150+
required=False,
151+
allow_null=True,
152+
allow_blank=True,
153+
)
154+
categories = rest_framework.serializers.ListField(
155+
child=rest_framework.serializers.CharField(
156+
min_length=2,
157+
max_length=20,
158+
),
159+
max_length=20,
160+
required=False,
161+
allow_empty=True,
162+
)
163+
164+
def validate(self, data):
165+
age_from = data.get('age_from')
166+
age_until = data.get('age_until')
167+
if (
168+
age_from is not None
169+
and age_until is not None
170+
and age_from > age_until
171+
):
172+
raise rest_framework.serializers.ValidationError(
173+
{'age_until': 'Must be greater than or equal to age_from.'},
174+
)
175+
176+
# change validation
177+
country = data.get('country')
178+
if country:
179+
country = country.strip().upper()
180+
try:
181+
pycountry.countries.lookup(country)
182+
data['country'] = country
183+
except LookupError:
184+
raise rest_framework.serializers.ValidationError(
185+
{'country': 'Invalid ISO 3166-1 alpha-2 country code.'},
186+
)
187+
188+
return data
189+
190+
191+
class PromoCreateSerializer(rest_framework.serializers.ModelSerializer):
192+
target = TargetSerializer(required=True)
193+
promo_common = rest_framework.serializers.CharField(
194+
min_length=5,
195+
max_length=30,
196+
required=False,
197+
allow_null=True,
198+
)
199+
promo_unique = rest_framework.serializers.ListField(
200+
child=rest_framework.serializers.CharField(
201+
min_length=3,
202+
max_length=30,
203+
),
204+
min_length=1,
205+
max_length=5000,
206+
required=False,
207+
allow_null=True,
208+
)
209+
210+
class Meta:
211+
model = business_models.Promo
212+
fields = (
213+
'description',
214+
'image_url',
215+
'target',
216+
'max_count',
217+
'active_from',
218+
'active_until',
219+
'mode',
220+
'promo_common',
221+
'promo_unique',
222+
)
223+
extra_kwargs = {
224+
'description': {'min_length': 10, 'max_length': 300},
225+
'image_url': {'max_length': 350},
226+
}
227+
228+
def validate(self, data):
229+
mode = data.get('mode')
230+
promo_common = data.get('promo_common')
231+
promo_unique = data.get('promo_unique')
232+
max_count = data.get('max_count')
233+
234+
if mode == business_models.Promo.MODE_COMMON:
235+
if not promo_common:
236+
raise rest_framework.serializers.ValidationError(
237+
{
238+
'promo_common': (
239+
'This field is required for COMMON mode.'
240+
),
241+
},
242+
)
243+
244+
if promo_unique is not None:
245+
raise rest_framework.serializers.ValidationError(
246+
{
247+
'promo_unique': (
248+
'This field is not allowed for COMMON mode.'
249+
),
250+
},
251+
)
252+
253+
if max_count < 0 or max_count > 100000000:
254+
raise rest_framework.serializers.ValidationError(
255+
{
256+
'max_count': (
257+
'Must be between 0 and 100,000,000 '
258+
'for COMMON mode.'
259+
),
260+
},
261+
)
262+
263+
elif mode == business_models.Promo.MODE_UNIQUE:
264+
if not promo_unique:
265+
raise rest_framework.serializers.ValidationError(
266+
{
267+
'promo_unique': (
268+
'This field is required for UNIQUE mode.'
269+
),
270+
},
271+
)
272+
273+
if promo_common is not None:
274+
raise rest_framework.serializers.ValidationError(
275+
{
276+
'promo_common': (
277+
'This field is not allowed for UNIQUE mode.'
278+
),
279+
},
280+
)
281+
282+
if max_count != 1:
283+
raise rest_framework.serializers.ValidationError(
284+
{'max_count': 'Must be 1 for UNIQUE mode.'},
285+
)
286+
287+
else:
288+
raise rest_framework.serializers.ValidationError(
289+
{'mode': 'Invalid mode.'},
290+
)
291+
292+
active_from = data.get('active_from')
293+
active_until = data.get('active_until')
294+
if active_from and active_until and active_from > active_until:
295+
raise rest_framework.serializers.ValidationError(
296+
{'active_until': 'Must be after or equal to active_from.'},
297+
)
298+
299+
return data
300+
301+
def create(self, validated_data):
302+
target_data = validated_data.pop('target')
303+
promo_common = validated_data.pop('promo_common', None)
304+
promo_unique = validated_data.pop('promo_unique', None)
305+
mode = validated_data['mode']
306+
307+
user = self.context['request'].user
308+
validated_data['company'] = user
309+
310+
promo = business_models.Promo.objects.create(
311+
**validated_data,
312+
target=target_data,
313+
)
314+
315+
if mode == business_models.Promo.MODE_COMMON:
316+
promo.promo_common = promo_common
317+
promo.save()
318+
elif mode == business_models.Promo.MODE_UNIQUE and promo_unique:
319+
promo_codes = [
320+
business_models.PromoCode(promo=promo, code=code)
321+
for code in promo_unique
322+
]
323+
business_models.PromoCode.objects.bulk_create(promo_codes)
324+
325+
return promo
326+
327+
def to_representation(self, instance):
328+
data = super().to_representation(instance)
329+
data['target'] = instance.target
330+
331+
if instance.mode == business_models.Promo.MODE_UNIQUE:
332+
data['promo_unique'] = [
333+
code.code for code in instance.unique_codes.all()
334+
]
335+
data.pop('promo_common', None)
336+
else:
337+
data.pop('promo_unique', None)
338+
339+
return data

promo_code/business/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,9 @@
2020
business.views.CompanyTokenRefreshView.as_view(),
2121
name='company-token-refresh',
2222
),
23+
django.urls.path(
24+
'promo/create',
25+
business.views.PromoCreateView.as_view(),
26+
name='promo-create',
27+
),
2328
]

0 commit comments

Comments
 (0)