diff --git a/promo_code/business/migrations/0004_promo_comment_count.py b/promo_code/business/migrations/0004_promo_comment_count.py new file mode 100644 index 0000000..c328d15 --- /dev/null +++ b/promo_code/business/migrations/0004_promo_comment_count.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-06-05 11:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("business", "0003_promo_like_count"), + ] + + operations = [ + migrations.AddField( + model_name="promo", + name="comment_count", + field=models.PositiveIntegerField(default=0, editable=False), + ), + ] diff --git a/promo_code/business/models.py b/promo_code/business/models.py index 89c63dd..9a86fe3 100644 --- a/promo_code/business/models.py +++ b/promo_code/business/models.py @@ -68,6 +68,10 @@ class Promo(django.db.models.Model): default=0, editable=False, ) + comment_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( @@ -107,6 +111,10 @@ def is_active(self) -> bool: def get_like_count(self) -> int: return self.like_count + @property + def get_comment_count(self) -> int: + return self.comment_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 c46f5f6..7d53e25 100644 --- a/promo_code/business/serializers.py +++ b/promo_code/business/serializers.py @@ -414,6 +414,10 @@ class PromoReadOnlySerializer(rest_framework.serializers.ModelSerializer): source='get_used_codes_count', read_only=True, ) + comment_count = rest_framework.serializers.IntegerField( + source='get_comment_count', + read_only=True, + ) active = rest_framework.serializers.BooleanField( source='is_active', read_only=True, @@ -435,6 +439,7 @@ class Meta: 'promo_common', 'promo_unique', 'like_count', + 'comment_count', 'used_count', 'active', ) @@ -479,6 +484,10 @@ class PromoDetailSerializer(rest_framework.serializers.ModelSerializer): source='get_like_count', read_only=True, ) + comment_count = rest_framework.serializers.IntegerField( + source='get_comment_count', + read_only=True, + ) used_count = rest_framework.serializers.IntegerField( source='get_used_codes_count', read_only=True, @@ -504,6 +513,7 @@ class Meta: 'company_name', 'active', 'like_count', + 'comment_count', 'used_count', ) diff --git a/promo_code/user/constants.py b/promo_code/user/constants.py index 951bd79..25e5a30 100644 --- a/promo_code/user/constants.py +++ b/promo_code/user/constants.py @@ -19,3 +19,6 @@ TARGET_CATEGORY_MIN_LENGTH = 2 TARGET_CATEGORY_MAX_LENGTH = 20 + +COMMENT_TEXT_MIN_LENGTH = 10 +COMMENT_TEXT_MAX_LENGTH = 1000 diff --git a/promo_code/user/migrations/0003_promocomment.py b/promo_code/user/migrations/0003_promocomment.py new file mode 100644 index 0000000..1a03523 --- /dev/null +++ b/promo_code/user/migrations/0003_promocomment.py @@ -0,0 +1,60 @@ +# Generated by Django 5.2 on 2025-06-04 14:26 + +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("business", "0003_promo_like_count"), + ("user", "0002_promolike"), + ] + + operations = [ + migrations.CreateModel( + name="PromoComment", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + verbose_name="UUID", + ), + ), + ("text", models.TextField(max_length=1000)), + ( + "created_at", + models.DateTimeField( + default=django.utils.timezone.now, editable=False + ), + ), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "promo", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to="business.promo", + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + ] diff --git a/promo_code/user/models.py b/promo_code/user/models.py index 467f404..8a25de9 100644 --- a/promo_code/user/models.py +++ b/promo_code/user/models.py @@ -114,3 +114,37 @@ class Meta: def __str__(self): return f'{self.user} likes {self.promo}' + + +class PromoComment(django.db.models.Model): + id = django.db.models.UUIDField( + 'UUID', + primary_key=True, + default=uuid.uuid4, + editable=False, + ) + promo = django.db.models.ForeignKey( + business.models.Promo, + on_delete=django.db.models.CASCADE, + related_name='comments', + ) + author = django.db.models.ForeignKey( + User, + on_delete=django.db.models.CASCADE, + related_name='comments', + ) + text = django.db.models.TextField( + max_length=user.constants.COMMENT_TEXT_MAX_LENGTH, + ) + + created_at = django.db.models.DateTimeField( + default=django.utils.timezone.now, + editable=False, + ) + updated_at = django.db.models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f'Comment by {self.author.email} on promo {self.promo.id}' diff --git a/promo_code/user/permissions.py b/promo_code/user/permissions.py new file mode 100644 index 0000000..032a1d2 --- /dev/null +++ b/promo_code/user/permissions.py @@ -0,0 +1,16 @@ +import rest_framework.permissions + + +class IsOwnerOrReadOnly(rest_framework.permissions.BasePermission): + """ + Custom permission to only allow owners of an object to edit or delete it. + Read-only for others. + """ + + def has_object_permission(self, request, view, obj): + # Read permissions are allowed to any request, + # so we'll always allow GET, HEAD or OPTIONS requests. + if request.method in rest_framework.permissions.SAFE_METHODS: + return True + + return obj.author == request.user diff --git a/promo_code/user/serializers.py b/promo_code/user/serializers.py index 60ec013..a6a504e 100644 --- a/promo_code/user/serializers.py +++ b/promo_code/user/serializers.py @@ -341,12 +341,15 @@ class PromoFeedSerializer(rest_framework.serializers.ModelSerializer): 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.IntegerField( source='get_like_count', read_only=True, ) - comment_count = rest_framework.serializers.SerializerMethodField() + 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 @@ -365,18 +368,23 @@ 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_is_liked_by_user(self, obj) -> bool: + def get_is_activated_by_user(self, obj) -> bool: # TODO: return False - def get_comment_count(self, obj) -> int: - # TODO: - return 0 - class UserPromoDetailSerializer(rest_framework.serializers.ModelSerializer): """ @@ -406,8 +414,11 @@ class UserPromoDetailSerializer(rest_framework.serializers.ModelSerializer): 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() - comment_count = rest_framework.serializers.SerializerMethodField() class Meta: model = business.models.Promo @@ -420,8 +431,8 @@ class Meta: 'active', 'is_activated_by_user', 'like_count', - 'is_liked_by_user', 'comment_count', + 'is_liked_by_user', ) read_only_fields = fields @@ -442,6 +453,64 @@ def get_is_activated_by_user(self, obj) -> bool: # TODO: return False - def get_comment_count(self, obj) -> int: - # TODO: - return 0 + +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 + fields = ('name', 'surname', 'avatar_url') + + +class CommentSerializer(rest_framework.serializers.ModelSerializer): + 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, + format='%Y-%m-%dT%H:%M:%S%z', + ) + 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: + model = user.models.PromoComment + fields = ('text',) + + +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',) diff --git a/promo_code/user/urls.py b/promo_code/user/urls.py index ef1c496..00b9b01 100644 --- a/promo_code/user/urls.py +++ b/promo_code/user/urls.py @@ -42,4 +42,14 @@ user.views.UserPromoLikeView.as_view(), name='user-promo-like', ), + django.urls.path( + 'promo//comments', + user.views.PromoCommentListCreateView.as_view(), + name='user-promo-comment-list-create', + ), + django.urls.path( + 'promo//comments/', + user.views.PromoCommentDetailView.as_view(), + name='user-promo-comment-detail', + ), ] diff --git a/promo_code/user/views.py b/promo_code/user/views.py index d0eb04d..e72d23f 100644 --- a/promo_code/user/views.py +++ b/promo_code/user/views.py @@ -1,6 +1,7 @@ import django.db.models import django.shortcuts import django.utils.timezone +import rest_framework.exceptions import rest_framework.generics import rest_framework.permissions import rest_framework.response @@ -13,6 +14,7 @@ import business.models import user.models import user.pagination +import user.permissions import user.serializers @@ -88,6 +90,8 @@ class UserPromoDetailView(rest_framework.generics.RetrieveAPIView): 'active_until', 'mode', 'used_count', + 'like_count', + 'comment_count', ) ) @@ -276,3 +280,114 @@ def delete(self, request, id): {'status': 'ok'}, status=rest_framework.status.HTTP_200_OK, ) + + +class PromoCommentListCreateView(rest_framework.generics.ListCreateAPIView): + permission_classes = [rest_framework.permissions.IsAuthenticated] + + pagination_class = user.pagination.UserFeedPagination + + def get_serializer_class(self): + if self.request.method == 'POST': + return user.serializers.CommentCreateSerializer + return user.serializers.CommentSerializer + + def get_queryset(self): + promo_id = self.kwargs.get('promo_id') + try: + promo = business.models.Promo.objects.get(pk=promo_id) + except business.models.Promo.DoesNotExist: + raise rest_framework.exceptions.NotFound(detail='Promo not found.') + + return user.models.PromoComment.objects.filter( + promo=promo, + ).select_related('author') + + def perform_create(self, serializer): + promo_id = self.kwargs.get('promo_id') + try: + promo = business.models.Promo.objects.get(pk=promo_id) + except business.models.Promo.DoesNotExist: + raise rest_framework.exceptions.ValidationError( + {'promo_id': 'Promo not found.'}, + ) + + serializer.save(author=self.request.user, promo=promo) + promo.comment_count = django.db.models.F('comment_count') + 1 + promo.save(update_fields=['comment_count']) + + def create(self, request, *args, **kwargs): + create_serializer = self.get_serializer(data=request.data) + create_serializer.is_valid(raise_exception=True) + self.perform_create(create_serializer) + response_serializer = user.serializers.CommentSerializer( + create_serializer.instance, + ) + headers = self.get_success_headers(response_serializer.data) + return rest_framework.response.Response( + response_serializer.data, + status=rest_framework.status.HTTP_201_CREATED, + headers=headers, + ) + + def list(self, request, *args, **kwargs): + query_serializer = user.serializers.UserFeedQuerySerializer( + data=request.query_params, + ) + query_serializer.is_valid(raise_exception=True) + + return super().list(request, *args, **kwargs) + + +class PromoCommentDetailView( + rest_framework.generics.RetrieveUpdateDestroyAPIView, +): + permission_classes = [ + rest_framework.permissions.IsAuthenticated, + user.permissions.IsOwnerOrReadOnly, + ] + lookup_url_kwarg = 'comment_id' + + http_method_names = ['get', 'put', 'delete', 'options', 'head'] + + def get_serializer_class(self): + if self.request.method == 'PUT': + return user.serializers.CommentUpdateSerializer + return user.serializers.CommentSerializer + + def get_queryset(self): + promo_id = self.kwargs.get('promo_id') + try: + promo = business.models.Promo.objects.get(pk=promo_id) + except business.models.Promo.DoesNotExist: + raise rest_framework.exceptions.NotFound(detail='Promo not found.') + return user.models.PromoComment.objects.filter( + promo=promo, + ).select_related('author') + + def update(self, request, *args, **kwargs): + partial = kwargs.pop('partial', False) + instance = self.get_object() + update_serializer = self.get_serializer( + instance, + data=request.data, + partial=partial, + ) + update_serializer.is_valid(raise_exception=True) + self.perform_update(update_serializer) + + response_serializer = user.serializers.CommentSerializer(instance) + return rest_framework.response.Response(response_serializer.data) + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + self.perform_destroy(instance) + + promo = instance.promo + promo.comment_count = django.db.models.F('comment_count') - 1 + promo.save(update_fields=['comment_count']) + + return rest_framework.response.Response( + {'status': 'ok'}, + status=rest_framework.status.HTTP_200_OK, + )