diff --git a/backend/market/__init__.py b/backend/market/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/market/admin.py b/backend/market/admin.py new file mode 100644 index 00000000..db11398e --- /dev/null +++ b/backend/market/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin +from django.utils.html import mark_safe + +from market.models import Amenity, Offer, Sublet, SubletImage + + +class SubletAdmin(admin.ModelAdmin): + def image_tag(self, instance): + images = ['' for image in instance.images.all()] + return mark_safe("
".join(images)) + + image_tag.short_description = "Sublet Images" + readonly_fields = ("image_tag",) + + +admin.site.register(Offer) +admin.site.register(Amenity) +admin.site.register(Sublet, SubletAdmin) +admin.site.register(SubletImage) diff --git a/backend/market/apps.py b/backend/market/apps.py new file mode 100644 index 00000000..b41e2d7d --- /dev/null +++ b/backend/market/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MarketConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "market" diff --git a/backend/market/migrations/__init__.py b/backend/market/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/market/models.py b/backend/market/models.py new file mode 100644 index 00000000..e391053a --- /dev/null +++ b/backend/market/models.py @@ -0,0 +1,58 @@ +from django.contrib.auth import get_user_model +from django.db import models +from phonenumber_field.modelfields import PhoneNumberField + + +User = get_user_model() + + +class Offer(models.Model): + class Meta: + constraints = [models.UniqueConstraint(fields=["user", "sublet"], name="unique_offer")] + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="offers_made") + sublet = models.ForeignKey("Sublet", on_delete=models.CASCADE, related_name="offers") + email = models.EmailField(max_length=255, null=True, blank=True) + phone_number = PhoneNumberField(null=True, blank=True) + message = models.CharField(max_length=255, blank=True) + created_date = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Offer for {self.sublet} made by {self.user}" + + +class Amenity(models.Model): + name = models.CharField(max_length=255, primary_key=True) + + def __str__(self): + return self.name + + +class Sublet(models.Model): + subletter = models.ForeignKey(User, on_delete=models.CASCADE) + sublettees = models.ManyToManyField( + User, through=Offer, related_name="sublets_offered", blank=True + ) + favorites = models.ManyToManyField(User, related_name="sublets_favorited", blank=True) + amenities = models.ManyToManyField(Amenity, blank=True) + + title = models.CharField(max_length=255) + address = models.CharField(max_length=255, null=True, blank=True) + beds = models.IntegerField(null=True, blank=True) + baths = models.DecimalField(max_digits=3, decimal_places=1, null=True, blank=True) + description = models.TextField(null=True, blank=True) + external_link = models.URLField(max_length=255, null=True, blank=True) + price = models.IntegerField() + negotiable = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField() + start_date = models.DateField() + end_date = models.DateField() + + def __str__(self): + return f"{self.title} by {self.subletter}" + + +class SubletImage(models.Model): + sublet = models.ForeignKey(Sublet, on_delete=models.CASCADE, related_name="images") + image = models.ImageField(upload_to="sublet/images") diff --git a/backend/market/permissions.py b/backend/market/permissions.py new file mode 100644 index 00000000..c1aeb314 --- /dev/null +++ b/backend/market/permissions.py @@ -0,0 +1,57 @@ +from rest_framework import permissions + + +class IsSuperUser(permissions.BasePermission): + """ + Grants permission if the current user is a superuser. + """ + + def has_object_permission(self, request, view, obj): + return request.user.is_superuser + + def has_permission(self, request, view): + return request.user.is_superuser + + +class SubletOwnerPermission(permissions.BasePermission): + """ + Custom permission to allow the owner of a Sublet to edit or delete it. + """ + + def has_permission(self, request, view): + return request.user.is_authenticated + + def has_object_permission(self, request, view, obj): + # Check if the user is the owner of the Sublet. + if request.method in permissions.SAFE_METHODS: + return True + return obj.subletter == request.user + + +class SubletImageOwnerPermission(permissions.BasePermission): + """ + Custom permission to allow the owner of a SubletImage to edit or delete it. + """ + + def has_permission(self, request, view): + return request.user.is_authenticated + + def has_object_permission(self, request, view, obj): + # Check if the user is the owner of the Sublet. + return request.method in permissions.SAFE_METHODS or obj.sublet.subletter == request.user + + +class OfferOwnerPermission(permissions.BasePermission): + """ + Custom permission to allow owner of an offer to delete it. + """ + + def has_permission(self, request, view): + return request.user.is_authenticated + + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + # Check if the user owns the sublet when getting list + return obj.subletter == request.user + # This is redundant, here for safety + return obj.user == request.user diff --git a/backend/market/serializers.py b/backend/market/serializers.py new file mode 100644 index 00000000..c8967cde --- /dev/null +++ b/backend/market/serializers.py @@ -0,0 +1,189 @@ +from phonenumber_field.serializerfields import PhoneNumberField +from profanity_check import predict +from rest_framework import serializers + +from market.models import Amenity, Offer, Sublet, SubletImage + + +class AmenitySerializer(serializers.ModelSerializer): + class Meta: + model = Amenity + fields = "__all__" + + +class OfferSerializer(serializers.ModelSerializer): + phone_number = PhoneNumberField() + + class Meta: + model = Offer + fields = "__all__" + read_only_fields = ["id", "created_date", "user"] + + def create(self, validated_data): + validated_data["user"] = self.context["request"].user + return super().create(validated_data) + + +# Create/Update Image Serializer +class SubletImageSerializer(serializers.ModelSerializer): + image = serializers.ImageField(write_only=True, required=False, allow_null=True) + + class Meta: + model = SubletImage + fields = ["sublet", "image"] + + +# Browse images +class SubletImageURLSerializer(serializers.ModelSerializer): + image_url = serializers.SerializerMethodField("get_image_url") + + def get_image_url(self, obj): + image = obj.image + + if not image: + return None + if image.url.startswith("http"): + return image.url + elif "request" in self.context: + return self.context["request"].build_absolute_uri(image.url) + else: + return image.url + + class Meta: + model = SubletImage + fields = ["id", "image_url"] + + +# complex sublet serializer for use in C/U/D + getting info about a singular sublet +class SubletSerializer(serializers.ModelSerializer): + # amenities = AmenitySerializer(many=True, required=False) + # images = SubletImageURLSerializer(many=True, required=False) + amenities = serializers.PrimaryKeyRelatedField( + many=True, queryset=Amenity.objects.all(), required=False + ) + + class Meta: + model = Sublet + read_only_fields = [ + "id", + "created_at", + "subletter", + "sublettees", + # "images" + ] + fields = [ + "id", + "subletter", + "amenities", + "title", + "address", + "beds", + "baths", + "description", + "external_link", + "price", + "negotiable", + "start_date", + "end_date", + "expires_at", + # "images", + # images are now created/deleted through a separate endpoint (see urls.py) + # this serializer isn't used for getting, + # but gets on sublets will include ids/urls for images + ] + + def validate_title(self, value): + if self.contains_profanity(value): + raise serializers.ValidationError("The title contains inappropriate language.") + return value + + def validate_description(self, value): + if self.contains_profanity(value): + raise serializers.ValidationError("The description contains inappropriate language.") + return value + + def contains_profanity(self, text): + return predict([text])[0] + + def create(self, validated_data): + validated_data["subletter"] = self.context["request"].user + instance = super().create(validated_data) + instance.save() + return instance + + # delete_images is a list of image ids to delete + def update(self, instance, validated_data): + # Check if the user is the subletter before allowing the update + if ( + self.context["request"].user == instance.subletter + or self.context["request"].user.is_superuser + ): + instance = super().update(instance, validated_data) + instance.save() + return instance + else: + raise serializers.ValidationError("You do not have permission to update this sublet.") + + def destroy(self, instance): + # Check if the user is the subletter before allowing the delete + if ( + self.context["request"].user == instance.subletter + or self.context["request"].user.is_superuser + ): + instance.delete() + else: + raise serializers.ValidationError("You do not have permission to delete this sublet.") + + +class SubletSerializerRead(serializers.ModelSerializer): + amenities = serializers.PrimaryKeyRelatedField( + many=True, queryset=Amenity.objects.all(), required=False + ) + images = SubletImageURLSerializer(many=True, required=False) + + class Meta: + model = Sublet + read_only_fields = ["id", "created_at", "subletter", "sublettees"] + fields = [ + "id", + "subletter", + "amenities", + "title", + "address", + "beds", + "baths", + "description", + "external_link", + "price", + "negotiable", + "start_date", + "end_date", + "expires_at", + "images", + ] + + +# simple sublet serializer for use when pulling all serializers/etc +class SubletSerializerSimple(serializers.ModelSerializer): + amenities = serializers.PrimaryKeyRelatedField( + many=True, queryset=Amenity.objects.all(), required=False + ) + images = SubletImageURLSerializer(many=True, required=False) + + class Meta: + model = Sublet + fields = [ + "id", + "subletter", + "amenities", + "title", + "address", + "beds", + "baths", + "price", + "negotiable", + "start_date", + "end_date", + "images", + ] + read_only_fields = ["id", "subletter"] diff --git a/backend/market/urls.py b/backend/market/urls.py new file mode 100644 index 00000000..14efe102 --- /dev/null +++ b/backend/market/urls.py @@ -0,0 +1,49 @@ +from django.urls import path +from rest_framework import routers + +from market.views import ( + Amenities, + CreateImages, + DeleteImage, + Favorites, + Offers, + Properties, + UserFavorites, + UserOffers, +) + + +app_name = "sublet" + +router = routers.DefaultRouter() +router.register(r"properties", Properties, basename="properties") + +additional_urls = [ + # List of all amenities + path("amenities/", Amenities.as_view(), name="amenities"), + # All favorites for user + path("favorites/", UserFavorites.as_view(), name="user-favorites"), + # All offers made by user + path("offers/", UserOffers.as_view(), name="user-offers"), + # Favorites + # post: add a sublet to the user's favorites + # delete: remove a sublet from the user's favorites + path( + "properties//favorites/", + Favorites.as_view({"post": "create", "delete": "destroy"}), + ), + # Offers + # get: list all offers for a sublet + # post: create an offer for a sublet + # delete: delete an offer for a sublet + path( + "properties//offers/", + Offers.as_view({"get": "list", "post": "create", "delete": "destroy"}), + ), + # Image Creation + path("properties//images/", CreateImages.as_view()), + # Image Deletion + path("properties/images//", DeleteImage.as_view()), +] + +urlpatterns = router.urls + additional_urls diff --git a/backend/market/views.py b/backend/market/views.py new file mode 100644 index 00000000..6d64bbd4 --- /dev/null +++ b/backend/market/views.py @@ -0,0 +1,306 @@ +from django.contrib.auth import get_user_model +from django.db.models import prefetch_related_objects +from django.utils import timezone +from rest_framework import exceptions, generics, mixins, status, viewsets +from rest_framework.generics import get_object_or_404 +from rest_framework.parsers import FormParser, MultiPartParser +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from market.models import Amenity, Offer, Sublet, SubletImage +from market.permissions import ( + IsSuperUser, + OfferOwnerPermission, + SubletImageOwnerPermission, + SubletOwnerPermission, +) +from market.serializers import ( + AmenitySerializer, + OfferSerializer, + SubletImageSerializer, + SubletImageURLSerializer, + SubletSerializer, + SubletSerializerRead, + SubletSerializerSimple, +) +from pennmobile.analytics import Metric, record_analytics + + +User = get_user_model() + + +class Amenities(generics.ListAPIView): + serializer_class = AmenitySerializer + queryset = Amenity.objects.all() + + def get(self, request, *args, **kwargs): + temp = super().get(self, request, *args, **kwargs).data + response_data = [a["name"] for a in temp] + return Response(response_data) + + +class UserFavorites(generics.ListAPIView): + serializer_class = SubletSerializerSimple + permission_classes = [IsAuthenticated] + + def get_queryset(self): + user = self.request.user + return user.sublets_favorited + + +class UserOffers(generics.ListAPIView): + serializer_class = OfferSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + user = self.request.user + return Offer.objects.filter(user=user) + + +class Properties(viewsets.ModelViewSet): + """ + list: + Returns a list of Sublets that match query parameters (e.g., amenities) and belong to the user. + + create: + Create a Sublet. + + partial_update: + Update certain fields in the Sublet. Only the owner can edit it. + + destroy: + Delete a Sublet. + """ + + permission_classes = [SubletOwnerPermission | IsSuperUser] + + def get_serializer_class(self): + return SubletSerializerRead if self.action == "retrieve" else SubletSerializer + + def get_queryset(self): + return Sublet.objects.all() + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) # Check if the data is valid + instance = serializer.save() # Create the Sublet + instance_serializer = SubletSerializerRead(instance=instance, context={"request": request}) + + record_analytics(Metric.SUBLET_CREATED, request.user.username) + + return Response(instance_serializer.data, status=status.HTTP_201_CREATED) + + def update(self, request, *args, **kwargs): + partial = kwargs.pop("partial", False) + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + + queryset = self.filter_queryset(self.get_queryset()) + # no clue what this does but I copied it from the DRF source code + if queryset._prefetch_related_lookups: + # If 'prefetch_related' has been applied to a queryset, we need to + # forcibly invalidate the prefetch cache on the instance, + # and then re-prefetch related objects + instance._prefetched_objects_cache = {} + prefetch_related_objects([instance], *queryset._prefetch_related_lookups) + return Response(SubletSerializerRead(instance=instance).data) + + # This is currently redundant but will leave for use when implementing image creation + # def create(self, request, *args, **kwargs): + # # amenities = request.data.pop("amenities", []) + # new_data = request.data + # amenities = new_data.pop("amenities", []) + + # # check if valid amenities + # try: + # amenities = [Amenity.objects.get(name=amenity) for amenity in amenities] + # except Amenity.DoesNotExist: + # return Response({"amenities": "Invalid amenity"}, status=status.HTTP_400_BAD_REQUEST) + + # serializer = self.get_serializer(data=new_data) + # serializer.is_valid(raise_exception=True) + # sublet = serializer.save() + # sublet.amenities.set(amenities) + # sublet.save() + # return Response(serializer.data, status=status.HTTP_201_CREATED) + + def list(self, request, *args, **kwargs): + """Returns a list of Sublets that match query parameters and user ownership.""" + # Get query parameters from request (e.g., amenities, user_owned) + params = request.query_params + amenities = params.getlist("amenities") + title = params.get("title") + address = params.get("address") + subletter = params.get("subletter", "false") # Defaults to False if not specified + starts_before = params.get("starts_before", None) + starts_after = params.get("starts_after", None) + ends_before = params.get("ends_before", None) + ends_after = params.get("ends_after", None) + min_price = params.get("min_price", None) + max_price = params.get("max_price", None) + negotiable = params.get("negotiable", None) + beds = params.get("beds", None) + baths = params.get("baths", None) + + queryset = self.get_queryset() + + # Apply filters based on query parameters + + if subletter.lower() == "true": + queryset = queryset.filter(subletter=request.user) + else: + queryset = queryset.filter(expires_at__gte=timezone.now()) + if title: + queryset = queryset.filter(title__icontains=title) + if address: + queryset = queryset.filter(address__icontains=address) + if amenities: + for amenity in amenities: + queryset = queryset.filter(amenities__name=amenity) + if starts_before: + queryset = queryset.filter(start_date__lt=starts_before) + if starts_after: + queryset = queryset.filter(start_date__gt=starts_after) + if ends_before: + queryset = queryset.filter(end_date__lt=ends_before) + if ends_after: + queryset = queryset.filter(end_date__gt=ends_after) + if min_price: + queryset = queryset.filter(price__gte=min_price) + if max_price: + queryset = queryset.filter(price__lte=max_price) + if negotiable: + queryset = queryset.filter(negotiable=negotiable) + if beds: + queryset = queryset.filter(beds=beds) + if baths: + queryset = queryset.filter(baths=baths) + + record_analytics(Metric.SUBLET_BROWSE, request.user.username) + + # Serialize and return the queryset + serializer = SubletSerializerSimple(queryset, many=True) + return Response(serializer.data) + + +class CreateImages(generics.CreateAPIView): + serializer_class = SubletImageSerializer + http_method_names = ["post"] + permission_classes = [SubletImageOwnerPermission | IsSuperUser] + parser_classes = ( + MultiPartParser, + FormParser, + ) + + def get_queryset(self, *args, **kwargs): + sublet = get_object_or_404(Sublet, id=int(self.kwargs["sublet_id"])) + return SubletImage.objects.filter(sublet=sublet) + + # takes an image multipart form data and creates a new image object + def post(self, request, *args, **kwargs): + images = request.data.getlist("images") + sublet_id = int(self.kwargs["sublet_id"]) + self.get_queryset() # check if sublet exists + img_serializers = [] + for img in images: + img_serializer = self.get_serializer(data={"sublet": sublet_id, "image": img}) + img_serializer.is_valid(raise_exception=True) + img_serializers.append(img_serializer) + instances = [img_serializer.save() for img_serializer in img_serializers] + data = [SubletImageURLSerializer(instance=instance).data for instance in instances] + return Response(data, status=status.HTTP_201_CREATED) + + +class DeleteImage(generics.DestroyAPIView): + serializer_class = SubletImageSerializer + http_method_names = ["delete"] + permission_classes = [SubletImageOwnerPermission | IsSuperUser] + queryset = SubletImage.objects.all() + + def destroy(self, request, *args, **kwargs): + queryset = self.get_queryset() + filter = {"id": self.kwargs["image_id"]} + obj = get_object_or_404(queryset, **filter) + # checking permissions here is kind of redundant + self.check_object_permissions(self.request, obj) + self.perform_destroy(obj) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class Favorites(mixins.DestroyModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet): + serializer_class = SubletSerializer + http_method_names = ["post", "delete"] + permission_classes = [IsAuthenticated | IsSuperUser] + + def get_queryset(self): + user = self.request.user + return user.sublets_favorited + + def create(self, request, *args, **kwargs): + sublet_id = int(self.kwargs["sublet_id"]) + queryset = self.get_queryset() + if queryset.filter(id=sublet_id).exists(): + raise exceptions.NotAcceptable("Favorite already exists") + sublet = get_object_or_404(Sublet, id=sublet_id) + self.get_queryset().add(sublet) + + record_analytics(Metric.SUBLET_FAVORITED, request.user.username) + + return Response(status=status.HTTP_201_CREATED) + + def destroy(self, request, *args, **kwargs): + queryset = self.get_queryset() + sublet = get_object_or_404(queryset, pk=int(self.kwargs["sublet_id"])) + self.get_queryset().remove(sublet) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class Offers(viewsets.ModelViewSet): + """ + list: + Returns a list of all offers for the sublet matching the provided ID. + + create: + Create an offer on the sublet matching the provided ID. + + destroy: + Delete the offer between the user and the sublet matching the ID. + """ + + permission_classes = [OfferOwnerPermission | IsSuperUser] + serializer_class = OfferSerializer + + def get_queryset(self): + return Offer.objects.filter(sublet_id=int(self.kwargs["sublet_id"])).order_by( + "created_date" + ) + + def create(self, request, *args, **kwargs): + data = request.data + request.POST._mutable = True + if self.get_queryset().filter(user=self.request.user).exists(): + raise exceptions.NotAcceptable("Offer already exists") + data["sublet"] = int(self.kwargs["sublet_id"]) + data["user"] = self.request.user.id + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + + record_analytics(Metric.SUBLET_OFFER, request.user.username) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def destroy(self, request, *args, **kwargs): + queryset = self.get_queryset() + filter = {"user": self.request.user.id, "sublet": int(self.kwargs["sublet_id"])} + obj = get_object_or_404(queryset, **filter) + # checking permissions here is kind of redundant + self.check_object_permissions(self.request, obj) + self.perform_destroy(obj) + return Response(status=status.HTTP_204_NO_CONTENT) + + def list(self, request, *args, **kwargs): + self.check_object_permissions(request, Sublet.objects.get(pk=int(self.kwargs["sublet_id"]))) + return super().list(request, *args, **kwargs)