diff --git a/promo_code/business/constants.py b/promo_code/business/constants.py index 5db8702..9d0a959 100644 --- a/promo_code/business/constants.py +++ b/promo_code/business/constants.py @@ -23,7 +23,7 @@ # === Promo Common === PROMO_COMMON_CODE_MIN_LENGTH = 5 PROMO_COMMON_CODE_MAX_LENGTH = 30 -PROMO_COMMON_MIN_COUNT = 1 +PROMO_COMMON_MIN_COUNT = 0 PROMO_COMMON_MAX_COUNT = 100_000_000 diff --git a/promo_code/business/migrations/0002_promo_used_count_alter_company_token_version_and_more.py b/promo_code/business/migrations/0002_promo_used_count_alter_company_token_version_and_more.py new file mode 100644 index 0000000..b935d8a --- /dev/null +++ b/promo_code/business/migrations/0002_promo_used_count_alter_company_token_version_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2 on 2025-05-03 00:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("business", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="promo", + name="used_count", + field=models.PositiveIntegerField(default=0, editable=False), + ), + migrations.AlterField( + model_name="company", + name="token_version", + field=models.PositiveIntegerField(default=0), + ), + migrations.AlterField( + model_name="promo", + name="max_count", + field=models.PositiveIntegerField(), + ), + ] diff --git a/promo_code/business/models.py b/promo_code/business/models.py index 3c1ca5f..4714eca 100644 --- a/promo_code/business/models.py +++ b/promo_code/business/models.py @@ -23,7 +23,7 @@ class Company(django.contrib.auth.models.AbstractBaseUser): max_length=business.constants.COMPANY_NAME_MAX_LENGTH, ) - token_version = django.db.models.IntegerField(default=0) + token_version = django.db.models.PositiveIntegerField(default=0) created_at = django.db.models.DateTimeField(auto_now_add=True) is_active = django.db.models.BooleanField(default=True) @@ -59,7 +59,11 @@ class Promo(django.db.models.Model): null=True, ) target = django.db.models.JSONField(default=dict) - max_count = django.db.models.IntegerField() + max_count = django.db.models.PositiveIntegerField() + used_count = django.db.models.PositiveIntegerField( + default=0, + editable=False, + ) 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( @@ -82,7 +86,7 @@ def __str__(self): @property def is_active(self) -> bool: - today = django.utils.timezone.timezone.now().date() + today = django.utils.timezone.now().date() if self.active_from and self.active_from > today: return False if self.active_until and self.active_until < today: @@ -90,7 +94,9 @@ def is_active(self) -> bool: if self.mode == business.constants.PROMO_MODE_UNIQUE: return self.unique_codes.filter(is_used=False).exists() - # TODO: COMMON Promo + if self.mode == business.constants.PROMO_MODE_COMMON: + return self.used_count < self.max_count + return True @property diff --git a/promo_code/business/serializers.py b/promo_code/business/serializers.py index 4f0168e..8be908f 100644 --- a/promo_code/business/serializers.py +++ b/promo_code/business/serializers.py @@ -481,6 +481,10 @@ class PromoDetailSerializer(rest_framework.serializers.ModelSerializer): source='get_used_codes_count', read_only=True, ) + active = rest_framework.serializers.BooleanField( + source='is_active', + read_only=True, + ) class Meta: model = business.models.Promo @@ -496,6 +500,7 @@ class Meta: 'promo_common', 'promo_unique', 'company_name', + 'active', 'like_count', 'used_count', ) diff --git a/promo_code/business/validators.py b/promo_code/business/validators.py index 08be430..88a10f6 100644 --- a/promo_code/business/validators.py +++ b/promo_code/business/validators.py @@ -56,8 +56,6 @@ def validate(self): promo_common = self.full_data.get('promo_common') promo_unique = self.full_data.get('promo_unique') max_count = self.full_data.get('max_count') - active_from = self.full_data.get('active_from') - active_until = self.full_data.get('active_until') if mode not in [ business.constants.PROMO_MODE_COMMON, @@ -86,7 +84,7 @@ def validate(self): ) if max_count is None or not ( business.constants.PROMO_COMMON_MIN_COUNT - < max_count + <= max_count <= business.constants.PROMO_COMMON_MAX_COUNT ): raise rest_framework.exceptions.ValidationError( @@ -112,9 +110,4 @@ def validate(self): {'max_count': 'Must be 1 for UNIQUE mode.'}, ) - if active_from and active_until and active_from > active_until: - raise rest_framework.exceptions.ValidationError( - {'active_until': 'Must be after or equal to active_from.'}, - ) - return self.full_data diff --git a/promo_code/user/constants.py b/promo_code/user/constants.py index 9681d90..951bd79 100644 --- a/promo_code/user/constants.py +++ b/promo_code/user/constants.py @@ -16,3 +16,6 @@ EMAIL_MAX_LENGTH = 120 AVATAR_URL_MAX_LENGTH = 350 + +TARGET_CATEGORY_MIN_LENGTH = 2 +TARGET_CATEGORY_MAX_LENGTH = 20 diff --git a/promo_code/user/pagination.py b/promo_code/user/pagination.py new file mode 100644 index 0000000..d079fbc --- /dev/null +++ b/promo_code/user/pagination.py @@ -0,0 +1,24 @@ +import rest_framework.pagination +import rest_framework.response + + +class UserFeedPagination(rest_framework.pagination.LimitOffsetPagination): + default_limit = 10 + max_limit = 100 + + def get_limit(self, request): + raw_limit = request.query_params.get(self.limit_query_param) + + if raw_limit is None: + return self.default_limit + + limit = int(raw_limit) + + # Allow 0, otherwise cut by max_limit + return 0 if limit == 0 else min(limit, self.max_limit) + + def get_paginated_response(self, data): + return rest_framework.response.Response( + data, + headers={'X-Total-Count': str(self.count)}, + ) diff --git a/promo_code/user/serializers.py b/promo_code/user/serializers.py index e41a997..f34347c 100644 --- a/promo_code/user/serializers.py +++ b/promo_code/user/serializers.py @@ -9,6 +9,7 @@ import rest_framework_simplejwt.token_blacklist.models as tb_models import rest_framework_simplejwt.tokens +import business.constants import business.models import user.constants import user.models @@ -247,6 +248,124 @@ def to_representation(self, instance): return data +class UserFeedQuerySerializer(rest_framework.serializers.Serializer): + """ + Serializer for validating query parameters of promo feed requests. + """ + + limit = rest_framework.serializers.CharField( + required=False, + allow_blank=True, + ) + offset = rest_framework.serializers.CharField( + required=False, + allow_blank=True, + ) + category = rest_framework.serializers.CharField( + min_length=business.constants.TARGET_CATEGORY_MIN_LENGTH, + max_length=business.constants.TARGET_CATEGORY_MAX_LENGTH, + required=False, + allow_blank=True, + ) + active = rest_framework.serializers.BooleanField( + required=False, + allow_null=True, + ) + + _allowed_params = None + + def get_allowed_params(self): + if self._allowed_params is None: + self._allowed_params = set(self.fields.keys()) + return self._allowed_params + + def validate(self, attrs): + query_params = self.initial_data + allowed_params = self.get_allowed_params() + + unexpected_params = set(query_params.keys()) - allowed_params + if unexpected_params: + raise rest_framework.exceptions.ValidationError('Invalid params.') + + field_errors = {} + + attrs = self._validate_int_field('limit', attrs, field_errors) + attrs = self._validate_int_field('offset', attrs, field_errors) + + if field_errors: + raise rest_framework.exceptions.ValidationError(field_errors) + + return attrs + + def _validate_int_field(self, field_name, attrs, field_errors): + value_str = self.initial_data.get(field_name) + if value_str is None: + return attrs + + if value_str == '': + raise rest_framework.exceptions.ValidationError( + f'Invalid {field_name} format.', + ) + + try: + value_int = int(value_str) + if value_int < 0: + raise rest_framework.exceptions.ValidationError( + f'{field_name.capitalize()} cannot be negative.', + ) + attrs[field_name] = value_int + except (ValueError, TypeError): + raise rest_framework.exceptions.ValidationError( + f'Invalid {field_name} format.', + ) + + return attrs + + +class PromoFeedSerializer(rest_framework.serializers.ModelSerializer): + promo_id = rest_framework.serializers.UUIDField(source='id') + company_id = rest_framework.serializers.UUIDField(source='company.id') + company_name = rest_framework.serializers.CharField(source='company.name') + active = rest_framework.serializers.BooleanField(source='is_active') + is_activated_by_user = rest_framework.serializers.SerializerMethodField() + is_liked_by_user = rest_framework.serializers.SerializerMethodField() + like_count = rest_framework.serializers.SerializerMethodField() + comment_count = rest_framework.serializers.SerializerMethodField() + + class Meta: + model = business.models.Promo + fields = [ + 'promo_id', + 'company_id', + 'company_name', + 'description', + 'image_url', + 'active', + 'is_activated_by_user', + 'like_count', + 'is_liked_by_user', + 'comment_count', + ] + + read_only_fields = fields + + def get_is_activated_by_user(self, obj) -> bool: + # TODO: + return False + + def get_like_count(self, obj) -> int: + # TODO: + return 0 + + def get_is_liked_by_user(self, obj) -> bool: + # TODO: + return False + + def get_comment_count(self, obj) -> int: + # TODO: + return 0 + + class UserPromoDetailSerializer(rest_framework.serializers.ModelSerializer): """ Serializer for detailed promo-code information diff --git a/promo_code/user/urls.py b/promo_code/user/urls.py index 4142ae1..29f50e5 100644 --- a/promo_code/user/urls.py +++ b/promo_code/user/urls.py @@ -28,7 +28,12 @@ name='user-profile', ), django.urls.path( - 'promo//', + 'feed', + user.views.UserFeedView.as_view(), + name='user-feed', + ), + django.urls.path( + 'promo/', user.views.UserPromoDetailView.as_view(), name='user-promo-detail', ), diff --git a/promo_code/user/views.py b/promo_code/user/views.py index 4538b84..49ebe46 100644 --- a/promo_code/user/views.py +++ b/promo_code/user/views.py @@ -1,3 +1,5 @@ +import django.db.models +import django.utils.timezone import rest_framework.generics import rest_framework.permissions import rest_framework.response @@ -7,7 +9,7 @@ import business.constants import business.models -import user.models +import user.pagination import user.serializers @@ -93,3 +95,132 @@ class UserPromoDetailView(rest_framework.generics.RetrieveAPIView): ] lookup_field = 'id' + + +class UserFeedView(rest_framework.generics.ListAPIView): + serializer_class = user.serializers.PromoFeedSerializer + permission_classes = [rest_framework.permissions.IsAuthenticated] + pagination_class = user.pagination.UserFeedPagination + + def get_queryset(self): + user = self.request.user + user_age = user.other.get('age') + user_country_raw = user.other.get('country') + user_country = user_country_raw.lower() if user_country_raw else None + + queryset = business.models.Promo.objects.select_related('company') + + today_utc = django.utils.timezone.now().date() + + q_active_time = ( + django.db.models.Q(active_from__lte=today_utc) + | django.db.models.Q(active_from__isnull=True) + ) & ( + django.db.models.Q(active_until__gte=today_utc) + | django.db.models.Q(active_until__isnull=True) + ) + + q_common_active = django.db.models.Q( + mode=business.constants.PROMO_MODE_COMMON, + used_count__lt=django.db.models.F('max_count'), + ) + + has_available_unique_codes = business.models.PromoCode.objects.filter( + promo=django.db.models.OuterRef('pk'), + is_used=False, + ) + + queryset = queryset.annotate( + _has_available_unique_codes=django.db.models.Exists( + has_available_unique_codes, + ), + ) + q_unique_active = django.db.models.Q( + mode=business.constants.PROMO_MODE_UNIQUE, + _has_available_unique_codes=True, + ) + + q_is_active_by_rules = q_active_time & ( + q_common_active | q_unique_active + ) + + q_target_empty = django.db.models.Q(target={}) + + q_country_target_matches = django.db.models.Q() + if user_country: + q_country_target_matches = django.db.models.Q( + target__country__iexact=user_country, + ) + + q_country_target_not_set_or_empty = ~django.db.models.Q( + target__has_key='country', + ) | django.db.models.Q(target__country__isnull=True) + q_user_meets_country_target = ( + q_country_target_matches | q_country_target_not_set_or_empty + ) + + q_age_target_not_set = ~django.db.models.Q( + target__has_key='age_from', + ) & ~django.db.models.Q(target__has_key='age_until') + q_user_meets_age_target = q_age_target_not_set + + if user_age is not None: + q_age_from_ok = ( + ~django.db.models.Q(target__has_key='age_from') + | django.db.models.Q(target__age_from__isnull=True) + | django.db.models.Q(target__age_from__lte=user_age) + ) + q_age_until_ok = ( + ~django.db.models.Q(target__has_key='age_until') + | django.db.models.Q(target__age_until__isnull=True) + | django.db.models.Q(target__age_until__gte=user_age) + ) + q_user_age_in_defined_range = q_age_from_ok & q_age_until_ok + q_user_meets_age_target = ( + q_age_target_not_set | q_user_age_in_defined_range + ) + + q_user_is_targeted = q_target_empty | ( + q_user_meets_country_target & q_user_meets_age_target + ) + + queryset = queryset.filter(q_user_is_targeted) + + active_param_str = self.request.query_params.get('active') + if active_param_str is not None: + active_param_bool = active_param_str.lower() == 'true' + if active_param_bool: + queryset = queryset.filter(q_is_active_by_rules) + else: + queryset = queryset.exclude(q_is_active_by_rules) + + return queryset.order_by('-created_at') + + def filter_queryset(self, queryset): + queryset = super().filter_queryset(queryset) + + category_param = self.request.query_params.get('category') + if category_param: + category_param = category_param.lower() + if category_param: + filtered_pks = [] + for promo in queryset: + target_categories = promo.target.get('categories') + if not isinstance(target_categories, list): + continue + if any( + cat_name.lower() == category_param + for cat_name in target_categories + ): + filtered_pks.append(promo.pk) + queryset = queryset.filter(pk__in=filtered_pks) + return queryset + + def list(self, request, *args, **kwargs): + query_serializer = user.serializers.UserFeedQuerySerializer( + data=request.query_params, + ) + query_serializer.is_valid(raise_exception=True) + self.validated_query_params = query_serializer.validated_data + + return super().list(request, *args, **kwargs)