diff --git a/backend/market/admin.py b/backend/market/admin.py index db11398e..876a1d9c 100644 --- a/backend/market/admin.py +++ b/backend/market/admin.py @@ -1,19 +1,21 @@ from django.contrib import admin from django.utils.html import mark_safe -from market.models import Amenity, Offer, Sublet, SubletImage +from market.models import Category, Item, ItemImage, Offer, Sublet, Tag -class SubletAdmin(admin.ModelAdmin): +class ItemAdmin(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" + image_tag.short_description = "Item Images" readonly_fields = ("image_tag",) admin.site.register(Offer) -admin.site.register(Amenity) -admin.site.register(Sublet, SubletAdmin) -admin.site.register(SubletImage) +admin.site.register(Tag) +admin.site.register(Category) +admin.site.register(Item, ItemAdmin) +admin.site.register(Sublet) +admin.site.register(ItemImage) diff --git a/backend/market/migrations/0001_initial.py b/backend/market/migrations/0001_initial.py new file mode 100644 index 00000000..e9b4757f --- /dev/null +++ b/backend/market/migrations/0001_initial.py @@ -0,0 +1,168 @@ +# Generated by Django 5.0.2 on 2024-10-25 16:41 + +import django.db.models.deletion +import phonenumber_field.modelfields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Category", + fields=[ + ("name", models.CharField(max_length=50, primary_key=True, serialize=False)), + ], + ), + migrations.CreateModel( + name="Tag", + fields=[ + ("name", models.CharField(max_length=255, primary_key=True, serialize=False)), + ], + ), + migrations.CreateModel( + name="Item", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("title", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, null=True)), + ("external_link", models.URLField(blank=True, max_length=255, null=True)), + ("price", models.IntegerField()), + ("negotiable", models.BooleanField(default=True)), + ("used", models.BooleanField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("expires_at", models.DateTimeField()), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="items", + to="market.category", + ), + ), + ( + "favorites", + models.ManyToManyField( + blank=True, related_name="items_favorited", to=settings.AUTH_USER_MODEL + ), + ), + ( + "seller", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ], + ), + migrations.CreateModel( + name="ItemImage", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("image", models.ImageField(upload_to="marketplace/images")), + ( + "item", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="images", + to="market.item", + ), + ), + ], + ), + migrations.CreateModel( + name="Offer", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("email", models.EmailField(blank=True, max_length=255, null=True)), + ( + "phone_number", + phonenumber_field.modelfields.PhoneNumberField( + blank=True, max_length=128, null=True, region=None + ), + ), + ("message", models.CharField(blank=True, max_length=255)), + ("created_date", models.DateTimeField(auto_now_add=True)), + ( + "item", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="offers", + to="market.item", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="offers_made_market", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.AddField( + model_name="item", + name="buyers", + field=models.ManyToManyField( + blank=True, + related_name="items_offered", + through="market.Offer", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.CreateModel( + name="Sublet", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("address", models.CharField(max_length=255)), + ("beds", models.IntegerField()), + ("baths", models.IntegerField()), + ("start_date", models.DateTimeField()), + ("end_date", models.DateTimeField()), + ( + "item", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="sublet", + to="market.item", + ), + ), + ], + ), + migrations.AddField( + model_name="item", + name="tags", + field=models.ManyToManyField(blank=True, related_name="items", to="market.tag"), + ), + migrations.AddConstraint( + model_name="offer", + constraint=models.UniqueConstraint(fields=("user", "item"), name="unique_offer_market"), + ), + ] diff --git a/backend/market/migrations/0002_remove_item_used_alter_item_price.py b/backend/market/migrations/0002_remove_item_used_alter_item_price.py new file mode 100644 index 00000000..4f900150 --- /dev/null +++ b/backend/market/migrations/0002_remove_item_used_alter_item_price.py @@ -0,0 +1,22 @@ +# Generated by Django 5.0.2 on 2024-10-25 21:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("market", "0001_initial"), + ] + + operations = [ + migrations.RemoveField( + model_name="item", + name="used", + ), + migrations.AlterField( + model_name="item", + name="price", + field=models.FloatField(), + ), + ] diff --git a/backend/market/migrations/0003_alter_item_category_alter_item_seller_and_more.py b/backend/market/migrations/0003_alter_item_category_alter_item_seller_and_more.py new file mode 100644 index 00000000..d1a83c53 --- /dev/null +++ b/backend/market/migrations/0003_alter_item_category_alter_item_seller_and_more.py @@ -0,0 +1,54 @@ +# Generated by Django 5.0.2 on 2024-11-02 22:49 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("market", "0002_remove_item_used_alter_item_price"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name="item", + name="category", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="market.category" + ), + ), + migrations.AlterField( + model_name="item", + name="seller", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="items_listed", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="item", + name="tags", + field=models.ManyToManyField(blank=True, to="market.tag"), + ), + migrations.AlterField( + model_name="itemimage", + name="item", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="market.item"), + ), + migrations.AlterField( + model_name="offer", + name="item", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="market.item"), + ), + migrations.AlterField( + model_name="offer", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/backend/market/migrations/0004_rename_image_itemimage_images.py b/backend/market/migrations/0004_rename_image_itemimage_images.py new file mode 100644 index 00000000..623d33f7 --- /dev/null +++ b/backend/market/migrations/0004_rename_image_itemimage_images.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-11-08 23:07 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("market", "0003_alter_item_category_alter_item_seller_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="itemimage", + old_name="image", + new_name="images", + ), + ] diff --git a/backend/market/migrations/0005_rename_images_itemimage_image.py b/backend/market/migrations/0005_rename_images_itemimage_image.py new file mode 100644 index 00000000..04db11ff --- /dev/null +++ b/backend/market/migrations/0005_rename_images_itemimage_image.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-11-08 23:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("market", "0004_rename_image_itemimage_images"), + ] + + operations = [ + migrations.RenameField( + model_name="itemimage", + old_name="images", + new_name="image", + ), + ] diff --git a/backend/market/migrations/0006_alter_itemimage_item.py b/backend/market/migrations/0006_alter_itemimage_item.py new file mode 100644 index 00000000..2364b16f --- /dev/null +++ b/backend/market/migrations/0006_alter_itemimage_item.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.2 on 2024-11-08 23:11 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("market", "0005_rename_images_itemimage_image"), + ] + + operations = [ + migrations.AlterField( + model_name="itemimage", + name="item", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="images", to="market.item" + ), + ), + ] diff --git a/backend/market/models.py b/backend/market/models.py index e391053a..b80e6bc8 100644 --- a/backend/market/models.py +++ b/backend/market/models.py @@ -8,51 +8,66 @@ class Offer(models.Model): class Meta: - constraints = [models.UniqueConstraint(fields=["user", "sublet"], name="unique_offer")] + constraints = [models.UniqueConstraint(fields=["user", "item"], name="unique_offer_market")] - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="offers_made") - sublet = models.ForeignKey("Sublet", on_delete=models.CASCADE, related_name="offers") + user = models.ForeignKey(User, on_delete=models.CASCADE) + item = models.ForeignKey("Item", on_delete=models.CASCADE) 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}" + return f"Offer for {self.item} made by {self.user}" -class Amenity(models.Model): +class Category(models.Model): + name = models.CharField(max_length=50, primary_key=True) + + def __str__(self): + return self.name + + +class Tag(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) +class Item(models.Model): + seller = models.ForeignKey(User, on_delete=models.CASCADE, related_name="items_listed") + buyers = models.ManyToManyField(User, through=Offer, related_name="items_offered", blank=True) + tags = models.ManyToManyField(Tag, blank=True) + category = models.ForeignKey(Category, on_delete=models.CASCADE) + favorites = models.ManyToManyField(User, related_name="items_favorited", 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() + price = models.FloatField() 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}" + return f"{self.title} by {self.seller}" + + +class Sublet(models.Model): + item = models.OneToOneField(Item, on_delete=models.CASCADE, related_name="sublet") + address = models.CharField(max_length=255) + beds = models.IntegerField() + baths = models.IntegerField() + start_date = models.DateTimeField() + end_date = models.DateTimeField() + + def delete(self, *args, **kwargs): + if self.item: + self.item.delete() + super().delete(*args, **kwargs) -class SubletImage(models.Model): - sublet = models.ForeignKey(Sublet, on_delete=models.CASCADE, related_name="images") - image = models.ImageField(upload_to="sublet/images") +class ItemImage(models.Model): + item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name="images") + image = models.ImageField(upload_to="marketplace/images") diff --git a/backend/market/permissions.py b/backend/market/permissions.py index c1aeb314..fbc8408c 100644 --- a/backend/market/permissions.py +++ b/backend/market/permissions.py @@ -13,32 +13,43 @@ def has_permission(self, request, view): return request.user.is_superuser +class ItemOwnerPermission(permissions.BasePermission): + """ + Custom permission to allow the owner of a Item 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 Item. + return request.method in permissions.SAFE_METHODS or obj.seller == request.user + + class SubletOwnerPermission(permissions.BasePermission): """ - Custom permission to allow the owner of a Sublet to edit or delete it. + Custom permission to allow the owner of a Item 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 + # Check if the user is the owner of the Item. + return request.method in permissions.SAFE_METHODS or obj.item.seller == request.user -class SubletImageOwnerPermission(permissions.BasePermission): +class ItemImageOwnerPermission(permissions.BasePermission): """ - Custom permission to allow the owner of a SubletImage to edit or delete it. + Custom permission to allow the owner of a ItemImage 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 + # Check if the user is the owner of the Item. + return request.method in permissions.SAFE_METHODS or obj.item.seller == request.user class OfferOwnerPermission(permissions.BasePermission): @@ -51,7 +62,7 @@ def has_permission(self, request, view): 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 + # Check if the user owns the item when getting list + return obj.item.seller == 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 index c8967cde..d0573598 100644 --- a/backend/market/serializers.py +++ b/backend/market/serializers.py @@ -1,14 +1,28 @@ +from django.contrib.auth import get_user_model from phonenumber_field.serializerfields import PhoneNumberField from profanity_check import predict from rest_framework import serializers -from market.models import Amenity, Offer, Sublet, SubletImage +from market.models import Category, Item, ItemImage, Offer, Sublet, Tag -class AmenitySerializer(serializers.ModelSerializer): +User = get_user_model() + + +class TagSerializer(serializers.ModelSerializer): class Meta: - model = Amenity + model = Tag fields = "__all__" + read_only_fields = [field.name for field in model._meta.fields] + + +# TODO: We could make a Read-Only Serializer in a PennLabs core library. +# This could inherit from that. +class CategorySerializer(serializers.ModelSerializer): + class Meta: + model = Category + fields = "__all__" + read_only_fields = [field.name for field in model._meta.fields] class OfferSerializer(serializers.ModelSerializer): @@ -25,17 +39,17 @@ def create(self, validated_data): # Create/Update Image Serializer -class SubletImageSerializer(serializers.ModelSerializer): +class ItemImageSerializer(serializers.ModelSerializer): image = serializers.ImageField(write_only=True, required=False, allow_null=True) class Meta: - model = SubletImage - fields = ["sublet", "image"] + model = ItemImage + fields = "__all__" # Browse images -class SubletImageURLSerializer(serializers.ModelSerializer): - image_url = serializers.SerializerMethodField("get_image_url") +class ItemImageURLSerializer(serializers.ModelSerializer): + image_url = serializers.SerializerMethodField() def get_image_url(self, obj): image = obj.image @@ -50,47 +64,17 @@ def get_image_url(self, obj): return image.url class Meta: - model = SubletImage - fields = ["id", "image_url"] - + model = ItemImage + fields = "__all__" + read_only_fields = [field.name for field in model._meta.fields] -# 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 - ) +# complex item serializer for use in C/U/D + getting info about a singular item +class ItemSerializer(serializers.ModelSerializer): 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 - ] + model = Item + fields = "__all__" + read_only_fields = ["id", "created_at", "seller", "buyers", "images", "favorites"] def validate_title(self, value): if self.contains_profanity(value): @@ -106,84 +90,75 @@ 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.") - + validated_data["seller"] = self.context["request"].user + return super().create(validated_data) -class SubletSerializerRead(serializers.ModelSerializer): - amenities = serializers.PrimaryKeyRelatedField( - many=True, queryset=Amenity.objects.all(), required=False - ) - images = SubletImageURLSerializer(many=True, required=False) +# Read-only serializer for use when pulling all items/etc +class ItemSerializerRead(serializers.ModelSerializer): class Meta: - model = Sublet - read_only_fields = ["id", "created_at", "subletter", "sublettees"] + model = Item fields = [ "id", - "subletter", - "amenities", + "seller", + "tags", + "category", + "favorites", "title", - "address", - "beds", - "baths", - "description", - "external_link", "price", "negotiable", - "start_date", - "end_date", - "expires_at", "images", ] + read_only_fields = fields - -# 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) - +# Read-only serializer for use when pulling all items/etc +class ItemSerializerSimple(serializers.ModelSerializer): class Meta: - model = Sublet + model = Item fields = [ "id", - "subletter", - "amenities", + "seller", + "tags", + "category", + "favorites", "title", - "address", - "beds", - "baths", "price", "negotiable", - "start_date", - "end_date", "images", ] - read_only_fields = ["id", "subletter"] + read_only_fields = fields + + +class SubletSerializer(serializers.ModelSerializer): + item = ItemSerializer(required=True) + + class Meta: + model = Sublet + fields = ["id", "item", "address", "beds", "baths", "start_date", "end_date"] + read_only_fields = ["id"] + + def create(self, validated_data): + item_serializer = ItemSerializer(data=validated_data.pop("item"), context=self.context) + item_serializer.is_valid(raise_exception=True) + validated_data["item"] = item_serializer.save() + instance = super().create(validated_data) + return instance + + def update(self, instance, validated_data): + if item_data := validated_data.pop("item", None): + item_serializer = ItemSerializer( + instance=instance.item, data=item_data, context=self.context, partial=True + ) + item_serializer.is_valid(raise_exception=True) + validated_data["item"] = item_serializer.save() + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + + return instance + + def destroy(self, instance): + # Could check if instance.item is None, but it should never be. + instance.item.delete() + instance.delete() diff --git a/backend/market/urls.py b/backend/market/urls.py index 14efe102..b425876b 100644 --- a/backend/market/urls.py +++ b/backend/market/urls.py @@ -2,48 +2,53 @@ from rest_framework import routers from market.views import ( - Amenities, + Categories, CreateImages, DeleteImage, Favorites, + Items, Offers, - Properties, + Sublets, + Tags, UserFavorites, UserOffers, ) -app_name = "sublet" +app_name = "market" router = routers.DefaultRouter() -router.register(r"properties", Properties, basename="properties") +router.register(r"items", Items, basename="properties") +router.register(r"sublets", Sublets, basename="sublets") additional_urls = [ # List of all amenities - path("amenities/", Amenities.as_view(), name="amenities"), + path("tags/", Tags.as_view(), name="tags"), + # List of all categories + path("categories/", Categories.as_view(), name="categories"), # 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 + # post: add an item to the user's favorites + # delete: remove an item from the user's favorites path( - "properties//favorites/", + "items//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 + # get: list all offers for an item + # post: create an offer for an item + # delete: delete an offer for an item path( - "properties//offers/", + "items//offers/", Offers.as_view({"get": "list", "post": "create", "delete": "destroy"}), ), # Image Creation - path("properties//images/", CreateImages.as_view()), + path("items//images/", CreateImages.as_view()), # Image Deletion - path("properties/images//", DeleteImage.as_view()), + path("items/images//", DeleteImage.as_view()), ] urlpatterns = router.urls + additional_urls diff --git a/backend/market/views.py b/backend/market/views.py index 6d64bbd4..4198c678 100644 --- a/backend/market/views.py +++ b/backend/market/views.py @@ -1,51 +1,54 @@ 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 rest_framework.views import APIView -from market.models import Amenity, Offer, Sublet, SubletImage +from market.models import Category, Item, ItemImage, Offer, Sublet, Tag from market.permissions import ( IsSuperUser, + ItemImageOwnerPermission, + ItemOwnerPermission, OfferOwnerPermission, - SubletImageOwnerPermission, SubletOwnerPermission, ) from market.serializers import ( - AmenitySerializer, + ItemImageSerializer, + ItemImageURLSerializer, + ItemSerializer, + ItemSerializerRead, 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() +class Tags(APIView): - def get(self, request, *args, **kwargs): - temp = super().get(self, request, *args, **kwargs).data - response_data = [a["name"] for a in temp] + def get(self, request, format=None): + response_data = Tag.objects.values_list("name", flat=True) + return Response(response_data) + + +class Categories(APIView): + + def get(self, request, format=None): + response_data = Category.objects.values_list("name", flat=True) return Response(response_data) class UserFavorites(generics.ListAPIView): - serializer_class = SubletSerializerSimple + serializer_class = ItemSerializerRead permission_classes = [IsAuthenticated] def get_queryset(self): user = self.request.user - return user.sublets_favorited + return user.items_favorited class UserOffers(generics.ListAPIView): @@ -57,167 +60,151 @@ def get_queryset(self): return Offer.objects.filter(user=user) -class Properties(viewsets.ModelViewSet): +class Items(viewsets.ModelViewSet): """ list: - Returns a list of Sublets that match query parameters (e.g., amenities) and belong to the user. + Returns a list of Items that match query parameters (e.g., amenities) and belong to the user. create: - Create a Sublet. + Create an Item. partial_update: - Update certain fields in the Sublet. Only the owner can edit it. + Update certain fields in the Item. Only the owner can edit it. destroy: - Delete a Sublet. + Delete an Item. """ - permission_classes = [SubletOwnerPermission | IsSuperUser] + permission_classes = [ItemOwnerPermission | IsSuperUser] + serializer_class = ItemSerializer + queryset = Item.objects.all() def get_serializer_class(self): - return SubletSerializerRead if self.action == "retrieve" else SubletSerializer + return ( + ItemSerializerRead + if self.action == "list" or self.action == "retrieve" + else ItemSerializer + ) - def get_queryset(self): - return Sublet.objects.all() + @staticmethod + def get_filter_dict(): + filter_dict = { + "category": "category__name", + "title": "title__icontains", + "min_price": "price__gte", + "max_price": "price__lte", + "negotiable": "negotiable", + } + return filter_dict - 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}) + def list(self, request, *args, **kwargs): + """Returns a list of Items that match query parameters and user ownership.""" + queryset = self.get_queryset() - record_analytics(Metric.SUBLET_CREATED, request.user.username) + filter_dict = self.get_filter_dict() - return Response(instance_serializer.data, status=status.HTTP_201_CREATED) + for param, field in filter_dict.items(): + if param_value := request.query_params.get(param): + queryset = queryset.filter(**{field: param_value}) - 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) + for tag in request.query_params.getlist("tags"): + queryset = queryset.filter(tags__name=tag) - 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 = queryset.exclude(category__name__in=["Sublet"]) + + if request.query_params.get("seller", "false").lower() == "true": + queryset = queryset.filter(seller=request.user) + else: + queryset = queryset.filter(expires_at__gte=timezone.now()) + + # Serialize and return the queryset + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + +class Sublets(viewsets.ModelViewSet): + permission_classes = [SubletOwnerPermission | IsSuperUser] + serializer_class = SubletSerializer + queryset = Sublet.objects.all() + + @staticmethod + def get_filter_dict(): + item_filter_dict = Items.get_filter_dict() + for key, value in item_filter_dict.items(): + item_filter_dict[key] = "item__" + value + filter_dict = { + **item_filter_dict, + "address": "address__icontains", + "beds": "beds", + "baths": "baths", + "start_date_min": "start_date__gte", + "start_date_max": "start_date__lte", + "end_date_min": "end_date__gte", + "end_date_max": "end_date__lte", + } + del filter_dict["category"] + return filter_dict + def list(self, request, *args, **kwargs): + """Returns a filtered list of Sublets based on query parameters.""" queryset = self.get_queryset() - # Apply filters based on query parameters + filter_dict = self.get_filter_dict() + + for param, field in filter_dict.items(): + if param_value := request.query_params.get(param): + queryset = queryset.filter(**{field: param_value}) + + for tag in request.query_params.getlist("tags"): + queryset = queryset.filter(item__tags__name=tag) + + queryset = queryset.filter(item__category__name__in=["Sublet"]) - if subletter.lower() == "true": - queryset = queryset.filter(subletter=request.user) + if request.query_params.get("seller", "false").lower() == "true": + queryset = queryset.filter(item__seller=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) + queryset = queryset.filter(item__expires_at__gte=timezone.now()) # Serialize and return the queryset - serializer = SubletSerializerSimple(queryset, many=True) + serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) +# TODO: This doesn't use CreateAPIView's functionality since we overrode the create method. +# Think about if there's a better way class CreateImages(generics.CreateAPIView): - serializer_class = SubletImageSerializer + serializer_class = ItemImageSerializer http_method_names = ["post"] - permission_classes = [SubletImageOwnerPermission | IsSuperUser] + permission_classes = [ItemImageOwnerPermission | 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) + item = get_object_or_404(Item, id=int(self.kwargs["item_id"])) + return ItemImage.objects.filter(item=item) # 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}) + item_id = int(self.kwargs["item_id"]) + self.get_queryset() # check if item exists + img_serializers = [ + self.get_serializer(data={"item": item_id, "image": img}) for img in images + ] + for img_serializer in img_serializers: 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] + data = [ItemImageURLSerializer(instance=instance).data for instance in instances] return Response(data, status=status.HTTP_201_CREATED) class DeleteImage(generics.DestroyAPIView): - serializer_class = SubletImageSerializer + serializer_class = ItemImageSerializer http_method_names = ["delete"] - permission_classes = [SubletImageOwnerPermission | IsSuperUser] - queryset = SubletImage.objects.all() + permission_classes = [ItemImageOwnerPermission | IsSuperUser] + queryset = ItemImage.objects.all() def destroy(self, request, *args, **kwargs): queryset = self.get_queryset() @@ -229,72 +216,72 @@ def destroy(self, request, *args, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) +# TODO: We don't use the CreateModelMixin or DestroyModelMixin's functionality here. +# Think about if there's a better way class Favorites(mixins.DestroyModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet): - serializer_class = SubletSerializer + serializer_class = ItemSerializer http_method_names = ["post", "delete"] permission_classes = [IsAuthenticated | IsSuperUser] def get_queryset(self): user = self.request.user - return user.sublets_favorited + return user.items_favorited def create(self, request, *args, **kwargs): - sublet_id = int(self.kwargs["sublet_id"]) + item_id = int(self.kwargs["item_id"]) queryset = self.get_queryset() - if queryset.filter(id=sublet_id).exists(): + if queryset.filter(id=item_id).exists(): raise exceptions.NotAcceptable("Favorite already exists") - sublet = get_object_or_404(Sublet, id=sublet_id) - self.get_queryset().add(sublet) + item = get_object_or_404(Item, id=item_id) + self.get_queryset().add(item) - record_analytics(Metric.SUBLET_FAVORITED, request.user.username) + # 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) + item = get_object_or_404(queryset, pk=int(self.kwargs["item_id"])) + self.get_queryset().remove(item) 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. + Returns a list of all offers for the item matching the provided ID. create: - Create an offer on the sublet matching the provided ID. + Create an offer on the item matching the provided ID. destroy: - Delete the offer between the user and the sublet matching the ID. + Delete the offer between the user and the item 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" - ) + return Offer.objects.filter(item_id=int(self.kwargs["item_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["item"] = int(self.kwargs["item_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) + # 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"])} + filter = {"user": self.request.user.id, "item": int(self.kwargs["item_id"])} obj = get_object_or_404(queryset, **filter) # checking permissions here is kind of redundant self.check_object_permissions(self.request, obj) @@ -302,5 +289,5 @@ def destroy(self, request, *args, **kwargs): 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"]))) + self.check_object_permissions(request, Item.objects.get(pk=int(self.kwargs["item_id"]))) return super().list(request, *args, **kwargs) diff --git a/backend/pennmobile/settings/base.py b/backend/pennmobile/settings/base.py index c54b3e21..4d73e450 100644 --- a/backend/pennmobile/settings/base.py +++ b/backend/pennmobile/settings/base.py @@ -53,6 +53,7 @@ "options.apps.OptionsConfig", "sublet", "phonenumber_field", + "market", ] MIDDLEWARE = [ diff --git a/backend/pennmobile/urls.py b/backend/pennmobile/urls.py index 5e94960e..3387caf2 100644 --- a/backend/pennmobile/urls.py +++ b/backend/pennmobile/urls.py @@ -27,6 +27,7 @@ path("dining/", include("dining.urls")), path("penndata/", include("penndata.urls")), path("sublet/", include("sublet.urls")), + path("market/", include("market.urls")), ] urlpatterns = [ diff --git a/backend/tests/market/__init__.py b/backend/tests/market/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/market/mock_items.json b/backend/tests/market/mock_items.json new file mode 100644 index 00000000..8545bdde --- /dev/null +++ b/backend/tests/market/mock_items.json @@ -0,0 +1,42 @@ +[ + { + "tags": ["Used","Textbook"], + "category": "Book", + "title": "Math Textbook", + "description": "2023 version", + "external_link": "https://example.com/book", + "price": 20.0, + "negotiable": true, + "expires_at": "2024-12-12T00:00:00-05:00" + }, + { + "tags": ["New"], + "category": "Food", + "title": "Bag of Doritos", + "description": "Cool Ranch", + "external_link": "https://example.com/doritos", + "price": 5.0, + "negotiable": false, + "expires_at": "2024-10-12T00:00:00-05:00" + }, + { + "tags": ["Laptop", "New"], + "category": "Electronics", + "title": "Macbook Pro", + "description": "M1 Chip", + "external_link": "https://example.com/macbook", + "price": 2000.0, + "negotiable": true, + "expires_at": "2025-08-12T00:00:00-05:00" + }, + { + "tags": ["Couch"], + "category": "Furniture", + "title": "Couch", + "description": "5 feet wide, brown", + "external_link": "https://example.com/couch", + "price": 400.0, + "negotiable": true, + "expires_at": "2025-1-12T00:00:00-05:00" + } +] \ No newline at end of file diff --git a/backend/tests/market/mock_sublets.json b/backend/tests/market/mock_sublets.json new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/market/test_market.py b/backend/tests/market/test_market.py new file mode 100644 index 00000000..2fae81b8 --- /dev/null +++ b/backend/tests/market/test_market.py @@ -0,0 +1,243 @@ +import json +from unittest.mock import MagicMock +from django.utils.timezone import now + +from django.contrib.auth import get_user_model +from django.core.files.storage import Storage +from django.test import TestCase +from rest_framework.test import APIClient + +from market.models import Offer, Item, Tag, Category, Sublet, ItemImage +from market.serializers import ItemSerializer + + +User = get_user_model() + +# To run: python manage.py test ./tests/market +class TestMarket(TestCase): + """Tests Create/Update/Retrieve/List for market""" + + def setUp(self): + self.user = User.objects.create_user("user", "user@seas.upenn.edu", "user") + self.client = APIClient() + self.client.force_authenticate(self.user) + user1 = User.objects.create_user("user1", "user1@seas.upenn.edu", "user1") + user2 = User.objects.create_user("user2", "user2@seas.upenn.edu", "user2") + tags = ['New', 'Used', 'Couch', 'Laptop', 'Textbook', 'Chair', 'Apartment', 'House'] + categories = ["Book", "Electronics", "Furniture", "Food", "Sublet", "Other"] + for tag in tags: + Tag.objects.create(name=tag) + for category in categories: + Category.objects.create(name=category) + # "backend/tests/market/mock_items.json" if debugging + # "tests/market/mock_items.json" if from backend directory + with open("tests/market/mock_items.json") as data: + data = json.load(data) + for item in data: + created_item = Item.objects.create( + seller=self.user, + category=Category.objects.get(name=item["category"]), + title=item["title"], + description=item["description"], + external_link=item["external_link"], + price=item["price"], + negotiable=item["negotiable"], + created_at=now(), + expires_at=item["expires_at"] + ) + created_item.tags.set(Tag.objects.filter(name__in=item["tags"])) + created_item.save() + + storage_mock = MagicMock(spec=Storage, name='StorageMock') + storage_mock.generate_filename = lambda filename: filename + storage_mock.save = MagicMock(side_effect=lambda name, *args, **kwargs: name) + storage_mock.url = MagicMock(name="url") + storage_mock.url.return_value = "http://penn-mobile.com/mock-image.png" + ItemImage._meta.get_field("image").storage = storage_mock + + def test_create_item_all_fields(self): + payload = { + "id": 88, + "seller": 2, + "buyers": [], + "tags": [ + "New" + ], + "category": "Book", + "title": "Math Textbook", + "description": "2023 version", + "external_link": "https://example.com/listing", + "price": 20.0, + "negotiable": True, + "created_at": "2024-11-26T00:50:03.217587-05:00", + "expires_at": "2024-12-12T00:00:00-05:00", + "images": [] + } + response = self.client.post("/market/items/", payload) + res_json = json.loads(response.content) + match_keys = ["id", "seller", "buyers", "tags", "category", "title", "description", "external_link", "price", "negotiable", "created_at", "expires_at", "images"] + self.assertEqual(response.status_code, 201) + self.assertEqual(payload.keys(), res_json.keys()) + self.assertEqual(payload.keys(), set(match_keys)) + self.assertEqual(res_json["id"], 5) + self.assertEqual(self.user.id, res_json["seller"]) + # Not sure how to check if created_at exactly matches the + # time of the request, so just check if it's present. + self.assertIn("created_at", res_json) + match_keys.remove("id") + match_keys.remove("seller") + match_keys.remove("created_at") + [self.assertEqual(payload[key], res_json[key]) for key in match_keys] + + def test_create_item_exclude_unrequired(self): + payload = { + "tags": [ + "New" + ], + "category": "Book", + "title": "Math Textbook", + "description": "2023 version", + "external_link": "https://example.com/listing", + "price": 20.0, + "negotiable": True, + "expires_at": "2024-12-12T00:00:00-05:00" + } + response = self.client.post("/market/items/", payload) + res_json = json.loads(response.content) + match_keys = ["id", "seller", "buyers", "tags", "category", "title", "description", "external_link", "price", "negotiable", "created_at", "expires_at", "images"] + self.assertEqual(response.status_code, 201) + self.assertEqual(list(res_json.keys()), match_keys) + self.assertEqual(res_json["id"], 5) + self.assertEqual(self.user.id, res_json["seller"]) + self.assertEqual([], res_json["buyers"]) + self.assertEqual([], res_json["images"]) + # Not sure how to check if created_at exactly matches the + # time of the request, so just check if it's present. + self.assertIn("created_at", res_json) + match_keys.remove("id") + match_keys.remove("seller") + match_keys.remove("created_at") + match_keys.remove("buyers") + match_keys.remove("images") + [self.assertEqual(payload[key], res_json[key]) for key in match_keys] + + def test_create_item_invalid_category(self): + payload = { + "tags": [ + "New" + ], + "category": "Textbook", + "title": "Math Textbook", + "description": "2023 version", + "external_link": "https://example.com/listing", + "price": 20.0, + "negotiable": True, + "expires_at": "2024-12-12T00:00:00-05:00" + } + response = self.client.post("/market/items/", payload) + res_json = json.loads(response.content) + self.assertEqual(response.status_code, 400) + self.assertEqual(res_json, {"category": ['Invalid pk "Textbook" - object does not exist.']}) + + def test_create_item_invalid_tag(self): + payload = { + "tags": [ + "Not a tag" + ], + "category": "Book", + "title": "Math Textbook", + "description": "2023 version", + "external_link": "https://example.com/listing", + "price": 20.0, + "negotiable": True, + "expires_at": "2024-12-12T00:00:00-05:00" + } + response = self.client.post("/market/items/", payload) + res_json = json.loads(response.content) + self.assertEqual(response.status_code, 400) + self.assertEqual(res_json, {"tags": ['Invalid pk "Not a tag" - object does not exist.']}) + + def test_create_item_with_profanity_title(self): + payload = { + "tags": [ + "New" + ], + "category": "Book", + "title": "Fuck Textbook", + "description": "Fuck 2023 version", + "external_link": "https://example.com/listing", + "price": 20.0, + "negotiable": True, + "expires_at": "2024-12-12T00:00:00-05:00" + } + response = self.client.post("/market/items/", payload) + self.assertEqual(response.status_code, 400) + + res_json = response.json() + self.assertIn("title", res_json) + self.assertIn("description", res_json) + self.assertEqual(res_json["title"][0], "The title contains inappropriate language.") + self.assertEqual( + res_json["description"][0], "The description contains inappropriate language." + ) + + def test_update_item_post_minimum_required(self): + # All fields included are strictly required. + payload = { + "category": "Book", + "title": "Physics Textbook", + "price": 25.0, + "expires_at": "2024-12-13T00:00:00-05:00" + } + response = self.client.patch("/market/items/1/", payload) + self.assertEqual(response.status_code, 200) + + res_json = response.json() + self.assertEqual(res_json["id"], 1) + self.assertEqual(res_json["seller"], self.user.id) + self.assertEqual(res_json["category"], "Book") + self.assertEqual(res_json["title"], "Physics Textbook") + self.assertEqual(res_json["price"], 25.0) + self.assertEqual(res_json["expires_at"], "2024-12-13T00:00:00-05:00") + self.assertEqual(res_json["negotiable"], True) + self.assertEqual(res_json["external_link"], "https://example.com/book") + self.assertEqual(set(res_json["tags"]), set(["Used", "Textbook"])) + self.assertEqual(res_json["description"], "2023 version") + + def test_update_item_post_all_fields(self): + payload = { + "id": 5, + "seller": 1, + "buyers": [], + "tags": [ + "New" + ], + "category": "Food", + "title": "5 meal swipes", + "description": "5 meal swipes for sale", + "external_link": "https://example.com/meal-swipes", + "price": 25.0, + "negotiable": False, + "created_at": "2024-11-26T00:50:03.217587-05:00", + "expires_at": "2024-12-14T00:00:00-05:00", + "images": [] + } + original_item = self.client.get("/market/items/1/").json() + original_created_at = Item.objects.get(id=1).created_at.astimezone().isoformat() + response = self.client.patch("/market/items/1/", payload) + self.assertEqual(response.status_code, 200) + + res_json = response.json() + self.assertEqual(res_json["id"], original_item["id"]) + self.assertEqual(res_json["seller"], original_item["seller"]) + self.assertEqual(res_json["category"], "Food") + self.assertEqual(set(res_json["tags"]), set(["New"])) + self.assertEqual(res_json["title"], "5 meal swipes") + self.assertEqual(res_json["description"], "5 meal swipes for sale") + self.assertEqual(res_json["external_link"], "https://example.com/meal-swipes") + self.assertEqual(res_json["price"], 25.0) + self.assertEqual(res_json["negotiable"], False) + # created_at needs to be retrieved seperately since it's not returned by the read-only serializer. + self.assertEqual(res_json["created_at"], original_created_at) + self.assertEqual(res_json["expires_at"], "2024-12-14T00:00:00-05:00") + self.assertEqual(res_json["images"], []) \ No newline at end of file diff --git a/backend/tests/market/test_permissions.py b/backend/tests/market/test_permissions.py new file mode 100644 index 00000000..e69de29b