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)