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