diff --git a/promo_code/business/managers.py b/promo_code/business/managers.py index 0ddc547..39c81a3 100644 --- a/promo_code/business/managers.py +++ b/promo_code/business/managers.py @@ -22,6 +22,21 @@ def create_company(self, email, name, password=None, **extra_fields): class PromoManager(django.db.models.Manager): + with_related_fields = ( + 'id', + 'company__id', + 'company__name', + 'description', + 'image_url', + 'target', + 'max_count', + 'active_from', + 'active_until', + 'mode', + 'promo_common', + 'created_at', + ) + def get_queryset(self): return super().get_queryset() @@ -30,19 +45,7 @@ def with_related(self): self.select_related('company') .prefetch_related('unique_codes') .only( - 'id', - 'company', - 'description', - 'image_url', - 'target', - 'max_count', - 'active_from', - 'active_until', - 'mode', - 'promo_common', - 'created_at', - 'company__id', - 'company__name', + *self.with_related_fields, ) ) diff --git a/promo_code/business/serializers.py b/promo_code/business/serializers.py index ffebcb6..b0adf07 100644 --- a/promo_code/business/serializers.py +++ b/promo_code/business/serializers.py @@ -2,7 +2,6 @@ import django.contrib.auth.password_validation import django.db.transaction -import pycountry import rest_framework.exceptions import rest_framework.serializers import rest_framework_simplejwt.exceptions @@ -140,28 +139,6 @@ def get_active_company_from_token(self, token): return company -class CountryField(rest_framework.serializers.CharField): - """ - Custom field for validating country codes according to ISO 3166-1 alpha-2. - """ - - def __init__(self, **kwargs): - kwargs['allow_blank'] = False - kwargs['min_length'] = business.constants.TARGET_COUNTRY_CODE_LENGTH - kwargs['max_length'] = business.constants.TARGET_COUNTRY_CODE_LENGTH - super().__init__(**kwargs) - - def to_internal_value(self, data): - code = super().to_internal_value(data) - try: - pycountry.countries.lookup(code.upper()) - except LookupError: - raise rest_framework.serializers.ValidationError( - 'Invalid ISO 3166-1 alpha-2 country code.', - ) - return code - - class MultiCountryField(rest_framework.serializers.ListField): """ Custom field for handling multiple country codes, @@ -169,7 +146,7 @@ class MultiCountryField(rest_framework.serializers.ListField): """ def __init__(self, **kwargs): - kwargs['child'] = CountryField() + kwargs['child'] = core.serializers.CountryField() kwargs['allow_empty'] = False super().__init__(**kwargs) @@ -196,234 +173,16 @@ def to_internal_value(self, data): return super().to_internal_value(data) -class TargetSerializer(rest_framework.serializers.Serializer): - age_from = rest_framework.serializers.IntegerField( - min_value=business.constants.TARGET_AGE_MIN, - max_value=business.constants.TARGET_AGE_MAX, - required=False, - ) - age_until = rest_framework.serializers.IntegerField( - min_value=business.constants.TARGET_AGE_MIN, - max_value=business.constants.TARGET_AGE_MAX, - required=False, - ) - country = CountryField(required=False) - - categories = rest_framework.serializers.ListField( - child=rest_framework.serializers.CharField( - min_length=business.constants.TARGET_CATEGORY_MIN_LENGTH, - max_length=business.constants.TARGET_CATEGORY_MAX_LENGTH, - allow_blank=False, - ), - max_length=business.constants.TARGET_CATEGORY_MAX_ITEMS, - 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.'}, - ) - return data - - -class BasePromoSerializer(rest_framework.serializers.ModelSerializer): - """ - Base serializer for promo, containing validation and representation logic. - """ - - image_url = rest_framework.serializers.URLField( - required=False, - allow_blank=False, - max_length=business.constants.PROMO_IMAGE_URL_MAX_LENGTH, - ) - description = rest_framework.serializers.CharField( - min_length=business.constants.PROMO_DESC_MIN_LENGTH, - max_length=business.constants.PROMO_DESC_MAX_LENGTH, - required=True, - ) - target = TargetSerializer(required=True, allow_null=True) - promo_common = rest_framework.serializers.CharField( - min_length=business.constants.PROMO_COMMON_CODE_MIN_LENGTH, - max_length=business.constants.PROMO_COMMON_CODE_MAX_LENGTH, - required=False, - allow_null=True, - allow_blank=False, - ) - promo_unique = rest_framework.serializers.ListField( - child=rest_framework.serializers.CharField( - min_length=business.constants.PROMO_UNIQUE_CODE_MIN_LENGTH, - max_length=business.constants.PROMO_UNIQUE_CODE_MAX_LENGTH, - allow_blank=False, - ), - min_length=business.constants.PROMO_UNIQUE_LIST_MIN_ITEMS, - max_length=business.constants.PROMO_UNIQUE_LIST_MAX_ITEMS, - 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', - ) - - def validate(self, data): - """ - Main validation method. - Determines the mode and calls the corresponding validation method. - """ - - mode = data.get('mode', getattr(self.instance, 'mode', None)) - - if mode == business.constants.PROMO_MODE_COMMON: - self._validate_common(data) - elif mode == business.constants.PROMO_MODE_UNIQUE: - self._validate_unique(data) - elif mode is None: - raise rest_framework.serializers.ValidationError( - {'mode': 'This field is required.'}, - ) - else: - raise rest_framework.serializers.ValidationError( - {'mode': 'Invalid mode.'}, - ) - - return data - - def _validate_common(self, data): - """ - Validations for COMMON promo mode. - """ - - if 'promo_unique' in data and data['promo_unique'] is not None: - raise rest_framework.serializers.ValidationError( - {'promo_unique': 'This field is not allowed for COMMON mode.'}, - ) - - if self.instance is None and not data.get('promo_common'): - raise rest_framework.serializers.ValidationError( - {'promo_common': 'This field is required for COMMON mode.'}, - ) - - new_max_count = data.get('max_count') - if self.instance and new_max_count is not None: - used_count = self.instance.get_used_codes_count - if used_count > new_max_count: - raise rest_framework.serializers.ValidationError( - { - 'max_count': ( - f'max_count ({new_max_count}) cannot be less than ' - f'used_count ({used_count}).' - ), - }, - ) - - effective_max_count = ( - new_max_count - if new_max_count is not None - else getattr(self.instance, 'max_count', None) - ) - - min_c = business.constants.PROMO_COMMON_MIN_COUNT - max_c = business.constants.PROMO_COMMON_MAX_COUNT - if effective_max_count is not None and not ( - min_c <= effective_max_count <= max_c - ): - raise rest_framework.serializers.ValidationError( - { - 'max_count': ( - f'Must be between {min_c} and {max_c} for COMMON mode.' - ), - }, - ) - - def _validate_unique(self, data): - """ - Validations for UNIQUE promo mode. - """ - - if 'promo_common' in data and data['promo_common'] is not None: - raise rest_framework.serializers.ValidationError( - {'promo_common': 'This field is not allowed for UNIQUE mode.'}, - ) - - if self.instance is None and not data.get('promo_unique'): - raise rest_framework.serializers.ValidationError( - {'promo_unique': 'This field is required for UNIQUE mode.'}, - ) - - effective_max_count = data.get( - 'max_count', - getattr(self.instance, 'max_count', None), - ) - - if ( - effective_max_count is not None - and effective_max_count - != business.constants.PROMO_UNIQUE_MAX_COUNT - ): - raise rest_framework.serializers.ValidationError( - { - 'max_count': ( - 'Must be equal to ' - f'{business.constants.PROMO_UNIQUE_MAX_COUNT} ' - 'for UNIQUE mode.' - ), - }, - ) - - def to_representation(self, instance): - """ - Controls the display of fields in the response. - """ - - data = super().to_representation(instance) - - if not instance.image_url: - data.pop('image_url', None) - - if instance.mode == business.constants.PROMO_MODE_UNIQUE: - data.pop('promo_common', None) - if 'promo_unique' in self.fields and isinstance( - self.fields['promo_unique'], - rest_framework.serializers.SerializerMethodField, - ): - data['promo_unique'] = self.get_promo_unique(instance) - else: - data['promo_unique'] = [ - code.code for code in instance.unique_codes.all() - ] - else: - data.pop('promo_unique', None) - - return data - - -class PromoCreateSerializer(BasePromoSerializer): +class PromoCreateSerializer(core.serializers.BaseCompanyPromoSerializer): url = rest_framework.serializers.HyperlinkedIdentityField( view_name='api-business:promo-detail', lookup_field='id', ) - class Meta(BasePromoSerializer.Meta): - fields = ('url',) + BasePromoSerializer.Meta.fields + class Meta(core.serializers.BaseCompanyPromoSerializer.Meta): + fields = ( + 'url', + ) + core.serializers.BaseCompanyPromoSerializer.Meta.fields def create(self, validated_data): target_data = validated_data.pop('target') @@ -468,7 +227,7 @@ def validate(self, attrs): return attrs -class PromoDetailSerializer(BasePromoSerializer): +class PromoDetailSerializer(core.serializers.BaseCompanyPromoSerializer): promo_id = rest_framework.serializers.UUIDField( source='id', read_only=True, @@ -496,8 +255,8 @@ class PromoDetailSerializer(BasePromoSerializer): promo_unique = rest_framework.serializers.SerializerMethodField() - class Meta(BasePromoSerializer.Meta): - fields = BasePromoSerializer.Meta.fields + ( + class Meta(core.serializers.BaseCompanyPromoSerializer.Meta): + fields = core.serializers.BaseCompanyPromoSerializer.Meta.fields + ( 'promo_id', 'company_name', 'like_count', @@ -514,13 +273,12 @@ def get_promo_unique(self, obj): def update(self, instance, validated_data): target_data = validated_data.pop('target', None) - for attr, value in validated_data.items(): - setattr(instance, attr, value) + instance = super().update(instance, validated_data) if target_data is not None: instance.target = target_data + instance.save(update_fields=['target']) - instance.save() return instance diff --git a/promo_code/business/views.py b/promo_code/business/views.py index 9aa7c02..5f68824 100644 --- a/promo_code/business/views.py +++ b/promo_code/business/views.py @@ -74,28 +74,21 @@ class CompanyPromoListCreateView(rest_framework.generics.ListCreateAPIView): rest_framework.permissions.IsAuthenticated, business.permissions.IsCompanyUser, ] - # Pagination is only needed for GET (listing) pagination_class = core.pagination.CustomLimitOffsetPagination - _validated_query_params = {} - def get_serializer_class(self): if self.request.method == 'POST': return business.serializers.PromoCreateSerializer return business.serializers.PromoReadOnlySerializer - def list(self, request, *args, **kwargs): + def get_queryset(self): query_serializer = business.serializers.PromoListQuerySerializer( - data=request.query_params, + data=self.request.query_params, ) query_serializer.is_valid(raise_exception=True) - self._validated_query_params = query_serializer.validated_data + params = query_serializer.validated_data - return super().list(request, *args, **kwargs) - - def get_queryset(self): - params = self._validated_query_params countries = [c.upper() for c in params.get('countries', [])] sort_by = params.get('sort_by') diff --git a/promo_code/core/pagination.py b/promo_code/core/pagination.py index 31a129d..7637f11 100644 --- a/promo_code/core/pagination.py +++ b/promo_code/core/pagination.py @@ -1,4 +1,3 @@ -import rest_framework.exceptions import rest_framework.pagination import rest_framework.response diff --git a/promo_code/core/serializers.py b/promo_code/core/serializers.py index 60cd85b..c98df61 100644 --- a/promo_code/core/serializers.py +++ b/promo_code/core/serializers.py @@ -1,6 +1,13 @@ +import django.contrib.auth.password_validation +import pycountry import rest_framework.exceptions import rest_framework.serializers +import business.constants +import business.models +import user.constants +import user.models + class BaseLimitOffsetPaginationSerializer( rest_framework.serializers.Serializer, @@ -29,3 +36,385 @@ def validate(self, attrs): raise rest_framework.exceptions.ValidationError(errors) return super().validate(attrs) + + +class CountryField(rest_framework.serializers.CharField): + """ + Custom field for validating country codes according to ISO 3166-1 alpha-2. + """ + + def __init__(self, **kwargs): + kwargs['allow_blank'] = False + kwargs['min_length'] = business.constants.TARGET_COUNTRY_CODE_LENGTH + kwargs['max_length'] = business.constants.TARGET_COUNTRY_CODE_LENGTH + super().__init__(**kwargs) + + def to_internal_value(self, data): + code = super().to_internal_value(data) + try: + pycountry.countries.lookup(code.upper()) + except LookupError: + raise rest_framework.serializers.ValidationError( + 'Invalid ISO 3166-1 alpha-2 country code.', + ) + return code + + +class TargetSerializer(rest_framework.serializers.Serializer): + age_from = rest_framework.serializers.IntegerField( + min_value=business.constants.TARGET_AGE_MIN, + max_value=business.constants.TARGET_AGE_MAX, + required=False, + ) + age_until = rest_framework.serializers.IntegerField( + min_value=business.constants.TARGET_AGE_MIN, + max_value=business.constants.TARGET_AGE_MAX, + required=False, + ) + country = CountryField(required=False) + + categories = rest_framework.serializers.ListField( + child=rest_framework.serializers.CharField( + min_length=business.constants.TARGET_CATEGORY_MIN_LENGTH, + max_length=business.constants.TARGET_CATEGORY_MAX_LENGTH, + allow_blank=False, + ), + max_length=business.constants.TARGET_CATEGORY_MAX_ITEMS, + 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.'}, + ) + return data + + +class BaseCompanyPromoSerializer(rest_framework.serializers.ModelSerializer): + """ + Base serializer for promo, containing validation and representation logic. + """ + + image_url = rest_framework.serializers.URLField( + required=False, + allow_blank=False, + max_length=business.constants.PROMO_IMAGE_URL_MAX_LENGTH, + ) + description = rest_framework.serializers.CharField( + min_length=business.constants.PROMO_DESC_MIN_LENGTH, + max_length=business.constants.PROMO_DESC_MAX_LENGTH, + required=True, + ) + target = TargetSerializer( + required=True, + allow_null=True, + ) + promo_common = rest_framework.serializers.CharField( + min_length=business.constants.PROMO_COMMON_CODE_MIN_LENGTH, + max_length=business.constants.PROMO_COMMON_CODE_MAX_LENGTH, + required=False, + allow_null=True, + allow_blank=False, + ) + promo_unique = rest_framework.serializers.ListField( + child=rest_framework.serializers.CharField( + min_length=business.constants.PROMO_UNIQUE_CODE_MIN_LENGTH, + max_length=business.constants.PROMO_UNIQUE_CODE_MAX_LENGTH, + allow_blank=False, + ), + min_length=business.constants.PROMO_UNIQUE_LIST_MIN_ITEMS, + max_length=business.constants.PROMO_UNIQUE_LIST_MAX_ITEMS, + 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', + ) + + def validate(self, data): + """ + Main validation method. + Determines the mode and calls the corresponding validation method. + """ + + mode = data.get('mode', getattr(self.instance, 'mode', None)) + + if mode == business.constants.PROMO_MODE_COMMON: + self._validate_common(data) + elif mode == business.constants.PROMO_MODE_UNIQUE: + self._validate_unique(data) + elif mode is None: + raise rest_framework.serializers.ValidationError( + {'mode': 'This field is required.'}, + ) + else: + raise rest_framework.serializers.ValidationError( + {'mode': 'Invalid mode.'}, + ) + + return data + + def _validate_common(self, data): + """ + Validations for COMMON promo mode. + """ + + if 'promo_unique' in data and data['promo_unique'] is not None: + raise rest_framework.serializers.ValidationError( + {'promo_unique': 'This field is not allowed for COMMON mode.'}, + ) + + if self.instance is None and not data.get('promo_common'): + raise rest_framework.serializers.ValidationError( + {'promo_common': 'This field is required for COMMON mode.'}, + ) + + new_max_count = data.get('max_count') + if self.instance and new_max_count is not None: + used_count = self.instance.get_used_codes_count + if used_count > new_max_count: + raise rest_framework.serializers.ValidationError( + { + 'max_count': ( + f'max_count ({new_max_count}) cannot be less than ' + f'used_count ({used_count}).' + ), + }, + ) + + effective_max_count = ( + new_max_count + if new_max_count is not None + else getattr(self.instance, 'max_count', None) + ) + + min_c = business.constants.PROMO_COMMON_MIN_COUNT + max_c = business.constants.PROMO_COMMON_MAX_COUNT + if effective_max_count is not None and not ( + min_c <= effective_max_count <= max_c + ): + raise rest_framework.serializers.ValidationError( + { + 'max_count': ( + f'Must be between {min_c} and {max_c} for COMMON mode.' + ), + }, + ) + + def _validate_unique(self, data): + """ + Validations for UNIQUE promo mode. + """ + + if 'promo_common' in data and data['promo_common'] is not None: + raise rest_framework.serializers.ValidationError( + {'promo_common': 'This field is not allowed for UNIQUE mode.'}, + ) + + if self.instance is None and not data.get('promo_unique'): + raise rest_framework.serializers.ValidationError( + {'promo_unique': 'This field is required for UNIQUE mode.'}, + ) + + effective_max_count = data.get( + 'max_count', + getattr(self.instance, 'max_count', None), + ) + + if ( + effective_max_count is not None + and effective_max_count + != business.constants.PROMO_UNIQUE_MAX_COUNT + ): + raise rest_framework.serializers.ValidationError( + { + 'max_count': ( + 'Must be equal to ' + f'{business.constants.PROMO_UNIQUE_MAX_COUNT} ' + 'for UNIQUE mode.' + ), + }, + ) + + def to_representation(self, instance): + """ + Controls the display of fields in the response. + """ + + data = super().to_representation(instance) + + if not instance.image_url: + data.pop('image_url', None) + + if instance.mode == business.constants.PROMO_MODE_UNIQUE: + data.pop('promo_common', None) + if 'promo_unique' in self.fields and isinstance( + self.fields['promo_unique'], + rest_framework.serializers.SerializerMethodField, + ): + data['promo_unique'] = self.get_promo_unique(instance) + else: + data['promo_unique'] = [ + code.code for code in instance.unique_codes.all() + ] + else: + data.pop('promo_unique', None) + + return data + + +class OtherFieldSerializer(rest_framework.serializers.Serializer): + age = rest_framework.serializers.IntegerField( + required=True, + min_value=user.constants.AGE_MIN, + max_value=user.constants.AGE_MAX, + ) + country = CountryField(required=True) + + +class BaseUserSerializer(rest_framework.serializers.ModelSerializer): + password = rest_framework.serializers.CharField( + write_only=True, + required=True, + validators=[django.contrib.auth.password_validation.validate_password], + max_length=user.constants.PASSWORD_MAX_LENGTH, + min_length=user.constants.PASSWORD_MIN_LENGTH, + style={'input_type': 'password'}, + ) + name = rest_framework.serializers.CharField( + required=True, + min_length=user.constants.NAME_MIN_LENGTH, + max_length=user.constants.NAME_MAX_LENGTH, + ) + surname = rest_framework.serializers.CharField( + required=True, + min_length=user.constants.SURNAME_MIN_LENGTH, + max_length=user.constants.SURNAME_MAX_LENGTH, + ) + email = rest_framework.serializers.EmailField( + required=True, + min_length=user.constants.EMAIL_MIN_LENGTH, + max_length=user.constants.EMAIL_MAX_LENGTH, + ) + avatar_url = rest_framework.serializers.URLField( + required=False, + max_length=user.constants.AVATAR_URL_MAX_LENGTH, + allow_null=True, + ) + other = OtherFieldSerializer(required=True) + + class Meta: + model = user.models.User + fields = ( + 'name', + 'surname', + 'email', + 'password', + 'avatar_url', + 'other', + ) + + +class BaseUserPromoSerializer(rest_framework.serializers.ModelSerializer): + """ + Base serializer for promos, containing common fields and methods. + """ + + 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') + like_count = rest_framework.serializers.IntegerField( + source='get_like_count', + ) + comment_count = rest_framework.serializers.IntegerField( + source='get_comment_count', + ) + + is_liked_by_user = rest_framework.serializers.SerializerMethodField() + is_activated_by_user = 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', + 'comment_count', + 'is_liked_by_user', + ) + read_only_fields = fields + + def get_is_liked_by_user(self, obj: business.models.Promo) -> bool: + """ + Checks whether the current user has liked this promo. + """ + request = self.context.get('request') + if ( + request + and hasattr(request, 'user') + and request.user.is_authenticated + ): + return user.models.PromoLike.objects.filter( + promo=obj, + user=request.user, + ).exists() + return False + + def get_is_activated_by_user(self, obj: business.models.Promo) -> bool: + """ + Checks whether the current user has activated this promo code. + """ + request = self.context.get('request') + + if not ( + request + and hasattr(request, 'user') + and request.user.is_authenticated + ): + return False + + return user.models.PromoActivationHistory.objects.filter( + promo=obj, + user=request.user, + ).exists() + + +class BaseCommentSerializer(rest_framework.serializers.ModelSerializer): + """ + Base serializer for promo comments. + """ + + text = rest_framework.serializers.CharField( + min_length=user.constants.COMMENT_TEXT_MIN_LENGTH, + max_length=user.constants.COMMENT_TEXT_MAX_LENGTH, + ) + + class Meta: + model = user.models.PromoComment + fields = ('text',) diff --git a/promo_code/user/serializers.py b/promo_code/user/serializers.py index cbdba3a..e7f7fbf 100644 --- a/promo_code/user/serializers.py +++ b/promo_code/user/serializers.py @@ -1,7 +1,5 @@ -import django.contrib.auth.password_validation import django.core.cache import django.db.transaction -import pycountry import rest_framework.exceptions import rest_framework.serializers import rest_framework_simplejwt.serializers @@ -9,79 +7,12 @@ import rest_framework_simplejwt.tokens import business.constants -import business.models +import core.serializers import core.utils.auth -import user.constants import user.models -class OtherFieldSerializer(rest_framework.serializers.Serializer): - age = rest_framework.serializers.IntegerField( - required=True, - min_value=user.constants.AGE_MIN, - max_value=user.constants.AGE_MAX, - ) - country = rest_framework.serializers.CharField( - required=True, - max_length=user.constants.COUNTRY_CODE_LENGTH, - min_length=user.constants.COUNTRY_CODE_LENGTH, - ) - - def validate(self, value): - country = value['country'] - - try: - pycountry.countries.lookup(country.upper()) - except LookupError: - raise rest_framework.serializers.ValidationError( - 'Invalid ISO 3166-1 alpha-2 country code.', - ) - - return value - - -class SignUpSerializer(rest_framework.serializers.ModelSerializer): - password = rest_framework.serializers.CharField( - write_only=True, - required=True, - validators=[django.contrib.auth.password_validation.validate_password], - max_length=user.constants.PASSWORD_MAX_LENGTH, - min_length=user.constants.PASSWORD_MIN_LENGTH, - style={'input_type': 'password'}, - ) - name = rest_framework.serializers.CharField( - required=True, - min_length=user.constants.NAME_MIN_LENGTH, - max_length=user.constants.NAME_MAX_LENGTH, - ) - surname = rest_framework.serializers.CharField( - required=True, - min_length=user.constants.SURNAME_MIN_LENGTH, - max_length=user.constants.SURNAME_MAX_LENGTH, - ) - email = rest_framework.serializers.EmailField( - required=True, - min_length=user.constants.EMAIL_MIN_LENGTH, - max_length=user.constants.EMAIL_MAX_LENGTH, - ) - avatar_url = rest_framework.serializers.URLField( - required=False, - max_length=user.constants.AVATAR_URL_MAX_LENGTH, - allow_null=True, - ) - other = OtherFieldSerializer(required=True) - - class Meta: - model = user.models.User - fields = ( - 'name', - 'surname', - 'email', - 'avatar_url', - 'other', - 'password', - ) - +class SignUpSerializer(core.serializers.BaseUserSerializer): @django.db.transaction.atomic def create(self, validated_data): try: @@ -166,81 +97,45 @@ def get_token(cls, user): return token -class UserProfileSerializer(rest_framework.serializers.ModelSerializer): - name = rest_framework.serializers.CharField( - required=False, - min_length=user.constants.NAME_MIN_LENGTH, - max_length=user.constants.NAME_MAX_LENGTH, - ) - surname = rest_framework.serializers.CharField( - required=False, - min_length=user.constants.SURNAME_MIN_LENGTH, - max_length=user.constants.SURNAME_MAX_LENGTH, - ) - email = rest_framework.serializers.EmailField( - required=False, - min_length=user.constants.EMAIL_MIN_LENGTH, - max_length=user.constants.EMAIL_MAX_LENGTH, - ) - password = rest_framework.serializers.CharField( - write_only=True, - required=False, - validators=[django.contrib.auth.password_validation.validate_password], - max_length=user.constants.PASSWORD_MAX_LENGTH, - min_length=user.constants.PASSWORD_MIN_LENGTH, - style={'input_type': 'password'}, - ) - avatar_url = rest_framework.serializers.URLField( - required=False, - max_length=user.constants.AVATAR_URL_MAX_LENGTH, - allow_null=True, - ) - other = OtherFieldSerializer(required=False) - - class Meta: - model = user.models.User - fields = ( - 'name', - 'surname', - 'email', - 'password', - 'avatar_url', - 'other', - ) - - def update(self, instance, validated_data): - password = validated_data.pop('password', None) - - if password: - # do not invalidate the token - instance.set_password(password) - - other_data = validated_data.pop('other', None) - if other_data is not None: - instance.other = other_data +class UserProfileSerializer(core.serializers.BaseUserSerializer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + field.required = False + def validate_email(self, value): if ( - 'email' in validated_data - and user.models.User.objects.filter( - email=validated_data['email'], - ) - .exclude(id=instance.id) + user.models.User.objects.filter(email=value) + .exclude(pk=self.instance.pk) .exists() ): - raise rest_framework.exceptions.ValidationError( + raise rest_framework.serializers.ValidationError( {'email': 'This email address is already registered.'}, ) + return value - for attr, value in validated_data.items(): - setattr(instance, attr, value) + def update(self, instance, validated_data): + password = validated_data.pop('password', None) + other_data = validated_data.pop('other', None) - instance.save() + instance = super().update(instance, validated_data) - user_type = instance.__class__.__name__.lower() - token_version = instance.token_version + if other_data is not None: + instance.other.update(other_data) - cache_key = f'auth_instance_{user_type}_{instance.id}_v{token_version}' - django.core.cache.cache.delete(cache_key) + if password: + instance.set_password(password) + + update_fields = [] + if other_data is not None: + update_fields.append('other') + if password: + update_fields.append('password') + + if update_fields: + instance.save(update_fields=update_fields) + + self._invalidate_cache(instance) return instance def to_representation(self, instance): @@ -251,254 +146,90 @@ def to_representation(self, instance): data.pop('avatar_url', None) return data + def _invalidate_cache(self, instance): + """ + Private helper to remove the authentication instance cache key. + """ -class UserFeedQuerySerializer(rest_framework.serializers.Serializer): + user_type = instance.__class__.__name__.lower() + token_version = getattr(instance, 'token_version', None) + cache_key = f'auth_instance_{user_type}_{instance.id}_v{token_version}' + django.core.cache.cache.delete(cache_key) + + +class UserFeedQuerySerializer( + core.serializers.BaseLimitOffsetPaginationSerializer, +): """ 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, + allow_blank=False, ) 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() + query_params = self.initial_data.keys() + allowed_params = self.fields.keys() + unexpected_params = set(query_params) - set(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_category(self, value): - cotegory = self.initial_data.get('category') - - if cotegory is None: - return value - - if value == '': - raise rest_framework.exceptions.ValidationError( - 'Invalid category format.', - ) - - return value - - 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.', + f'Invalid parameters: {", ".join(unexpected_params)}', ) - 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.', + if ( + 'category' in self.initial_data + and self.initial_data.get('category') == '' + ): + raise rest_framework.serializers.ValidationError( + {'category': 'This field cannot be blank.'}, ) 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() - like_count = rest_framework.serializers.IntegerField( - source='get_like_count', - read_only=True, - ) - comment_count = rest_framework.serializers.IntegerField( - source='get_comment_count', - read_only=True, - ) - is_liked_by_user = 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_liked_by_user(self, obj: business.models.Promo) -> bool: - request = self.context.get('request') - if ( - request - and hasattr(request, 'user') - and request.user.is_authenticated - ): - return user.models.PromoLike.objects.filter( - promo=obj, - user=request.user, - ).exists() - return False +class PromoFeedSerializer(core.serializers.BaseUserPromoSerializer): + """ + Serializer for representing promo feed data for a user. + """ - def get_is_activated_by_user(self, obj) -> bool: - # TODO: - return False + pass -class UserPromoDetailSerializer(rest_framework.serializers.ModelSerializer): +class UserPromoDetailSerializer(core.serializers.BaseUserPromoSerializer): """ Serializer for detailed promo-code information (without revealing the code value). The output format matches the given example. """ - promo_id = rest_framework.serializers.UUIDField( - source='id', - read_only=True, - ) - company_id = rest_framework.serializers.UUIDField( - source='company.id', - read_only=True, - ) - company_name = rest_framework.serializers.CharField( - source='company.name', - read_only=True, - ) - active = rest_framework.serializers.BooleanField( - source='is_active', - read_only=True, - ) - is_activated_by_user = rest_framework.serializers.SerializerMethodField() - like_count = rest_framework.serializers.IntegerField( - source='get_like_count', - read_only=True, - ) - comment_count = rest_framework.serializers.IntegerField( - source='get_comment_count', - read_only=True, - ) - is_liked_by_user = 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', - 'comment_count', - 'is_liked_by_user', - ) - read_only_fields = fields + pass - def get_is_liked_by_user(self, obj: business.models.Promo) -> bool: - request = self.context.get('request') - if ( - request - and hasattr(request, 'user') - and request.user.is_authenticated - ): - return user.models.PromoLike.objects.filter( - promo=obj, - user=request.user, - ).exists() - return False - - def get_is_activated_by_user(self, obj: business.models.Promo) -> bool: - """ - Checks whether the current user has activated this promo code. - """ - request = self.context.get('request') - if not ( - request - and hasattr(request, 'user') - and request.user.is_authenticated - ): - return False - return user.models.PromoActivationHistory.objects.filter( - promo=obj, - user=request.user, - ).exists() +class UserAuthorSerializer(core.serializers.BaseUserSerializer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + field.required = False - -class UserAuthorSerializer(rest_framework.serializers.ModelSerializer): - name = rest_framework.serializers.CharField( - read_only=True, - min_length=1, - max_length=100, - ) - surname = rest_framework.serializers.CharField( - read_only=True, - min_length=1, - max_length=120, - ) - avatar_url = rest_framework.serializers.URLField( - read_only=True, - max_length=350, - allow_null=True, - ) - - class Meta: - model = user.models.User + class Meta(core.serializers.BaseUserSerializer.Meta): fields = ('name', 'surname', 'avatar_url') + read_only_fields = fields -class CommentSerializer(rest_framework.serializers.ModelSerializer): +class CommentSerializer(core.serializers.BaseCommentSerializer): + """ + Serializer for displaying (reading) a comment. + """ + id = rest_framework.serializers.UUIDField(read_only=True) - text = rest_framework.serializers.CharField( - min_length=user.constants.COMMENT_TEXT_MIN_LENGTH, - max_length=user.constants.COMMENT_TEXT_MAX_LENGTH, - ) date = rest_framework.serializers.DateTimeField( source='created_at', read_only=True, @@ -506,31 +237,20 @@ class CommentSerializer(rest_framework.serializers.ModelSerializer): ) author = UserAuthorSerializer(read_only=True) - class Meta: - model = user.models.PromoComment - fields = ('id', 'text', 'date', 'author') - - -class CommentCreateSerializer(rest_framework.serializers.ModelSerializer): - text = rest_framework.serializers.CharField( - min_length=user.constants.COMMENT_TEXT_MIN_LENGTH, - max_length=user.constants.COMMENT_TEXT_MAX_LENGTH, - ) + class Meta(core.serializers.BaseCommentSerializer.Meta): + fields = core.serializers.BaseCommentSerializer.Meta.fields + ( + 'id', + 'date', + 'author', + ) - class Meta: - model = user.models.PromoComment - fields = ('text',) +class CommentCreateSerializer(core.serializers.BaseCommentSerializer): + pass -class CommentUpdateSerializer(rest_framework.serializers.ModelSerializer): - text = rest_framework.serializers.CharField( - min_length=user.constants.COMMENT_TEXT_MIN_LENGTH, - max_length=user.constants.COMMENT_TEXT_MAX_LENGTH, - ) - class Meta: - model = user.models.PromoComment - fields = ('text',) +class CommentUpdateSerializer(core.serializers.BaseCommentSerializer): + pass class PromoActivationSerializer(rest_framework.serializers.Serializer):