diff --git a/promo_code/business/migrations/0002_promo_promocode.py b/promo_code/business/migrations/0002_promo_promocode.py new file mode 100644 index 0000000..c256e44 --- /dev/null +++ b/promo_code/business/migrations/0002_promo_promocode.py @@ -0,0 +1,87 @@ +# Generated by Django 5.2b1 on 2025-03-28 16:03 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('business', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Promo', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ('description', models.CharField(max_length=300)), + ( + 'image_url', + models.URLField(blank=True, max_length=350, null=True), + ), + ('target', models.JSONField(default=dict)), + ('max_count', models.IntegerField()), + ('active_from', models.DateField(blank=True, null=True)), + ('active_until', models.DateField(blank=True, null=True)), + ( + 'mode', + models.CharField( + choices=[('COMMON', 'Common'), ('UNIQUE', 'Unique')], + max_length=10, + ), + ), + ( + 'promo_common', + models.CharField(blank=True, max_length=30, null=True), + ), + ('active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ( + 'company', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to='business.company', + ), + ), + ], + ), + migrations.CreateModel( + name='PromoCode', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ('code', models.CharField(max_length=30)), + ('is_used', models.BooleanField(default=False)), + ('used_at', models.DateTimeField(blank=True, null=True)), + ( + 'promo', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='unique_codes', + to='business.promo', + ), + ), + ], + options={ + 'unique_together': {('promo', 'code')}, + }, + ), + ] diff --git a/promo_code/business/models.py b/promo_code/business/models.py index 8e2219b..11af78e 100644 --- a/promo_code/business/models.py +++ b/promo_code/business/models.py @@ -36,3 +36,58 @@ class Company(django.contrib.auth.models.AbstractBaseUser): def __str__(self): return self.name + + +class Promo(django.db.models.Model): + MODE_COMMON = 'COMMON' + MODE_UNIQUE = 'UNIQUE' + MODE_CHOICES = [ + (MODE_COMMON, 'Common'), + (MODE_UNIQUE, 'Unique'), + ] + + company = django.db.models.ForeignKey( + Company, + on_delete=django.db.models.CASCADE, + null=True, + blank=True, + ) + description = django.db.models.CharField(max_length=300) + image_url = django.db.models.URLField( + max_length=350, + blank=True, + null=True, + ) + target = django.db.models.JSONField(default=dict) + max_count = django.db.models.IntegerField() + active_from = django.db.models.DateField(null=True, blank=True) + active_until = django.db.models.DateField(null=True, blank=True) + mode = django.db.models.CharField(max_length=10, choices=MODE_CHOICES) + promo_common = django.db.models.CharField( + max_length=30, + blank=True, + null=True, + ) + active = django.db.models.BooleanField(default=True) + + created_at = django.db.models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f'Promo {self.id} ({self.mode})' + + +class PromoCode(django.db.models.Model): + promo = django.db.models.ForeignKey( + Promo, + on_delete=django.db.models.CASCADE, + related_name='unique_codes', + ) + code = django.db.models.CharField(max_length=30) + is_used = django.db.models.BooleanField(default=False) + used_at = django.db.models.DateTimeField(null=True, blank=True) + + class Meta: + unique_together = ('promo', 'code') + + def __str__(self): + return self.code diff --git a/promo_code/business/permissions.py b/promo_code/business/permissions.py new file mode 100644 index 0000000..bc5519b --- /dev/null +++ b/promo_code/business/permissions.py @@ -0,0 +1,7 @@ +import business.models +import rest_framework.permissions + + +class IsCompanyUser(rest_framework.permissions.BasePermission): + def has_permission(self, request, view): + return isinstance(request.user, business.models.Company) diff --git a/promo_code/business/serializers.py b/promo_code/business/serializers.py index 6b2ae0f..7f3af8d 100644 --- a/promo_code/business/serializers.py +++ b/promo_code/business/serializers.py @@ -3,6 +3,7 @@ import django.contrib.auth.password_validation import django.core.exceptions import django.core.validators +import pycountry import rest_framework.exceptions import rest_framework.serializers import rest_framework.status @@ -130,3 +131,209 @@ def validate(self, attrs): ) return super().validate(attrs) + + +class TargetSerializer(rest_framework.serializers.Serializer): + age_from = rest_framework.serializers.IntegerField( + min_value=0, + max_value=100, + required=False, + allow_null=True, + ) + age_until = rest_framework.serializers.IntegerField( + min_value=0, + max_value=100, + required=False, + allow_null=True, + ) + country = rest_framework.serializers.CharField( + required=False, + allow_null=True, + allow_blank=True, + ) + categories = rest_framework.serializers.ListField( + child=rest_framework.serializers.CharField( + min_length=2, + max_length=20, + ), + max_length=20, + required=False, + allow_empty=True, + ) + + def validate(self, data): + age_from = data.get('age_from') + age_until = data.get('age_until') + if ( + age_from is not None + and age_until is not None + and age_from > age_until + ): + raise rest_framework.serializers.ValidationError( + {'age_until': 'Must be greater than or equal to age_from.'}, + ) + + # change validation + country = data.get('country') + if country: + country = country.strip().upper() + try: + pycountry.countries.lookup(country) + data['country'] = country + except LookupError: + raise rest_framework.serializers.ValidationError( + {'country': 'Invalid ISO 3166-1 alpha-2 country code.'}, + ) + + return data + + +class PromoCreateSerializer(rest_framework.serializers.ModelSerializer): + target = TargetSerializer(required=True) + promo_common = rest_framework.serializers.CharField( + min_length=5, + max_length=30, + required=False, + allow_null=True, + ) + promo_unique = rest_framework.serializers.ListField( + child=rest_framework.serializers.CharField( + min_length=3, + max_length=30, + ), + min_length=1, + max_length=5000, + required=False, + allow_null=True, + ) + + class Meta: + model = business_models.Promo + fields = ( + 'description', + 'image_url', + 'target', + 'max_count', + 'active_from', + 'active_until', + 'mode', + 'promo_common', + 'promo_unique', + ) + extra_kwargs = { + 'description': {'min_length': 10, 'max_length': 300}, + 'image_url': {'max_length': 350}, + } + + def validate(self, data): + mode = data.get('mode') + promo_common = data.get('promo_common') + promo_unique = data.get('promo_unique') + max_count = data.get('max_count') + + if mode == business_models.Promo.MODE_COMMON: + if not promo_common: + raise rest_framework.serializers.ValidationError( + { + 'promo_common': ( + 'This field is required for COMMON mode.' + ), + }, + ) + + if promo_unique is not None: + raise rest_framework.serializers.ValidationError( + { + 'promo_unique': ( + 'This field is not allowed for COMMON mode.' + ), + }, + ) + + if max_count < 0 or max_count > 100000000: + raise rest_framework.serializers.ValidationError( + { + 'max_count': ( + 'Must be between 0 and 100,000,000 ' + 'for COMMON mode.' + ), + }, + ) + + elif mode == business_models.Promo.MODE_UNIQUE: + if not promo_unique: + raise rest_framework.serializers.ValidationError( + { + 'promo_unique': ( + 'This field is required for UNIQUE mode.' + ), + }, + ) + + if promo_common is not None: + raise rest_framework.serializers.ValidationError( + { + 'promo_common': ( + 'This field is not allowed for UNIQUE mode.' + ), + }, + ) + + if max_count != 1: + raise rest_framework.serializers.ValidationError( + {'max_count': 'Must be 1 for UNIQUE mode.'}, + ) + + else: + raise rest_framework.serializers.ValidationError( + {'mode': 'Invalid mode.'}, + ) + + active_from = data.get('active_from') + active_until = data.get('active_until') + if active_from and active_until and active_from > active_until: + raise rest_framework.serializers.ValidationError( + {'active_until': 'Must be after or equal to active_from.'}, + ) + + return data + + def create(self, validated_data): + target_data = validated_data.pop('target') + promo_common = validated_data.pop('promo_common', None) + promo_unique = validated_data.pop('promo_unique', None) + mode = validated_data['mode'] + + user = self.context['request'].user + validated_data['company'] = user + + promo = business_models.Promo.objects.create( + **validated_data, + target=target_data, + ) + + if mode == business_models.Promo.MODE_COMMON: + promo.promo_common = promo_common + promo.save() + elif mode == business_models.Promo.MODE_UNIQUE and promo_unique: + promo_codes = [ + business_models.PromoCode(promo=promo, code=code) + for code in promo_unique + ] + business_models.PromoCode.objects.bulk_create(promo_codes) + + return promo + + def to_representation(self, instance): + data = super().to_representation(instance) + data['target'] = instance.target + + if instance.mode == business_models.Promo.MODE_UNIQUE: + data['promo_unique'] = [ + code.code for code in instance.unique_codes.all() + ] + data.pop('promo_common', None) + else: + data.pop('promo_unique', None) + + return data diff --git a/promo_code/business/urls.py b/promo_code/business/urls.py index 1f7f693..d634f05 100644 --- a/promo_code/business/urls.py +++ b/promo_code/business/urls.py @@ -20,4 +20,9 @@ business.views.CompanyTokenRefreshView.as_view(), name='company-token-refresh', ), + django.urls.path( + 'promo/create', + business.views.PromoCreateView.as_view(), + name='promo-create', + ), ] diff --git a/promo_code/business/views.py b/promo_code/business/views.py index 0b6bbb1..041a8f8 100644 --- a/promo_code/business/views.py +++ b/promo_code/business/views.py @@ -1,7 +1,9 @@ import business.models +import business.permissions import business.serializers import rest_framework.exceptions import rest_framework.generics +import rest_framework.permissions import rest_framework.response import rest_framework.serializers import rest_framework.status @@ -101,3 +103,28 @@ def post(self, request): class CompanyTokenRefreshView(rest_framework_simplejwt.views.TokenRefreshView): serializer_class = business.serializers.CompanyTokenRefreshSerializer + + +class PromoCreateView(rest_framework.views.APIView): + permission_classes = [ + rest_framework.permissions.IsAuthenticated, + business.permissions.IsCompanyUser, + ] + serializer_class = business.serializers.PromoCreateSerializer + + def post(self, request, *args, **kwargs): + serializer = self.serializer_class( + data=request.data, + context={'request': request}, + ) + if serializer.is_valid(): + instance = serializer.save() + return rest_framework.response.Response( + {'id': instance.id}, + status=rest_framework.status.HTTP_201_CREATED, + ) + + return rest_framework.response.Response( + serializer.errors, + status=rest_framework.status.HTTP_400_BAD_REQUEST, + )