From b20874adb00c31a99b5e15c207f78b4c2e2c1e51 Mon Sep 17 00:00:00 2001 From: DevlTz Date: Wed, 1 Apr 2026 17:50:17 -0300 Subject: [PATCH 01/13] feat(properties): add Reviews model with UniqueConstraint --- apps/properties/models.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/properties/models.py b/apps/properties/models.py index e5b41bc..808236f 100644 --- a/apps/properties/models.py +++ b/apps/properties/models.py @@ -75,3 +75,27 @@ class PropertiesPhotos(models.Model): ) r2_key = models.TextField(null=False, blank=False) order = models.IntegerField() + +class Reviews(models.Model): + property = models.ForeignKey( + Properties, + on_delete=models.CASCADE, + related_name="reviews" + ) + user = models.ForeignKey( + "users.User", + on_delete=models.CASCADE, + related_name="reviews" + ) + rating = models.IntegerField() + comment = models.TextField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "reviews" + constraints = [ + models.UniqueConstraint( + fields=["property", "user"], + name="unique_review_per_user_per_property" + ) + ] From 86c303565d334b24043ac657f9b474ff42a57d3b Mon Sep 17 00:00:00 2001 From: DevlTz Date: Wed, 1 Apr 2026 17:50:23 -0300 Subject: [PATCH 02/13] feat(properties): add validate_rating and validate_comment_length validators --- apps/properties/validators.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/properties/validators.py b/apps/properties/validators.py index b274b17..15bedee 100644 --- a/apps/properties/validators.py +++ b/apps/properties/validators.py @@ -9,3 +9,15 @@ def validate_positive_number(value, field): if value < 0: raise serializers.ValidationError(f"Invalid {field}") return value + +def validate_rating(value): + if value < 1 or value > 5: + raise serializers.ValidationError("Rating must be between 1 and 5.") + return value + +def validate_comment_length(value): + if value and len(value) < 10: + raise serializers.ValidationError("Comment must be at least 10 characters.") + if value and len(value) > 1000: + raise serializers.ValidationError("Comment must be less than 1000 characters.") + return value \ No newline at end of file From ecb455369f3ffe633eeaef4278c1cb6f1d5575e2 Mon Sep 17 00:00:00 2001 From: DevlTz Date: Wed, 1 Apr 2026 17:51:27 -0300 Subject: [PATCH 03/13] feat(properties): add CreateListReviewPropertyView and RUDReviewPropertyView --- apps/properties/views/property_views.py | 60 ++++++++++++++++++++----- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/apps/properties/views/property_views.py b/apps/properties/views/property_views.py index 0f10166..7b6e12b 100644 --- a/apps/properties/views/property_views.py +++ b/apps/properties/views/property_views.py @@ -1,13 +1,14 @@ +from apps.properties.serializers.reviews_serializers import ReviewsSerializer from rest_framework.views import APIView -from rest_framework import generics +from rest_framework import generics, status, serializers from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.permissions import BasePermission from rest_framework.exceptions import PermissionDenied from apps.properties.serializers.property_serializers import PropertiesWriteSerializer, PropertiesReadSerializer -from apps.properties.models import Properties +from apps.properties.models import Properties, Reviews from apps.properties.filters import PropertiesFilters from rest_framework.response import Response - +from django.shortcuts import get_object_or_404 # C -> Create # R -> Read @@ -16,11 +17,49 @@ class IsAdvertiser(BasePermission): message = "You do not have permission to do this action. Please, change your account type to advertise!" - def has_permission(self, request, view, obj): + def has_permission(self, request, view): return ( request.user.is_authenticated and request.user.user_type == "A" ) + +class IsReviewOwner(BasePermission): + message = "You only can edit or delete your own reviews." + def has_object_permission(self, request, view, obj): + return obj.user == request.user + +class CreateListReviewPropertyView(generics.ListCreateAPIView): + serializer_class = ReviewsSerializer + + def get_permissions(self): + if self.request.method == "GET": + return [AllowAny()] + return [IsAuthenticated()] + + def get_queryset(self): + return Reviews.objects.filter( + property_id=self.kwargs["pk"] + ).order_by("-created_at") + + def get_serializer_context(self): + context = super().get_serializer_context() + context["property_id"] = self.kwargs["pk"] + return context + + def perform_create(self, serializer): + property_obj = get_object_or_404(Properties, pk=self.kwargs["pk"]) + serializer.save(user=self.request.user, property=property_obj) + + +class RUDReviewPropertyView(generics.RetrieveUpdateDestroyAPIView): + queryset = Properties.objects.all() + lookup_field = "pk" + + def get_permissions(self): + if self.request.method in ["PUT", "PATCH", "DELETE"]: + return [IsAuthenticated(), IsPropertyOwner()] + return [AllowAny()] + class IsPropertyOwner(BasePermission): message = "You do not have permission to do this action." @@ -32,10 +71,8 @@ def has_object_permission(self, request, view, obj): owner = obj.property.owner else: return False - - if owner != request.user: - return False - return True + return owner == request.user + class CreateListPropertyView(generics.ListCreateAPIView): queryset = Properties.objects.all().order_by("created_at") @@ -48,8 +85,8 @@ def get_serializer_class(self): def get_permissions(self): if self.request.method == "POST": - return [IsAuthenticated, IsAdvertiser] - return [AllowAny] + return [IsAuthenticated(), IsAdvertiser()] + return [AllowAny()] def perform_create(self, serializer): serializer.save(owner_id=self.request.user.id) @@ -65,7 +102,7 @@ def get_serializer_class(self): def get_permissions(self): if self.request.method in ["PUT", "PATCH", "DELETE"]: - return [IsAuthenticated, IsPropertyOwner] + return [IsAuthenticated(), IsPropertyOwner()] return [AllowAny()] def destroy(self, request, *args, **kwargs): @@ -75,6 +112,5 @@ def destroy(self, request, *args, **kwargs): "message": "Delete successfull!" }, status=204) - class SearchPropertyAIView(APIView): pass From 04bf4c6040209ea9e5d67629d78eacf7de9f74b7 Mon Sep 17 00:00:00 2001 From: DevlTz Date: Wed, 1 Apr 2026 17:51:36 -0300 Subject: [PATCH 04/13] feat(properties): add reviews endpoints to urls --- apps/properties/urls.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/properties/urls.py b/apps/properties/urls.py index d8ccf5d..4a7baac 100644 --- a/apps/properties/urls.py +++ b/apps/properties/urls.py @@ -6,4 +6,8 @@ path("/", property_views.RUDPropertyView.as_view()), path("/photos/", photo_views.UploadPhotoPropertyView.as_view()), path("photos//", photo_views.RUDPhotoPropertyView.as_view()), + #path("search/filters/", property_views.FilterPropertyView.as_view()), + path("search/", property_views.SearchPropertyAIView.as_view()), + path("/reviews/", property_views.CreateListReviewPropertyView.as_view()), + path("/reviews//", property_views.RUDReviewPropertyView.as_view()), ] From 5a16eea9276ad172736d0107d6161306d7125871 Mon Sep 17 00:00:00 2001 From: DevlTz Date: Wed, 1 Apr 2026 17:51:50 -0300 Subject: [PATCH 05/13] feat(properties): migration for Reviews model --- apps/properties/migrations/0003_reviews.py | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 apps/properties/migrations/0003_reviews.py diff --git a/apps/properties/migrations/0003_reviews.py b/apps/properties/migrations/0003_reviews.py new file mode 100644 index 0000000..3de6078 --- /dev/null +++ b/apps/properties/migrations/0003_reviews.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2 on 2026-04-01 20:47 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('properties', '0002_rename_property_id_propertiesphotos_property'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Reviews', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rating', models.IntegerField()), + ('comment', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='properties.properties')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'reviews', + 'constraints': [models.UniqueConstraint(fields=('property', 'user'), name='unique_review_per_user_per_property')], + }, + ), + ] From a498dbadb1faa3652d3a247d621765e50a0ea495 Mon Sep 17 00:00:00 2001 From: DevlTz Date: Wed, 1 Apr 2026 17:52:01 -0300 Subject: [PATCH 06/13] feat(properties): add average_rating field to PropertiesReadSerializer --- apps/properties/serializers/property_serializers.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/properties/serializers/property_serializers.py b/apps/properties/serializers/property_serializers.py index f34369e..73f9d52 100644 --- a/apps/properties/serializers/property_serializers.py +++ b/apps/properties/serializers/property_serializers.py @@ -1,7 +1,10 @@ +from unittest import result from rest_framework import serializers from apps.properties.models import Condo, Properties, Rooms, RoomsExtras from apps.properties.validators import validate_positive_number, validate_required_field from apps.properties.serializers.photo_serializers import PropertiesPhotosSerializer +from django.db.models import Avg + class RoomsExtrasSerializer(serializers.ModelSerializer): class Meta: @@ -42,6 +45,11 @@ class PropertiesReadSerializer(serializers.ModelSerializer): condo = CondoSerializer() rooms_extras = RoomsExtrasSerializer() images = PropertiesPhotosSerializer(many=True, read_only=True, source="photos") + average_rating = serializers.SerializerMethodField() + + def get_average_rating(self, obj): + result = obj.reviews.aggregate(Avg("rating")) + return result["rating__avg"] class Meta: model = Properties From 14a6847984c2e339ceac40cd90affe2be92a2d10 Mon Sep 17 00:00:00 2001 From: DevlTz Date: Wed, 1 Apr 2026 17:52:09 -0300 Subject: [PATCH 07/13] feat(properties): add ReviewsSerializer --- .../serializers/reviews_serializers.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 apps/properties/serializers/reviews_serializers.py diff --git a/apps/properties/serializers/reviews_serializers.py b/apps/properties/serializers/reviews_serializers.py new file mode 100644 index 0000000..82d7353 --- /dev/null +++ b/apps/properties/serializers/reviews_serializers.py @@ -0,0 +1,24 @@ +from apps.properties.validators import validate_rating, validate_comment_length +from rest_framework import serializers +from apps.properties.models import Reviews + +class ReviewsSerializer(serializers.ModelSerializer): + user_name = serializers.CharField(source="user.name", read_only=True) + + class Meta: + model = Reviews + fields = ['id', 'user_name', 'rating', 'comment', 'created_at'] + read_only_fields = ['id', 'user_name', 'created_at'] + + def validate_rating(self, value): + return validate_rating(value) + + def validate_comment(self, value): + return validate_comment_length(value) + + def validate(self, data): + request = self.context.get("request") + property_id = self.context.get("property_id") + if Reviews.objects.filter(user=request.user, property_id=property_id).exists(): + raise serializers.ValidationError("You have already reviewed this property.") + return data \ No newline at end of file From 7d94b24d70e71d44ac14f23ad1dc7ab3e49dab14 Mon Sep 17 00:00:00 2001 From: DevlTz Date: Tue, 7 Apr 2026 18:06:49 -0300 Subject: [PATCH 08/13] fix(deps):fix version of boto3 dependency --- requirements.txt | Bin 472 -> 454 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index 91cd1ba59361492e1d07ad77b3c7a985751f3bd3..00b7ba56551afbb21448cb0c8ef45a2bc3af59eb 100644 GIT binary patch delta 7 Ocmcb?e2jU+F-8Clhyxq| delta 26 gcmX@ce1mzzF-9R<23rP020aE71|uM8&S1s>09>*K2mk;8 From 05c13c483059111230efcc953005d8460506db42 Mon Sep 17 00:00:00 2001 From: DevlTz Date: Tue, 7 Apr 2026 18:06:56 -0300 Subject: [PATCH 09/13] feat(properties): centralize custom permissions for reviews and properties --- apps/properties/permissions.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 apps/properties/permissions.py diff --git a/apps/properties/permissions.py b/apps/properties/permissions.py new file mode 100644 index 0000000..9ab915d --- /dev/null +++ b/apps/properties/permissions.py @@ -0,0 +1,26 @@ +from rest_framework import permissions + +class IsAdvertiser(permissions.BasePermission): + message = "You do not have permission to do this action. Please, change your account type to advertise!" + + def has_permission(self, request, view): + return ( + request.user.is_authenticated and + request.user.user_type == "A" + ) + +class IsReviewOwner(permissions.BasePermission): + message = "You only can edit or delete your own reviews." + + def has_object_permission(self, request, view, obj): + return obj.user == request.user + +class IsPropertyOwner(permissions.BasePermission): + message = "You do not have permission to do this action." + + def has_object_permission(self, request, view, obj): + if hasattr(obj, "owner"): + return obj.owner == request.user + if hasattr(obj, "property"): + return obj.property.owner == request.user + return False \ No newline at end of file From 31aef89d5f964ab44fdcae5b75e3a6227b77ef03 Mon Sep 17 00:00:00 2001 From: DevlTz Date: Tue, 7 Apr 2026 18:07:05 -0300 Subject: [PATCH 10/13] fix(serializers): optimize average_rating and fix review duplicate validation --- .../serializers/property_serializers.py | 19 ++++++++++++------- .../serializers/reviews_serializers.py | 12 +++++++++++- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/apps/properties/serializers/property_serializers.py b/apps/properties/serializers/property_serializers.py index 73f9d52..cde973c 100644 --- a/apps/properties/serializers/property_serializers.py +++ b/apps/properties/serializers/property_serializers.py @@ -1,4 +1,3 @@ -from unittest import result from rest_framework import serializers from apps.properties.models import Condo, Properties, Rooms, RoomsExtras from apps.properties.validators import validate_positive_number, validate_required_field @@ -48,6 +47,9 @@ class PropertiesReadSerializer(serializers.ModelSerializer): average_rating = serializers.SerializerMethodField() def get_average_rating(self, obj): + if hasattr(obj, "average_rating"): + return obj.average_rating + result = obj.reviews.aggregate(Avg("rating")) return result["rating__avg"] @@ -83,16 +85,20 @@ def create(self, validated_data): def update(self, instance, validated_data): rooms_data = validated_data.pop('rooms', {}) - condo_data = validated_data.pop('condo', {}) + condo_data = validated_data.pop('condo', None) rooms_extras_data = validated_data.pop('rooms_extras', {}) for attr, value in rooms_data.items(): setattr(instance.rooms, attr, value) instance.rooms.save() - - for attr, value in condo_data.items(): - setattr(instance.condo, attr, value) - instance.condo.save() + + if condo_data: + condo_obj, _ = Condo.objects.update_or_create( + id=instance.condo.id if instance.condo else None, + defaults=condo_data + ) + instance.condo = condo_obj + instance.save() for attr, value in rooms_extras_data.items(): setattr(instance.rooms_extras, attr, value) @@ -104,7 +110,6 @@ def update(self, instance, validated_data): return instance - def validate(self, data): if data.get("type") == "A" and not data.get("floor_number"): raise serializers.ValidationError("A floor number for an apartment is necessary") diff --git a/apps/properties/serializers/reviews_serializers.py b/apps/properties/serializers/reviews_serializers.py index 82d7353..4166809 100644 --- a/apps/properties/serializers/reviews_serializers.py +++ b/apps/properties/serializers/reviews_serializers.py @@ -19,6 +19,16 @@ def validate_comment(self, value): def validate(self, data): request = self.context.get("request") property_id = self.context.get("property_id") - if Reviews.objects.filter(user=request.user, property_id=property_id).exists(): + + if not property_id and self.instance: + property_id = self.instance.property_id + + if not property_id: + return data + # Se estamos editando, self.instance não será Nones + queryset = Reviews.objects.filter(user=request.user, property_id=property_id) + if self.instance: + queryset = queryset.exclude(pk=self.instance.pk) + if queryset.exists(): raise serializers.ValidationError("You have already reviewed this property.") return data \ No newline at end of file From 419b305fcc8b3909bba365079109f17260ab0d6d Mon Sep 17 00:00:00 2001 From: DevlTz Date: Tue, 7 Apr 2026 18:07:09 -0300 Subject: [PATCH 11/13] fix(views): cleanup unused imports and fix review detail lookup_url_kwarg --- apps/properties/views/property_views.py | 60 +++++++------------------ 1 file changed, 16 insertions(+), 44 deletions(-) diff --git a/apps/properties/views/property_views.py b/apps/properties/views/property_views.py index 7b6e12b..872c6aa 100644 --- a/apps/properties/views/property_views.py +++ b/apps/properties/views/property_views.py @@ -1,33 +1,19 @@ -from apps.properties.serializers.reviews_serializers import ReviewsSerializer +from django.shortcuts import get_object_or_404 +from rest_framework import generics, status from rest_framework.views import APIView -from rest_framework import generics, status, serializers -from rest_framework.permissions import IsAuthenticated, AllowAny -from rest_framework.permissions import BasePermission -from rest_framework.exceptions import PermissionDenied -from apps.properties.serializers.property_serializers import PropertiesWriteSerializer, PropertiesReadSerializer +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated, AllowAny, BasePermission from apps.properties.models import Properties, Reviews from apps.properties.filters import PropertiesFilters -from rest_framework.response import Response -from django.shortcuts import get_object_or_404 +from apps.properties.serializers.property_serializers import PropertiesWriteSerializer, PropertiesReadSerializer +from apps.properties.serializers.reviews_serializers import ReviewsSerializer +from apps.properties.permissions import IsAdvertiser, IsReviewOwner, IsPropertyOwner # C -> Create # R -> Read # U -> Update # D -> Delete -class IsAdvertiser(BasePermission): - message = "You do not have permission to do this action. Please, change your account type to advertise!" - def has_permission(self, request, view): - return ( - request.user.is_authenticated and - request.user.user_type == "A" - ) - -class IsReviewOwner(BasePermission): - message = "You only can edit or delete your own reviews." - def has_object_permission(self, request, view, obj): - return obj.user == request.user - class CreateListReviewPropertyView(generics.ListCreateAPIView): serializer_class = ReviewsSerializer @@ -50,30 +36,16 @@ def perform_create(self, serializer): property_obj = get_object_or_404(Properties, pk=self.kwargs["pk"]) serializer.save(user=self.request.user, property=property_obj) - class RUDReviewPropertyView(generics.RetrieveUpdateDestroyAPIView): - queryset = Properties.objects.all() - lookup_field = "pk" + queryset = Reviews.objects.all() + serializer_class = ReviewsSerializer + lookup_url_kwarg = "review_pk" def get_permissions(self): if self.request.method in ["PUT", "PATCH", "DELETE"]: - return [IsAuthenticated(), IsPropertyOwner()] + return [IsAuthenticated(), IsReviewOwner()] return [AllowAny()] - -class IsPropertyOwner(BasePermission): - message = "You do not have permission to do this action." - - def has_object_permission(self, request, view, obj): - if hasattr(obj, "owner"): - owner = obj.owner - elif hasattr(obj, "property"): - owner = obj.property.owner - else: - return False - return owner == request.user - - class CreateListPropertyView(generics.ListCreateAPIView): queryset = Properties.objects.all().order_by("created_at") filterset_class = PropertiesFilters @@ -106,11 +78,11 @@ def get_permissions(self): return [AllowAny()] def destroy(self, request, *args, **kwargs): - self.perform_destroy(self.get_object()) - + instance = self.get_object() + self.perform_destroy(instance) return Response({ - "message": "Delete successfull!" - }, status=204) + "message": "Delete successful!" + }, status=status.HTTP_204_NO_CONTENT) class SearchPropertyAIView(APIView): - pass + pass \ No newline at end of file From e0a9537b23fd67900c2b8fd5bdd74e7981b7bc1c Mon Sep 17 00:00:00 2001 From: DevlTz Date: Tue, 7 Apr 2026 18:07:17 -0300 Subject: [PATCH 12/13] feat(users): reactivate property favorites relation --- apps/users/models.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/users/models.py b/apps/users/models.py index 6712b4e..9427b28 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -44,11 +44,11 @@ class UserType(models.TextChoices): gender = models.CharField(max_length=50, null=True, blank=True) - # favorites = models.ManyToManyField( - # 'properties.Properties', - # related_name='favorited_by', - # blank=True - # ) + favorites = models.ManyToManyField( + 'properties.Properties', + related_name='favorited_by', + blank=True + ) class Meta: db_table = "users" From 9bb7a42ebfa933404c3c3f949404f8ae39f3cfa5 Mon Sep 17 00:00:00 2001 From: DevlTz Date: Tue, 7 Apr 2026 18:07:28 -0300 Subject: [PATCH 13/13] feat(users): add favorites field and migrations --- apps/users/migrations/0004_user_favorites.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 apps/users/migrations/0004_user_favorites.py diff --git a/apps/users/migrations/0004_user_favorites.py b/apps/users/migrations/0004_user_favorites.py new file mode 100644 index 0000000..9df1f06 --- /dev/null +++ b/apps/users/migrations/0004_user_favorites.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2 on 2026-04-07 21:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('properties', '0003_reviews'), + ('users', '0003_alter_user_managers'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='favorites', + field=models.ManyToManyField(blank=True, related_name='favorited_by', to='properties.properties'), + ), + ]