From 00793a1f90d8452362174af6ad2063d5dff680f8 Mon Sep 17 00:00:00 2001 From: Anthony Li Date: Mon, 20 Apr 2026 06:09:22 -0400 Subject: [PATCH 1/2] add user ratings --- backend/market/models.py | 30 ++++++++++++++++++-- backend/market/serializers.py | 53 ++++++++++++++++++++++++++++++++++- backend/market/urls.py | 20 +++++++++++++ backend/market/views.py | 37 ++++++++++++++++++++++-- 4 files changed, 135 insertions(+), 5 deletions(-) diff --git a/backend/market/models.py b/backend/market/models.py index 7812865..a759a90 100644 --- a/backend/market/models.py +++ b/backend/market/models.py @@ -5,7 +5,7 @@ from django.conf import settings from django.contrib.auth.models import AbstractUser from django.core.exceptions import ValidationError -from django.core.validators import MinValueValidator +from django.core.validators import MinValueValidator, MaxValueValidator from django.db import models from phonenumber_field.modelfields import PhoneNumberField @@ -63,7 +63,6 @@ class Tag(models.Model): def __str__(self): return self.name - class Listing(models.Model): class Meta: indexes = [ @@ -177,3 +176,30 @@ def approximate_location(self): def save(self, *args, **kwargs): self.full_clean() super().save(*args, **kwargs) + +class Rating(models.Model): + class RatingType(models.TextChoices): + BUYER = "BUYER", "Buyer Rating" + SELLER = "SELLER", "Seller Rating" + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["reviewer", "reviewed_user", "listing"], name="unique_rating_market") + ] + indexes = [ + models.Index(fields=["reviewer"]), + models.Index(fields=["reviewed_user"]), + models.Index(fields=["listing"]), + models.Index(fields=["created_at"]), + ] + + reviewer = models.ForeignKey(User, on_delete=models.CASCADE, related_name="ratings_given") + reviewed_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="ratings_received") + listing = models.ForeignKey(Listing, on_delete=models.CASCADE, related_name="ratings") + score = models.PositiveIntegerField(validators=[MinValueValidator(1), MaxValueValidator(5)]) + rating_type = models.CharField(max_length=10, choices=RatingType.choices, default=RatingType.BUYER) + comment = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Rating of {self.score} for {self.reviewed_user} by {self.reviewer}" \ No newline at end of file diff --git a/backend/market/serializers.py b/backend/market/serializers.py index c129131..c6a426f 100644 --- a/backend/market/serializers.py +++ b/backend/market/serializers.py @@ -14,7 +14,7 @@ ) from market.mixins import ListingTypeMixin -from market.models import Category, Item, Listing, ListingImage, Offer, Sublet, Tag +from market.models import Category, Item, Listing, ListingImage, Offer, Rating, Sublet, Tag User = get_user_model() @@ -443,3 +443,54 @@ class Meta: def get_favorite_count(self, obj): return obj.favorites.count() + +class RatingSerializer(ModelSerializer): + reviewer = UserSerializer(read_only=True) + + class Meta: + model = Rating + fields = [ + "id", + "reviewer", + "reviewed_user", + "listing", + "score", + "rating_type", + "comment", + "created_at" + ] + read_only_fields = ["id", "created_at", "reviewer", "rating_type"] + + def validate(self, attr): + reviewer = self.context["request"].user + reviewed_user = attr["reviewed_user"] + listing = attr["listing"] + + if reviewer == reviewed_user: + raise ValidationError("You cannot review yourself.") + + is_seller = listing.seller == reviewer + is_buyer = listing.offers_received.filter(user=reviewer).exists() + if not is_seller and not is_buyer: + raise ValidationError("You can only rate users on listings you have interacted with.") + + target_is_seller = listing.seller == reviewed_user + target_is_buyer = listing.offers_received.filter(user=reviewed_user).exists() + if not target_is_seller and not target_is_buyer: + raise ValidationError("You cannot rate a user who is not on either side of the transaction.") + attr["rating_type"] = "SELLER" if is_seller else "BUYER" + + return attr + + + def validate_comment(self, value): + if self.contains_profanity(value): + raise ValidationError("The comment contains inappropriate language.") + return value + + def contains_profanity(self, text): + return predict([text])[0] + + def create(self, validated_data): + validated_data["reviewer"] = self.context["request"].user + return super().create(validated_data) \ No newline at end of file diff --git a/backend/market/urls.py b/backend/market/urls.py index b00a13c..524c6e8 100644 --- a/backend/market/urls.py +++ b/backend/market/urls.py @@ -9,8 +9,11 @@ Offers, OffersMade, OffersReceived, + Ratings, Tags, + UserBuyerRatings, UserFavorites, + UserSellerRatings, get_current_user, get_phone_status, send_verification_code, @@ -49,6 +52,23 @@ "listings//offers/", Offers.as_view({"get": "list", "post": "create", "delete": "destroy"}), ), + # Ratings for a listing (list + create) + path( + "listings//ratings/", + Ratings.as_view({"get": "list", "post": "create"}), + name="listing-ratings", + ), + # Current user's received ratings by type + path( + "user/ratings/buyer/", + UserBuyerRatings.as_view(), + name="user-ratings-buyer", + ), + path( + "user/ratings/seller/", + UserSellerRatings.as_view(), + name="user-ratings-seller", + ), # Image Creation path("listings//images/", CreateImages.as_view()), # Image Deletion diff --git a/backend/market/views.py b/backend/market/views.py index c85974c..24cbd91 100644 --- a/backend/market/views.py +++ b/backend/market/views.py @@ -16,7 +16,7 @@ from rest_framework.response import Response from market.mixins import DefaultOrderMixin -from market.models import Listing, ListingImage, Offer, Tag +from market.models import Listing, ListingImage, Offer, Rating, Tag from market.pagination import PageSizeOffsetPagination from market.permissions import ( IsSuperUser, @@ -31,6 +31,7 @@ ListingSerializerList, ListingSerializerPublic, OfferSerializer, + RatingSerializer, TagSerializer, UserSerializer, ) @@ -189,7 +190,7 @@ def retrieve(self, request, *args, **kwargs): serializer_class = ListingSerializer else: serializer_class = ListingSerializerPublic - serializer = serializer_class(instance) + serializer = serializer_class(instance, context={"request": request}) return Response(serializer.data) @@ -342,6 +343,38 @@ def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) +class Ratings(mixins.CreateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): + serializer_class = RatingSerializer + permission_classes = [IsAuthenticated | IsSuperUser] + + def get_queryset(self): + return Rating.objects.filter(listing_id=self.kwargs["listing_id"]) + + def create(self, request, *args, **kwargs): + data = request.data.copy() + data["listing"] = int(self.kwargs["listing_id"]) + serializer = self.get_serializer(data=data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + +class UserBuyerRatings(ListAPIView, DefaultOrderMixin): + serializer_class = RatingSerializer + permission_classes = [IsAuthenticated | IsSuperUser] + default_ordering = ["-created_at"] + + def get_queryset(self): + return Rating.objects.filter(reviewed_user=self.request.user, rating_type="BUYER") + +class UserSellerRatings(ListAPIView, DefaultOrderMixin): + serializer_class = RatingSerializer + permission_classes = [IsAuthenticated | IsSuperUser] + default_ordering = ["-created_at"] + + def get_queryset(self): + return Rating.objects.filter(reviewed_user=self.request.user, rating_type="SELLER") + + @api_view(["POST"]) @permission_classes([IsAuthenticated]) def send_verification_code(request): From ec5717fe5b830945f3e5682a192dcc3333f5b322 Mon Sep 17 00:00:00 2001 From: Anthony Li Date: Mon, 20 Apr 2026 06:30:44 -0400 Subject: [PATCH 2/2] fix style check --- backend/market/models.py | 33 ++++++++++++++++++++--------- backend/market/serializers.py | 39 +++++++++++++++++++++++------------ backend/market/views.py | 10 ++++++--- 3 files changed, 56 insertions(+), 26 deletions(-) diff --git a/backend/market/models.py b/backend/market/models.py index a759a90..ace4ac5 100644 --- a/backend/market/models.py +++ b/backend/market/models.py @@ -5,7 +5,7 @@ from django.conf import settings from django.contrib.auth.models import AbstractUser from django.core.exceptions import ValidationError -from django.core.validators import MinValueValidator, MaxValueValidator +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from phonenumber_field.modelfields import PhoneNumberField @@ -181,10 +181,13 @@ class Rating(models.Model): class RatingType(models.TextChoices): BUYER = "BUYER", "Buyer Rating" SELLER = "SELLER", "Seller Rating" - + class Meta: constraints = [ - models.UniqueConstraint(fields=["reviewer", "reviewed_user", "listing"], name="unique_rating_market") + models.UniqueConstraint( + fields=["reviewer", "reviewed_user", "listing"], + name="unique_rating_market", + ) ] indexes = [ models.Index(fields=["reviewer"]), @@ -193,13 +196,23 @@ class Meta: models.Index(fields=["created_at"]), ] - reviewer = models.ForeignKey(User, on_delete=models.CASCADE, related_name="ratings_given") - reviewed_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="ratings_received") - listing = models.ForeignKey(Listing, on_delete=models.CASCADE, related_name="ratings") - score = models.PositiveIntegerField(validators=[MinValueValidator(1), MaxValueValidator(5)]) - rating_type = models.CharField(max_length=10, choices=RatingType.choices, default=RatingType.BUYER) + reviewer = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="ratings_given" + ) + reviewed_user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="ratings_received" + ) + listing = models.ForeignKey( + Listing, on_delete=models.CASCADE, related_name="ratings" + ) + score = models.PositiveIntegerField( + validators=[MinValueValidator(1), MaxValueValidator(5)] + ) + rating_type = models.CharField( + max_length=10, choices=RatingType.choices, default=RatingType.BUYER + ) comment = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) - + def __str__(self): - return f"Rating of {self.score} for {self.reviewed_user} by {self.reviewer}" \ No newline at end of file + return f"Rating of {self.score} for {self.reviewed_user} by {self.reviewer}" diff --git a/backend/market/serializers.py b/backend/market/serializers.py index c6a426f..49c3ac4 100644 --- a/backend/market/serializers.py +++ b/backend/market/serializers.py @@ -14,7 +14,16 @@ ) from market.mixins import ListingTypeMixin -from market.models import Category, Item, Listing, ListingImage, Offer, Rating, Sublet, Tag +from market.models import ( + Category, + Item, + Listing, + ListingImage, + Offer, + Rating, + Sublet, + Tag, +) User = get_user_model() @@ -450,13 +459,13 @@ class RatingSerializer(ModelSerializer): class Meta: model = Rating fields = [ - "id", - "reviewer", - "reviewed_user", - "listing", - "score", - "rating_type", - "comment", + "id", + "reviewer", + "reviewed_user", + "listing", + "score", + "rating_type", + "comment", "created_at" ] read_only_fields = ["id", "created_at", "reviewer", "rating_type"] @@ -468,16 +477,20 @@ def validate(self, attr): if reviewer == reviewed_user: raise ValidationError("You cannot review yourself.") - + is_seller = listing.seller == reviewer is_buyer = listing.offers_received.filter(user=reviewer).exists() if not is_seller and not is_buyer: - raise ValidationError("You can only rate users on listings you have interacted with.") - + raise ValidationError( + "You can only rate users on listings you have interacted with." + ) + target_is_seller = listing.seller == reviewed_user target_is_buyer = listing.offers_received.filter(user=reviewed_user).exists() if not target_is_seller and not target_is_buyer: - raise ValidationError("You cannot rate a user who is not on either side of the transaction.") + raise ValidationError( + "You cannot rate a user who is not on either side of the transaction." + ) attr["rating_type"] = "SELLER" if is_seller else "BUYER" return attr @@ -493,4 +506,4 @@ def contains_profanity(self, text): def create(self, validated_data): validated_data["reviewer"] = self.context["request"].user - return super().create(validated_data) \ No newline at end of file + return super().create(validated_data) diff --git a/backend/market/views.py b/backend/market/views.py index 24cbd91..0495223 100644 --- a/backend/market/views.py +++ b/backend/market/views.py @@ -349,7 +349,7 @@ class Ratings(mixins.CreateModelMixin, mixins.ListModelMixin, viewsets.GenericVi def get_queryset(self): return Rating.objects.filter(listing_id=self.kwargs["listing_id"]) - + def create(self, request, *args, **kwargs): data = request.data.copy() data["listing"] = int(self.kwargs["listing_id"]) @@ -364,7 +364,9 @@ class UserBuyerRatings(ListAPIView, DefaultOrderMixin): default_ordering = ["-created_at"] def get_queryset(self): - return Rating.objects.filter(reviewed_user=self.request.user, rating_type="BUYER") + return Rating.objects.filter( + reviewed_user=self.request.user, rating_type="BUYER" + ) class UserSellerRatings(ListAPIView, DefaultOrderMixin): serializer_class = RatingSerializer @@ -372,7 +374,9 @@ class UserSellerRatings(ListAPIView, DefaultOrderMixin): default_ordering = ["-created_at"] def get_queryset(self): - return Rating.objects.filter(reviewed_user=self.request.user, rating_type="SELLER") + return Rating.objects.filter( + reviewed_user=self.request.user, rating_type="SELLER" + ) @api_view(["POST"])