diff --git a/backend/market/models.py b/backend/market/models.py index 7812865..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 +from django.core.validators import MaxValueValidator, MinValueValidator 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,43 @@ 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}" diff --git a/backend/market/serializers.py b/backend/market/serializers.py index c129131..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, Sublet, Tag +from market.models import ( + Category, + Item, + Listing, + ListingImage, + Offer, + Rating, + Sublet, + Tag, +) User = get_user_model() @@ -443,3 +452,58 @@ 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) 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..0495223 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,42 @@ 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):