Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions apps/properties/migrations/0003_reviews.py
Original file line number Diff line number Diff line change
@@ -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')],
},
),
]
24 changes: 24 additions & 0 deletions apps/properties/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
]
26 changes: 26 additions & 0 deletions apps/properties/permissions.py
Original file line number Diff line number Diff line change
@@ -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
25 changes: 19 additions & 6 deletions apps/properties/serializers/property_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
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:
Expand Down Expand Up @@ -42,6 +44,14 @@ 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):
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_average_rating() performs an aggregate query per property instance. When serializing a list of properties, this will create an N+1 query pattern. Consider annotating the queryset with the average rating (or using prefetch_related + aggregation) in the view so the rating can be returned without per-row queries.

Suggested change
def get_average_rating(self, obj):
def get_average_rating(self, obj):
if hasattr(obj, "average_rating"):
return obj.average_rating

Copilot uses AI. Check for mistakes.
if hasattr(obj, "average_rating"):
return obj.average_rating

result = obj.reviews.aggregate(Avg("rating"))
return result["rating__avg"]

class Meta:
model = Properties
Expand Down Expand Up @@ -75,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)
Expand All @@ -96,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")
Expand Down
34 changes: 34 additions & 0 deletions apps/properties/serializers/reviews_serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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 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
4 changes: 4 additions & 0 deletions apps/properties/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,8 @@
path("<int:pk>/", property_views.RUDPropertyView.as_view()),
path("<int:pk>/photos/", photo_views.UploadPhotoPropertyView.as_view()),
path("photos/<int:pk>/", photo_views.RUDPhotoPropertyView.as_view()),
#path("search/filters/", property_views.FilterPropertyView.as_view()),
path("search/", property_views.SearchPropertyAIView.as_view()),
path("<int:pk>/reviews/", property_views.CreateListReviewPropertyView.as_view()),
path("<int:pk>/reviews/<int:review_pk>/", property_views.RUDReviewPropertyView.as_view()),
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This route provides review_pk, but the corresponding view currently uses lookup_field = "pk" and doesn't reference review_pk, so the endpoint won't resolve the intended review. Align the view's lookup kwarg/field with this URL pattern (and ensure it queries Reviews, not Properties).

Suggested change
path("<int:pk>/reviews/<int:review_pk>/", property_views.RUDReviewPropertyView.as_view()),
path("<int:property_pk>/reviews/<int:pk>/", property_views.RUDReviewPropertyView.as_view()),

Copilot uses AI. Check for mistakes.
]
12 changes: 12 additions & 0 deletions apps/properties/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
84 changes: 46 additions & 38 deletions apps/properties/views/property_views.py
Original file line number Diff line number Diff line change
@@ -1,42 +1,51 @@
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
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.filters import PropertiesFilters
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 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, obj):
return (
request.user.is_authenticated and
request.user.user_type == "A"
)
class CreateListReviewPropertyView(generics.ListCreateAPIView):
serializer_class = ReviewsSerializer

class IsPropertyOwner(BasePermission):
message = "You do not have permission to do this action."
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 = 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(), IsReviewOwner()]
return [AllowAny()]
Comment on lines +39 to +47
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RUDReviewPropertyView is wired as a review detail endpoint, but it currently queries Properties and looks up by pk, while the URL provides review_pk. This will retrieve the wrong model (or 404) and will also fail because no serializer_class/get_serializer_class is defined for this view. Update it to operate on Reviews (and filter by the parent property), align the lookup kwarg with review_pk, and use object-level permissions appropriate for review ownership (e.g., IsReviewOwner) for update/delete.

Copilot uses AI. Check for mistakes.

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

if owner != request.user:
return False
return True

class CreateListPropertyView(generics.ListCreateAPIView):
queryset = Properties.objects.all().order_by("created_at")
filterset_class = PropertiesFilters
Expand All @@ -48,8 +57,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)
Expand All @@ -65,16 +74,15 @@ 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):
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
19 changes: 19 additions & 0 deletions apps/users/migrations/0004_user_favorites.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
10 changes: 5 additions & 5 deletions apps/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Binary file modified requirements.txt
Binary file not shown.
Loading