From 27e6633a384ba0dadd02f91f5464b9eeb3b4e0e4 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 13 May 2025 23:54:59 +0300 Subject: [PATCH] feat: Add like endpoint for promo codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new idempotent endpoints to manage likes on promo codes: - **POST** `/user/promo/{id}/like` – add a like to the specified promo code - **DELETE** `/user/promo/{id}/like` – remove a like from the specified promo code Both operations return HTTP 200 and do not modify the like count if: 1. The user has already liked the promo code (on POST), or 2. The user has no existing like to remove (on DELETE). Validate that the promo code exists before processing the request; return HTTP 404 if not found. --- .../migrations/0003_promo_like_count.py | 18 ++++++ promo_code/business/models.py | 8 +++ promo_code/business/serializers.py | 18 +++--- promo_code/user/migrations/0002_promolike.py | 56 +++++++++++++++++++ promo_code/user/models.py | 32 +++++++++++ promo_code/user/serializers.py | 33 ++++++----- promo_code/user/urls.py | 5 ++ promo_code/user/views.py | 52 +++++++++++++++++ 8 files changed, 199 insertions(+), 23 deletions(-) create mode 100644 promo_code/business/migrations/0003_promo_like_count.py create mode 100644 promo_code/user/migrations/0002_promolike.py diff --git a/promo_code/business/migrations/0003_promo_like_count.py b/promo_code/business/migrations/0003_promo_like_count.py new file mode 100644 index 0000000..de50b4c --- /dev/null +++ b/promo_code/business/migrations/0003_promo_like_count.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-05-13 19:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("business", "0002_promo_used_count_alter_company_token_version_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="promo", + name="like_count", + field=models.PositiveIntegerField(default=0, editable=False), + ), + ] diff --git a/promo_code/business/models.py b/promo_code/business/models.py index 4714eca..89c63dd 100644 --- a/promo_code/business/models.py +++ b/promo_code/business/models.py @@ -64,6 +64,10 @@ class Promo(django.db.models.Model): default=0, editable=False, ) + like_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( @@ -99,6 +103,10 @@ def is_active(self) -> bool: return True + @property + def get_like_count(self) -> int: + return self.like_count + @property def get_used_codes_count(self) -> int: if self.mode == business.constants.PROMO_MODE_UNIQUE: diff --git a/promo_code/business/serializers.py b/promo_code/business/serializers.py index 8be908f..c46f5f6 100644 --- a/promo_code/business/serializers.py +++ b/promo_code/business/serializers.py @@ -406,7 +406,10 @@ class PromoReadOnlySerializer(rest_framework.serializers.ModelSerializer): target = TargetSerializer() promo_unique = rest_framework.serializers.SerializerMethodField() - like_count = rest_framework.serializers.SerializerMethodField() + like_count = rest_framework.serializers.IntegerField( + source='get_like_count', + read_only=True, + ) used_count = rest_framework.serializers.IntegerField( source='get_used_codes_count', read_only=True, @@ -439,10 +442,6 @@ class Meta: def get_promo_unique(self, obj): return obj.get_available_unique_codes - def get_like_count(self, obj): - # TODO - return 0 - def to_representation(self, instance): data = super().to_representation(instance) if instance.mode == business.constants.PROMO_MODE_COMMON: @@ -476,7 +475,10 @@ class PromoDetailSerializer(rest_framework.serializers.ModelSerializer): source='company.name', read_only=True, ) - like_count = rest_framework.serializers.SerializerMethodField() + like_count = rest_framework.serializers.IntegerField( + source='get_like_count', + read_only=True, + ) used_count = rest_framework.serializers.IntegerField( source='get_used_codes_count', read_only=True, @@ -526,7 +528,3 @@ def validate(self, data): instance=self.instance, ) return validator.validate() - - def get_like_count(self, obj): - # TODO - return 0 diff --git a/promo_code/user/migrations/0002_promolike.py b/promo_code/user/migrations/0002_promolike.py new file mode 100644 index 0000000..834a1cf --- /dev/null +++ b/promo_code/user/migrations/0002_promolike.py @@ -0,0 +1,56 @@ +# Generated by Django 5.2 on 2025-05-12 17:44 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("business", "0002_promo_used_count_alter_company_token_version_and_more"), + ("user", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="PromoLike", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + verbose_name="UUID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "promo", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="likes", + to="business.promo", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="promo_likes", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("user", "promo"), name="unique_like" + ) + ], + }, + ), + ] diff --git a/promo_code/user/models.py b/promo_code/user/models.py index f4d3ae5..467f404 100644 --- a/promo_code/user/models.py +++ b/promo_code/user/models.py @@ -4,6 +4,7 @@ import django.db.models import django.utils.timezone +import business.models import user.constants @@ -82,3 +83,34 @@ def save(self, *args, **kwargs): self.last_login = django.utils.timezone.now() super().save(*args, **kwargs) + + +class PromoLike(django.db.models.Model): + id = django.db.models.UUIDField( + 'UUID', + primary_key=True, + default=uuid.uuid4, + editable=False, + ) + user = django.db.models.ForeignKey( + User, + on_delete=django.db.models.CASCADE, + related_name='promo_likes', + ) + promo = django.db.models.ForeignKey( + business.models.Promo, + on_delete=django.db.models.CASCADE, + related_name='likes', + ) + created_at = django.db.models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + django.db.models.UniqueConstraint( + fields=['user', 'promo'], + name='unique_like', + ), + ] + + def __str__(self): + return f'{self.user} likes {self.promo}' diff --git a/promo_code/user/serializers.py b/promo_code/user/serializers.py index fb9e625..60ec013 100644 --- a/promo_code/user/serializers.py +++ b/promo_code/user/serializers.py @@ -342,7 +342,10 @@ class PromoFeedSerializer(rest_framework.serializers.ModelSerializer): 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() + like_count = rest_framework.serializers.IntegerField( + source='get_like_count', + read_only=True, + ) comment_count = rest_framework.serializers.SerializerMethodField() class Meta: @@ -366,10 +369,6 @@ 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 @@ -403,7 +402,10 @@ class UserPromoDetailSerializer(rest_framework.serializers.ModelSerializer): read_only=True, ) is_activated_by_user = rest_framework.serializers.SerializerMethodField() - like_count = rest_framework.serializers.SerializerMethodField() + like_count = rest_framework.serializers.IntegerField( + source='get_like_count', + read_only=True, + ) is_liked_by_user = rest_framework.serializers.SerializerMethodField() comment_count = rest_framework.serializers.SerializerMethodField() @@ -423,15 +425,20 @@ class Meta: ) read_only_fields = fields - def get_is_activated_by_user(self, obj) -> bool: - # TODO: + 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_like_count(self, obj) -> int: - # TODO: - return 0 - - def get_is_liked_by_user(self, obj) -> bool: + def get_is_activated_by_user(self, obj) -> bool: # TODO: return False diff --git a/promo_code/user/urls.py b/promo_code/user/urls.py index 29f50e5..ef1c496 100644 --- a/promo_code/user/urls.py +++ b/promo_code/user/urls.py @@ -37,4 +37,9 @@ user.views.UserPromoDetailView.as_view(), name='user-promo-detail', ), + django.urls.path( + 'promo//like', + user.views.UserPromoLikeView.as_view(), + name='user-promo-like', + ), ] diff --git a/promo_code/user/views.py b/promo_code/user/views.py index 49ebe46..d0eb04d 100644 --- a/promo_code/user/views.py +++ b/promo_code/user/views.py @@ -1,14 +1,17 @@ import django.db.models +import django.shortcuts import django.utils.timezone import rest_framework.generics import rest_framework.permissions import rest_framework.response import rest_framework.status +import rest_framework.views import rest_framework_simplejwt.tokens import rest_framework_simplejwt.views import business.constants import business.models +import user.models import user.pagination import user.serializers @@ -224,3 +227,52 @@ def list(self, request, *args, **kwargs): self.validated_query_params = query_serializer.validated_data return super().list(request, *args, **kwargs) + + +class UserPromoLikeView(rest_framework.views.APIView): + permission_classes = [rest_framework.permissions.IsAuthenticated] + + def get_promo_object(self, promo_id): + return django.shortcuts.get_object_or_404( + business.models.Promo, + id=promo_id, + ) + + def post(self, request, id): + """Add a like to the promo code.""" + promo = self.get_promo_object(id) + + created = user.models.PromoLike.objects.get_or_create( + user=request.user, + promo=promo, + ) + + if created: + promo.like_count = django.db.models.F('like_count') + 1 + promo.save(update_fields=['like_count']) + + return rest_framework.response.Response( + {'status': 'ok'}, + status=rest_framework.status.HTTP_200_OK, + ) + + def delete(self, request, id): + """Remove a like from the promo code.""" + promo = self.get_promo_object(id) + + # Idempotency: if the like doesn't exist, + # do nothing and still return 200 OK. + like_instance = user.models.PromoLike.objects.filter( + user=request.user, + promo=promo, + ).first() + + if like_instance: + like_instance.delete() + promo.like_count = django.db.models.F('like_count') - 1 + promo.save(update_fields=['like_count']) + + return rest_framework.response.Response( + {'status': 'ok'}, + status=rest_framework.status.HTTP_200_OK, + )