From 2497707bc76a2b9d3ba90c2087c0b319338fc63a Mon Sep 17 00:00:00 2001 From: Jesse Zong Date: Mon, 4 Dec 2023 13:16:06 -0500 Subject: [PATCH 01/15] Sublet Image Handling --- backend/sublet/permissions.py | 15 +++++ backend/sublet/serializers.py | 102 ++++++++++++++++++++++++++++------ backend/sublet/urls.py | 3 +- backend/sublet/views.py | 75 +++++++++++++++++++++++-- 4 files changed, 170 insertions(+), 25 deletions(-) diff --git a/backend/sublet/permissions.py b/backend/sublet/permissions.py index b90d7786..3e547f4f 100644 --- a/backend/sublet/permissions.py +++ b/backend/sublet/permissions.py @@ -28,6 +28,21 @@ def has_object_permission(self, request, view, obj): return obj.subletter == request.user +class SubletImageOwnerPermission(permissions.BasePermission): + """ + Custom permission to allow the owner of a SubletImage to edit or delete it. + """ + + def has_permission(self, request, view): + return request.user.is_authenticated + + def has_object_permission(self, request, view, obj): + # Check if the user is the owner of the Sublet. + if request.method in permissions.SAFE_METHODS: + return True + return obj.sublet.subletter == request.user + + class OfferOwnerPermission(permissions.BasePermission): """ Custom permission to allow owner of an offer to delete it. diff --git a/backend/sublet/serializers.py b/backend/sublet/serializers.py index 560e8fd1..42349948 100644 --- a/backend/sublet/serializers.py +++ b/backend/sublet/serializers.py @@ -1,5 +1,6 @@ from phonenumber_field.serializerfields import PhoneNumberField from rest_framework import serializers +from rest_framework.generics import get_object_or_404 from sublet.models import Amenity, Offer, Sublet, SubletImage @@ -30,7 +31,6 @@ class SubletImageSerializer(serializers.ModelSerializer): class Meta: model = SubletImage fields = ["sublet", "image"] - read_only_fields = ["sublet", "image"] # Browse images @@ -51,17 +51,42 @@ def get_image_url(self, obj): class Meta: model = SubletImage - fields = ["image_url"] + fields = ["id", "image_url"] # complex sublet serializer for use in C/U/D + getting info about a singular sublet class SubletSerializer(serializers.ModelSerializer): amenities = AmenitySerializer(many=True, required=False) + images = serializers.ListField( + child=serializers.FileField(max_length=100000, allow_empty_file=False, use_url=False), + required=False, + write_only=True, + ) + delete_images = serializers.ListField( + child=serializers.IntegerField(), required=False, write_only=True + ) class Meta: model = Sublet - exclude = ["favorites"] read_only_fields = ["id", "created_at", "subletter", "sublettees"] + fields = [ + "id", + "subletter", + "amenities", + "title", + "address", + "beds", + "baths", + "description", + "external_link", + "min_price", + "max_price", + "start_date", + "end_date", + "expires_at", + "images", + "delete_images", + ] def parse_amenities(self, raw_amenities): if isinstance(raw_amenities, list): @@ -74,29 +99,44 @@ def parse_amenities(self, raw_amenities): def create(self, validated_data): validated_data["subletter"] = self.context["request"].user + images = validated_data.pop("images") instance = super().create(validated_data) data = self.context["request"].POST amenities = self.parse_amenities(data.getlist("amenities")) instance.amenities.set(amenities) instance.save() + # TODO: make this atomic + for img in images: + img_serializer = SubletImageSerializer(data={"sublet": instance.id, "image": img}) + img_serializer.is_valid(raise_exception=True) + img_serializer.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 - ): - amenities_data = self.context["request"].data - if amenities_data.get("amenities") is not None: - amenities = self.parse_amenities(amenities_data.getlist("amenities")) - instance.amenities.set(amenities) - validated_data.pop("amenities", None) - instance = super().update(instance, validated_data) - instance.save() - else: - raise serializers.ValidationError("You do not have permission to update this sublet.") - + # This is probably redundant given permissions classes? + # if ( + # self.context["request"].user == instance.subletter + # or self.context["request"].user.is_superuser + # ): + amenities_data = self.context["request"].data + if amenities_data.get("amenities") is not None: + amenities = self.parse_amenities(amenities_data["amenities"]) + instance.amenities.set(amenities) + validated_data.pop("amenities", None) + delete_images = validated_data.pop("delete_images") + instance = super().update(instance, validated_data) + instance.save() + existing_images = Sublet.objects.get(id=instance.id).images.all() + print(existing_images) + for img in delete_images: + get_object_or_404(existing_images, id=img) + # this should probably be atomic + for img in delete_images: + existing_images.get(id=img).delete() + # else: + # raise serializers.ValidationError("You do not have permission to update this sublet.") return instance def destroy(self, instance): @@ -110,8 +150,34 @@ def destroy(self, instance): raise serializers.ValidationError("You do not have permission to delete this sublet.") +class SubletSerializerRead(serializers.ModelSerializer): + amenities = AmenitySerializer(many=True, required=False) + images = SubletImageURLSerializer(many=True, required=False) + + class Meta: + model = Sublet + read_only_fields = ["id", "created_at", "subletter", "sublettees"] + fields = [ + "id", + "subletter", + "amenities", + "title", + "address", + "beds", + "baths", + "description", + "external_link", + "min_price", + "max_price", + "start_date", + "end_date", + "expires_at", + "images", + ] + + # simple sublet serializer for use when pulling all serializers/etc -class SimpleSubletSerializer(serializers.ModelSerializer): +class SubletSerializerSimple(serializers.ModelSerializer): amenities = AmenitySerializer(many=True, required=False) images = SubletImageURLSerializer(many=True, required=False) diff --git a/backend/sublet/urls.py b/backend/sublet/urls.py index cefcf73c..c89f40bd 100644 --- a/backend/sublet/urls.py +++ b/backend/sublet/urls.py @@ -1,7 +1,7 @@ from django.urls import path from rest_framework import routers -from sublet.views import Amenities, Favorites, Offers, Properties, UserFavorites, UserOffers +from sublet.views import Amenities, Favorites, Offers, Properties, UserFavorites, UserOffers, Images app_name = "sublet" @@ -21,6 +21,7 @@ "properties//offers/", Offers.as_view({"get": "list", "post": "create", "delete": "destroy"}), ), + path("properties//images/", Images.as_view()), ] urlpatterns = router.urls + additional_urls diff --git a/backend/sublet/views.py b/backend/sublet/views.py index d83b72ae..e9802ab5 100644 --- a/backend/sublet/views.py +++ b/backend/sublet/views.py @@ -1,17 +1,26 @@ 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 MultiPartParser, FormParser, JSONParser from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from sublet.models import Amenity, Offer, Sublet -from sublet.permissions import IsSuperUser, OfferOwnerPermission, SubletOwnerPermission +from sublet.models import Amenity, Offer, Sublet, SubletImage +from sublet.permissions import ( + IsSuperUser, + OfferOwnerPermission, + SubletOwnerPermission, + SubletImageOwnerPermission, +) from sublet.serializers import ( AmenitySerializer, OfferSerializer, - SimpleSubletSerializer, + SubletSerializerSimple, SubletSerializer, + SubletSerializerRead, + SubletImageSerializer, ) @@ -29,7 +38,7 @@ def get(self, request, *args, **kwargs): class UserFavorites(generics.ListAPIView): - serializer_class = SimpleSubletSerializer + serializer_class = SubletSerializerSimple permission_classes = [IsAuthenticated] def get_queryset(self): @@ -62,11 +71,40 @@ class Properties(viewsets.ModelViewSet): """ permission_classes = [SubletOwnerPermission | IsSuperUser] - serializer_class = SubletSerializer + parser_classes = (MultiPartParser, FormParser, JSONParser) + + def get_serializer_class(self): + if self.action == "retrieve": + return SubletSerializerRead + return SubletSerializer def get_queryset(self): return Sublet.objects.all() + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) # Check if the data is valid + instance = serializer.save() # Create the Sublet + instance_serializer = SubletSerializerRead(instance=instance, context={"request": request}) + return Response(instance_serializer.data, status=status.HTTP_201_CREATED) + + def update(self, request, *args, **kwargs): + partial = kwargs.pop("partial", False) + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + + queryset = self.filter_queryset(self.get_queryset()) + # no clue what this does but I copied it from the DRF source code + if queryset._prefetch_related_lookups: + # If 'prefetch_related' has been applied to a queryset, we need to + # forcibly invalidate the prefetch cache on the instance, + # and then re-prefetch related objects + instance._prefetched_objects_cache = {} + prefetch_related_objects([instance], *queryset._prefetch_related_lookups) + return Response(SubletSerializerRead(instance=instance).data) + # This is currently redundant but will leave for use when implementing image creation # def create(self, request, *args, **kwargs): # # amenities = request.data.pop("amenities", []) @@ -132,10 +170,35 @@ def list(self, request, *args, **kwargs): queryset = queryset.filter(baths=baths) # Serialize and return the queryset - serializer = SimpleSubletSerializer(queryset, many=True) + serializer = SubletSerializerSimple(queryset, many=True) return Response(serializer.data) +class Images(generics.CreateAPIView): + serializer_class = SubletImageSerializer + http_method_names = ["post"] + permission_classes = [SubletImageOwnerPermission | IsSuperUser] + parser_classes = ( + MultiPartParser, + FormParser, + ) + + def get_queryset(self, *args, **kwargs): + get_object_or_404(Sublet, id=int(self.kwargs["sublet_id"])) + return SubletImage.objects.filter(sublet_id=int(self.kwargs["sublet_id"])) + + # 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 + for img in images: + img_serializer = self.get_serializer(data={"sublet": sublet_id, "image": img}) + img_serializer.is_valid(raise_exception=True) + img_serializer.save() + return Response(status=status.HTTP_201_CREATED) + + class Favorites(mixins.DestroyModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet): serializer_class = SubletSerializer http_method_names = ["post", "delete"] From 94b16010163d02378cd14c663a8891ff3ec1c919 Mon Sep 17 00:00:00 2001 From: Jesse Zong Date: Mon, 4 Dec 2023 13:19:18 -0500 Subject: [PATCH 02/15] Fix linting whoops --- backend/sublet/urls.py | 2 +- backend/sublet/views.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/sublet/urls.py b/backend/sublet/urls.py index c89f40bd..e8b5b61b 100644 --- a/backend/sublet/urls.py +++ b/backend/sublet/urls.py @@ -1,7 +1,7 @@ from django.urls import path from rest_framework import routers -from sublet.views import Amenities, Favorites, Offers, Properties, UserFavorites, UserOffers, Images +from sublet.views import Amenities, Favorites, Images, Offers, Properties, UserFavorites, UserOffers app_name = "sublet" diff --git a/backend/sublet/views.py b/backend/sublet/views.py index e9802ab5..b279717f 100644 --- a/backend/sublet/views.py +++ b/backend/sublet/views.py @@ -3,7 +3,7 @@ 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 MultiPartParser, FormParser, JSONParser +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -11,16 +11,16 @@ from sublet.permissions import ( IsSuperUser, OfferOwnerPermission, - SubletOwnerPermission, SubletImageOwnerPermission, + SubletOwnerPermission, ) from sublet.serializers import ( AmenitySerializer, OfferSerializer, - SubletSerializerSimple, + SubletImageSerializer, SubletSerializer, SubletSerializerRead, - SubletImageSerializer, + SubletSerializerSimple, ) @@ -191,7 +191,7 @@ def get_queryset(self, *args, **kwargs): 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 + self.get_queryset() # check if sublet exists for img in images: img_serializer = self.get_serializer(data={"sublet": sublet_id, "image": img}) img_serializer.is_valid(raise_exception=True) From 7df7f2f5615791bf69bc28e0f27cb1cb716e6358 Mon Sep 17 00:00:00 2001 From: Jesse Zong Date: Fri, 8 Dec 2023 11:10:48 -0500 Subject: [PATCH 03/15] Add list comprehensions + prevalidation to images --- backend/sublet/serializers.py | 47 ++++++++++++++++------------------- backend/sublet/views.py | 12 ++++----- 2 files changed, 28 insertions(+), 31 deletions(-) diff --git a/backend/sublet/serializers.py b/backend/sublet/serializers.py index 42349948..4dd32fe4 100644 --- a/backend/sublet/serializers.py +++ b/backend/sublet/serializers.py @@ -85,7 +85,7 @@ class Meta: "end_date", "expires_at", "images", - "delete_images", + "delete_images_ids", ] def parse_amenities(self, raw_amenities): @@ -106,38 +106,35 @@ def create(self, validated_data): instance.amenities.set(amenities) instance.save() # TODO: make this atomic + img_serializers = [] for img in images: img_serializer = SubletImageSerializer(data={"sublet": instance.id, "image": img}) img_serializer.is_valid(raise_exception=True) - img_serializer.save() + img_serializers.append(img_serializer) + [img_serializer.save() for img_serializer in img_serializers] 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 - # This is probably redundant given permissions classes? - # if ( - # self.context["request"].user == instance.subletter - # or self.context["request"].user.is_superuser - # ): - amenities_data = self.context["request"].data - if amenities_data.get("amenities") is not None: - amenities = self.parse_amenities(amenities_data["amenities"]) - instance.amenities.set(amenities) - validated_data.pop("amenities", None) - delete_images = validated_data.pop("delete_images") - instance = super().update(instance, validated_data) - instance.save() - existing_images = Sublet.objects.get(id=instance.id).images.all() - print(existing_images) - for img in delete_images: - get_object_or_404(existing_images, id=img) - # this should probably be atomic - for img in delete_images: - existing_images.get(id=img).delete() - # else: - # raise serializers.ValidationError("You do not have permission to update this sublet.") - return instance + if ( + self.context["request"].user == instance.subletter + or self.context["request"].user.is_superuser + ): + amenities_data = self.context["request"].data + if amenities_data.get("amenities") is not None: + amenities = self.parse_amenities(amenities_data["amenities"]) + instance.amenities.set(amenities) + validated_data.pop("amenities", None) + delete_images_ids = validated_data.pop("delete_images_ids") + instance = super().update(instance, validated_data) + instance.save() + existing_images = Sublet.objects.get(id=instance.id).images.all() + [get_object_or_404(existing_images, id=img) for img in delete_images_ids] + existing_images.filter(id__in=delete_images_ids).delete() + 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 diff --git a/backend/sublet/views.py b/backend/sublet/views.py index b279717f..cb7e6988 100644 --- a/backend/sublet/views.py +++ b/backend/sublet/views.py @@ -74,9 +74,7 @@ class Properties(viewsets.ModelViewSet): parser_classes = (MultiPartParser, FormParser, JSONParser) def get_serializer_class(self): - if self.action == "retrieve": - return SubletSerializerRead - return SubletSerializer + return SubletSerializerRead if self.action == "retrieve" else SubletSerializer def get_queryset(self): return Sublet.objects.all() @@ -184,18 +182,20 @@ class Images(generics.CreateAPIView): ) def get_queryset(self, *args, **kwargs): - get_object_or_404(Sublet, id=int(self.kwargs["sublet_id"])) - return SubletImage.objects.filter(sublet_id=int(self.kwargs["sublet_id"])) + sublet = get_object_or_404(Sublet, id=int(self.kwargs["sublet_id"])) + return SubletImage.objects.filter(sublet=sublet) # takes an image multipart form data and creates a new image object def post(self, request, *args, **kwargs): images = request.data.getlist("images") sublet_id = int(self.kwargs["sublet_id"]) self.get_queryset() # check if sublet exists + img_serializers = [] for img in images: img_serializer = self.get_serializer(data={"sublet": sublet_id, "image": img}) img_serializer.is_valid(raise_exception=True) - img_serializer.save() + img_serializers.append(img_serializer) + [img_serializer.save() for img_serializer in img_serializers] return Response(status=status.HTTP_201_CREATED) From 315c0c3342978b4cdd20ffd07ac2e831ca77b66d Mon Sep 17 00:00:00 2001 From: Jesse Zong Date: Fri, 8 Dec 2023 11:25:24 -0500 Subject: [PATCH 04/15] Fix misnamed serializer field --- backend/sublet/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/sublet/serializers.py b/backend/sublet/serializers.py index 4dd32fe4..2b4a51f4 100644 --- a/backend/sublet/serializers.py +++ b/backend/sublet/serializers.py @@ -62,7 +62,7 @@ class SubletSerializer(serializers.ModelSerializer): required=False, write_only=True, ) - delete_images = serializers.ListField( + delete_images_ids = serializers.ListField( child=serializers.IntegerField(), required=False, write_only=True ) From 7dd31b3c271274e84a706e2a1b6a5d49fb93230d Mon Sep 17 00:00:00 2001 From: Jesse Zong Date: Fri, 8 Dec 2023 17:45:16 -0500 Subject: [PATCH 05/15] Fix test cases --- backend/sublet/serializers.py | 8 ++++++-- backend/tests/sublet/test_sublets.py | 22 ++++++++++++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/backend/sublet/serializers.py b/backend/sublet/serializers.py index 2b4a51f4..75b35c24 100644 --- a/backend/sublet/serializers.py +++ b/backend/sublet/serializers.py @@ -99,7 +99,7 @@ def parse_amenities(self, raw_amenities): def create(self, validated_data): validated_data["subletter"] = self.context["request"].user - images = validated_data.pop("images") + images = validated_data.pop("images") if "images" in validated_data else [] instance = super().create(validated_data) data = self.context["request"].POST amenities = self.parse_amenities(data.getlist("amenities")) @@ -126,7 +126,11 @@ def update(self, instance, validated_data): amenities = self.parse_amenities(amenities_data["amenities"]) instance.amenities.set(amenities) validated_data.pop("amenities", None) - delete_images_ids = validated_data.pop("delete_images_ids") + delete_images_ids = ( + validated_data.pop("delete_images_ids") + if "delete_images_ids" in validated_data + else [] + ) instance = super().update(instance, validated_data) instance.save() existing_images = Sublet.objects.get(id=instance.id).images.all() diff --git a/backend/tests/sublet/test_sublets.py b/backend/tests/sublet/test_sublets.py index 3229abc8..48d49a2a 100644 --- a/backend/tests/sublet/test_sublets.py +++ b/backend/tests/sublet/test_sublets.py @@ -44,12 +44,26 @@ def test_create_sublet(self): "end_date": "2024-08-07", "amenities": ["Amenity1", "Amenity2"], } - response = self.client.post("/sublet/properties/", payload) res_json = json.loads(response.content) - self.assertEqual(payload["beds"], res_json["beds"]) - self.assertEqual(payload["title"], res_json["title"]) - self.assertIn("created_at", res_json) + match_keys = [ + "title", + "address", + "beds", + "baths", + "description", + "external_link", + "min_price", + "max_price", + "expires_at", + "start_date", + "end_date", + ] + [self.assertEqual(payload[key], res_json[key]) for key in match_keys] + self.assertIn("id", res_json) + self.assertEqual(self.user.id, res_json["subletter"]) + self.assertEqual(2, len(res_json["amenities"])) + self.assertIn("images", res_json) def test_update_sublet(self): # Create a sublet to be updated From a7b4eec8d001860ab0be40baff5030a46048435c Mon Sep 17 00:00:00 2001 From: Jesse Zong Date: Sun, 4 Feb 2024 12:36:02 -0500 Subject: [PATCH 06/15] Add separate routes for image creation and deletion --- .pre-commit-config.yaml | 5 +++- backend/sublet/models.py | 1 + backend/sublet/serializers.py | 52 ++++++++++++++++------------------- backend/sublet/urls.py | 15 ++++++++-- backend/sublet/views.py | 27 +++++++++++++----- 5 files changed, 61 insertions(+), 39 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 61a4bdf7..a626a833 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,6 +2,9 @@ repos: - repo: https://github.com/pennlabs/pre-commit-hooks rev: stable hooks: - - id: black - id: isort - id: flake8 + - repo: https://github.com/psf/black + rev: 22.3.0 + hooks: + - id: black diff --git a/backend/sublet/models.py b/backend/sublet/models.py index 4fd79bd1..aad9f155 100644 --- a/backend/sublet/models.py +++ b/backend/sublet/models.py @@ -42,6 +42,7 @@ class Sublet(models.Model): baths = models.IntegerField(null=True, blank=True) description = models.TextField(null=True, blank=True) external_link = models.URLField(max_length=255) + # TODO: change to just one price field and migrateeeee min_price = models.IntegerField() max_price = models.IntegerField() created_at = models.DateTimeField(auto_now_add=True) diff --git a/backend/sublet/serializers.py b/backend/sublet/serializers.py index 75b35c24..cd79b1ae 100644 --- a/backend/sublet/serializers.py +++ b/backend/sublet/serializers.py @@ -1,7 +1,5 @@ from phonenumber_field.serializerfields import PhoneNumberField from rest_framework import serializers -from rest_framework.generics import get_object_or_404 - from sublet.models import Amenity, Offer, Sublet, SubletImage @@ -57,18 +55,17 @@ class Meta: # 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 = serializers.ListField( - child=serializers.FileField(max_length=100000, allow_empty_file=False, use_url=False), - required=False, - write_only=True, - ) - delete_images_ids = serializers.ListField( - child=serializers.IntegerField(), required=False, write_only=True - ) + # images = SubletImageURLSerializer(many=True, required=False) class Meta: model = Sublet - read_only_fields = ["id", "created_at", "subletter", "sublettees"] + read_only_fields = [ + "id", + "created_at", + "subletter", + "sublettees", + # "images" + ] fields = [ "id", "subletter", @@ -84,8 +81,7 @@ class Meta: "start_date", "end_date", "expires_at", - "images", - "delete_images_ids", + # "images", ] def parse_amenities(self, raw_amenities): @@ -99,19 +95,19 @@ def parse_amenities(self, raw_amenities): def create(self, validated_data): validated_data["subletter"] = self.context["request"].user - images = validated_data.pop("images") if "images" in validated_data else [] + # images = validated_data.pop("images") if "images" in validated_data else [] instance = super().create(validated_data) data = self.context["request"].POST amenities = self.parse_amenities(data.getlist("amenities")) instance.amenities.set(amenities) instance.save() # TODO: make this atomic - img_serializers = [] - for img in images: - img_serializer = SubletImageSerializer(data={"sublet": instance.id, "image": img}) - img_serializer.is_valid(raise_exception=True) - img_serializers.append(img_serializer) - [img_serializer.save() for img_serializer in img_serializers] + # img_serializers = [] + # for img in images: + # img_serializer = SubletImageSerializer(data={"sublet": instance.id, "image": img}) + # img_serializer.is_valid(raise_exception=True) + # img_serializers.append(img_serializer) + # [img_serializer.save() for img_serializer in img_serializers] return instance # delete_images is a list of image ids to delete @@ -126,16 +122,16 @@ def update(self, instance, validated_data): amenities = self.parse_amenities(amenities_data["amenities"]) instance.amenities.set(amenities) validated_data.pop("amenities", None) - delete_images_ids = ( - validated_data.pop("delete_images_ids") - if "delete_images_ids" in validated_data - else [] - ) + # delete_images_ids = ( + # validated_data.pop("delete_images_ids") + # if "delete_images_ids" in validated_data + # else [] + # ) instance = super().update(instance, validated_data) instance.save() - existing_images = Sublet.objects.get(id=instance.id).images.all() - [get_object_or_404(existing_images, id=img) for img in delete_images_ids] - existing_images.filter(id__in=delete_images_ids).delete() + # existing_images = Sublet.objects.get(id=instance.id).images.all() + # [get_object_or_404(existing_images, id=img) for img in delete_images_ids] + # existing_images.filter(id__in=delete_images_ids).delete() return instance else: raise serializers.ValidationError("You do not have permission to update this sublet.") diff --git a/backend/sublet/urls.py b/backend/sublet/urls.py index e8b5b61b..dbae9aa5 100644 --- a/backend/sublet/urls.py +++ b/backend/sublet/urls.py @@ -1,7 +1,15 @@ from django.urls import path from rest_framework import routers - -from sublet.views import Amenities, Favorites, Images, Offers, Properties, UserFavorites, UserOffers +from sublet.views import ( + Amenities, + CreateImages, + DeleteImage, + Favorites, + Offers, + Properties, + UserFavorites, + UserOffers, +) app_name = "sublet" @@ -21,7 +29,8 @@ "properties//offers/", Offers.as_view({"get": "list", "post": "create", "delete": "destroy"}), ), - path("properties//images/", Images.as_view()), + path("properties//images/", CreateImages.as_view()), + path("properties/images//", DeleteImage.as_view()), ] urlpatterns = router.urls + additional_urls diff --git a/backend/sublet/views.py b/backend/sublet/views.py index cb7e6988..e3d740d9 100644 --- a/backend/sublet/views.py +++ b/backend/sublet/views.py @@ -3,10 +3,9 @@ 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, JSONParser, MultiPartParser +from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response - from sublet.models import Amenity, Offer, Sublet, SubletImage from sublet.permissions import ( IsSuperUser, @@ -71,7 +70,6 @@ class Properties(viewsets.ModelViewSet): """ permission_classes = [SubletOwnerPermission | IsSuperUser] - parser_classes = (MultiPartParser, FormParser, JSONParser) def get_serializer_class(self): return SubletSerializerRead if self.action == "retrieve" else SubletSerializer @@ -140,14 +138,14 @@ def list(self, request, *args, **kwargs): baths = params.get("baths", None) queryset = Sublet.objects.all().filter(expires_at__gte=timezone.now()) - # Apply filters based on query parameters if title: queryset = queryset.filter(title__icontains=title) if address: queryset = queryset.filter(address__icontains=address) if amenities: - queryset = queryset.filter(amenities__name__in=amenities) + for amenity in amenities: + queryset = queryset.filter(amenities__name=amenity) if subletter.lower() == "true": queryset = queryset.filter(subletter=request.user) if starts_before: @@ -166,13 +164,12 @@ def list(self, request, *args, **kwargs): queryset = queryset.filter(beds=beds) if baths: queryset = queryset.filter(baths=baths) - # Serialize and return the queryset serializer = SubletSerializerSimple(queryset, many=True) return Response(serializer.data) -class Images(generics.CreateAPIView): +class CreateImages(generics.CreateAPIView): serializer_class = SubletImageSerializer http_method_names = ["post"] permission_classes = [SubletImageOwnerPermission | IsSuperUser] @@ -199,6 +196,22 @@ def post(self, request, *args, **kwargs): return Response(status=status.HTTP_201_CREATED) +class DeleteImage(generics.DestroyAPIView): + serializer_class = SubletImageSerializer + http_method_names = ["delete"] + permission_classes = [SubletImageOwnerPermission | IsSuperUser] + queryset = SubletImage.objects.all() + + def destroy(self, request, *args, **kwargs): + queryset = self.get_queryset() + filter = {"id": self.kwargs["image_id"]} + obj = get_object_or_404(queryset, **filter) + # checking permissions here is kind of redundant + self.check_object_permissions(self.request, obj) + self.perform_destroy(obj) + return Response(status=status.HTTP_204_NO_CONTENT) + + class Favorites(mixins.DestroyModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet): serializer_class = SubletSerializer http_method_names = ["post", "delete"] From ba8504eefde4f583893bbf4b8c7f03ace5f70505 Mon Sep 17 00:00:00 2001 From: Jesse Zong Date: Thu, 8 Feb 2024 03:45:50 -0500 Subject: [PATCH 07/15] Begin work on sublet testing --- backend/sublet/serializers.py | 37 ++++++++++++----------- backend/sublet/views.py | 6 ++-- backend/tests/sublet/mock_image.jpg | Bin 0 -> 32705 bytes backend/tests/sublet/test_sublets.py | 43 ++++++++++++++++----------- 4 files changed, 49 insertions(+), 37 deletions(-) create mode 100644 backend/tests/sublet/mock_image.jpg diff --git a/backend/sublet/serializers.py b/backend/sublet/serializers.py index cd79b1ae..d109b917 100644 --- a/backend/sublet/serializers.py +++ b/backend/sublet/serializers.py @@ -54,8 +54,11 @@ class Meta: # 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) + # amenities = AmenitySerializer(many=True, required=False) # images = SubletImageURLSerializer(many=True, required=False) + amenities = serializers.PrimaryKeyRelatedField( + many=True, queryset=Amenity.objects.all(), required=False + ) class Meta: model = Sublet @@ -84,22 +87,22 @@ class Meta: # "images", ] - def parse_amenities(self, raw_amenities): - if isinstance(raw_amenities, list): - ids = raw_amenities - else: - ids = ( - list() if len(raw_amenities) == 0 else [str(id) for id in raw_amenities.split(",")] - ) - return Amenity.objects.filter(name__in=ids) + # def parse_amenities(self, raw_amenities): + # if isinstance(raw_amenities, list): + # ids = raw_amenities + # else: + # ids = ( + # list() if len(raw_amenities) == 0 else [str(id) for id in raw_amenities.split(",")] + # ) + # return Amenity.objects.filter(name__in=ids) def create(self, validated_data): validated_data["subletter"] = self.context["request"].user # images = validated_data.pop("images") if "images" in validated_data else [] instance = super().create(validated_data) - data = self.context["request"].POST - amenities = self.parse_amenities(data.getlist("amenities")) - instance.amenities.set(amenities) + # data = self.context["request"].POST + # amenities = self.parse_amenities(data.getlist("amenities")) + # instance.amenities.set(amenities) instance.save() # TODO: make this atomic # img_serializers = [] @@ -117,11 +120,11 @@ def update(self, instance, validated_data): self.context["request"].user == instance.subletter or self.context["request"].user.is_superuser ): - amenities_data = self.context["request"].data - if amenities_data.get("amenities") is not None: - amenities = self.parse_amenities(amenities_data["amenities"]) - instance.amenities.set(amenities) - validated_data.pop("amenities", None) + # amenities_data = self.context["request"].data + # if amenities_data.get("amenities") is not None: + # amenities = self.parse_amenities(amenities_data["amenities"]) + # instance.amenities.set(amenities) + # validated_data.pop("amenities", None) # delete_images_ids = ( # validated_data.pop("delete_images_ids") # if "delete_images_ids" in validated_data diff --git a/backend/sublet/views.py b/backend/sublet/views.py index e3d740d9..8e359863 100644 --- a/backend/sublet/views.py +++ b/backend/sublet/views.py @@ -17,6 +17,7 @@ AmenitySerializer, OfferSerializer, SubletImageSerializer, + SubletImageURLSerializer, SubletSerializer, SubletSerializerRead, SubletSerializerSimple, @@ -192,8 +193,9 @@ def post(self, request, *args, **kwargs): img_serializer = self.get_serializer(data={"sublet": sublet_id, "image": img}) img_serializer.is_valid(raise_exception=True) img_serializers.append(img_serializer) - [img_serializer.save() for img_serializer in img_serializers] - return Response(status=status.HTTP_201_CREATED) + instances = [img_serializer.save() for img_serializer in img_serializers] + data = [SubletImageURLSerializer(instance=instance).data for instance in instances] + return Response(data, status=status.HTTP_201_CREATED) class DeleteImage(generics.DestroyAPIView): diff --git a/backend/tests/sublet/mock_image.jpg b/backend/tests/sublet/mock_image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..425aa56680eae791b9e06a4bf9ba1792de13d4eb GIT binary patch literal 32705 zcmeFZc|4Wf_dmRkgJZ}%l$ns3l7tM0WGX{u5-Ji>#!TTJ8;J&**-@9JV^Zb6#^ZVzs>UHh4-g{kpSbMEKT>I44hpj0uTrQ z5bzJ!+5{ZOuR1t7Tf4YeTXCNE ziG^K+b3gneqAVyb2>&X}svkRgOkMV}!O4>bmwjw6*!si-2F6qtRj#gXA^ukj*m?tK zNQnB0`jLqJfPe;pq(N-G1+1_KNCb|+E)fx<2$2K`lC54qvJH1&aCwL^2Bz06@88#b z`8qQCun&(xn5+i9wy?N7F+DvK5f(jz)7&P+50p8^|0|%f7^mKW2m&8tQ2mA9Zoli@ z)dI-STpi2`09PwGcd$Kg!T|VQ)X(>q4p|rD3#h_k)w%;AS@JLd<@RZLdRK=m!;KaI zq(LcW2RV3!69D;{UhSkEfZ(ujJ_Y~@Hu@a^`E_*`cI#W(0Coq_eWAb!0KvyxC?m%r z8&XrpAxC5E2%iPh>`5?zgm`os1GOx!LZ0TxhpNaWjd$XZ7xKVqyI-E;gb5^KBTe{3 zWiC*|(wbu_5d!Y(5YU;|flt?O&S;oGV#?hEpTrxjP^&)hEpQ$8LqpVu2cnb}@3UY6 ziL6)|J`sh@7ywdFz`k#R1^Ctxa~yiOXagpYP?T(gFhM-&M+0yx1A}lVzyRkQU4uDH+SOD%; zV<1Q_30WLj9dU$Df-~?*XiW_WzR2#j6C7pI!K+ZRa?e6W&fD9|r*ciA6@cTq7$DLO zF(W&6b92@>vqTK6$F&naAswEEHbiDLC>#}mx`3vW6Of1C%zFL(TdZL+8W!c31fP%& zkzfGuIWtp?6HuX70K)G(j`eICe>Mn!{gNY20@4MX0GPVIgiipHRRN+a1;?RA_#}V8 zzEmdQBtWDG&COwIJ%|%X?xF!>12v9iHGX>ufT(^9P5@2goPW8P?l~-h(oY0qm*;!9paCxt(9uI@j>XvqTE|ZwhKDN_FQE7m zKU?BCB*7>E7Or7<;m4*Qw3i=x1A`a0A4tv|07T1z@Y0EMSU|afZ|ttho_3g3GC7Eo z5lpuMI3S7VaWak$_fKy-3FHY5?*ZV{wP&8!;*`49N){5gH$tH1?%AciuAb=D2rAq-pPc8<>6JFd0z@t7ij)!j%>CGWT z+;9`y9}0WYjin6U0;NzZX><^fo8yBP&^)vR1U3%1av)JN05I9Q1<-#g3Rx{X4~XvK z;^X(e#bDP8gIOCY5!{M}(~TES9RQVc7(g0di01)l@xTHz8#DmFcOt5|MnLcm=bgW= zOWLIXkT$>&`+Zf2kyrq<`>=niir8ro5cHkW4#NDC0f6AE%(e=C3xcZL#7nsC2LfL;yJyKD zVB4L45vSlfLp5%RS9&jm*;y(B(lOxD<{%ygWZkj=I7Fgx5)#Y;U~7bhllHZY-L)9v zC%z;B;zW0x3fS*B3y>BsFmSH~w+`f6zylA%z1HgdEI`=ypmFk^b=XdYBp)bHOUwca z@&X(R=`CEJ*vq#-D^5V}2?0Ru3KlNqqSpYjM;!-3VoIk5phpCa;~CEd{LE(OaxjD&r9_$M0Q<3dIHDwR+!!D!x(=z=mwAG_< zncnt>mj6_md-qp6X=R3M|Cz}lEuz1J5S5=^{%0o8J*31yo8dexh2G|3D$FXe+w5OYLP|g5pLhwGH{kl{FMO97&1fIO^R$NRrv`V? zzfx8=jc8Y#M3|e>b8HQd{~Y}}yRGnZYH1tVKk4>R41)uaDWBaR;uu0!`m0zR1AyBHT5t<%B^tdpo`$ckKJ;Bv_Wb!^!Pl!$_}?oQ4@UGvNbIbVg{{JW5UM zwg?c8UGf+J=9+)PC!UCgjG^0c9A4d#f$$ls4*&>uCG7&=TG4QwJ&c7iat-x${{8(> zNkPWFjj(am&)`IgTl98_0$IAfn*@l@(qeqP-@CpoFD@ZgDGW1JFaY7yg$5|1U1dP7 z3B5~i4t7d@x_e4Y2L`Y7$Rz-nqgw&EvAy?(%80{m(00!hI02ZbJc~0fkstpcfRjkR zB_OCe4OQ?&ps@k~ZX!UqwtJx=v{Gx21_WKuenN}Xcoq4y;fgKbc|H{jc;MVlrezL~ zB1cX-!c62mR}KIa#a4e6N!3RY)6d#vjK=G-dl*pz#B%;eTtTQBE;zm8wzOm$%J`DK z&!%ChJ6pm)cqIntJ|6^#tg{%HgP;l8gfofzCk$X%gOLf5iR&|fh8~00JnxtUh+-}A zcytFEP)N@GRlg`57G$9TDa$yZl%)q?t)CaC2b>1sJAu{!VUdNw$;Vu&f&AgCVFW5` zfZ)z!02sen;&8;h9yH+aYqdxMMw6U4gxnwu5Q);`Ykoh)35Z+daWKfVMg!u3gMi34 z1us`EV?cf<8dvikR2*EEUSV)J>qAa}$nMAD@^CV>7qA9|Nx2 z$Zu=n_Q=z46d)0lgkrz}xJTY`3xeeXfULp_sC`D`w@JIZORgW(_@m6OSzIqAq6s8} z=kWf3-%IYy)+9Mn_#MG+#gu*DlL*8khXJn3_>D+M9=_(kcHKJ`M0O8IqT=mJR`8?z zuluSU;{aDpHMgJ6@2Xevj`OcauHAORF5->zZ^we&NPs%H2>7=h@oq1m3T|lsbtAUx z_D~1sC4X!=)Y-M{e;yfrjb7-z|9Vi|2^(0--{$NCyo#z9{(OzB=wwl1^8ejcl`ITx;|Ru5exKno<7uo*{mqzwVTToS6{)O?C3N zvzFUhh(4E@-yDAlWb)x^h$BSL`2t@IAR~qg`0n?Qy|*!d*Bv%Uc7N&W&gP1JAVO{s zE+i(i@N~Ytza;df1z`Cz7FWzJH3-1-Q370)AhE8H07RARZWb;@()$BoE-{G5RyVx? zI1&qYD}R%Rr3EKE=xlEYh>05E=jO}Uzq8@-tpN=vR3`Dz9xd33UK{i;`BZUAa7aFE zHxSfMBmu(Ek)>Z0K=XVnK)hPQ;4p%FkKp9HaliE!1)^g@a4RTRm9&lBsm1~Vy|dB3 zXb`(6`ofLCfwy=JHHsL5Urb9Js0#kpLC$5zzr_Hg+K%<=aLco>(hdD$F+U<*hW1+q}83EdxZn4HmLzk8o}Sgyv%PkYg){J;xK#F+rFTwdn20 ztR9`X<`+yS?Eu@o+-Q@a-T}nd)L2+f2jND{VR#2X{M>*n za%Mi&YgdJ=*qa1y(yMHC0enZ7%07Ezc99cpldN#SJz&IblU30{fL!wZ>bSUz0FJOO zuSPyB{FtCnumcjiCt$$LGgIMs^j~VIVSQlA}6i`Q?HF{?i5?MGytcj2y_6v6k_Y$KnwVc)P_6@2_yTTe~m9lmJPHAR@-!Z4qqS zif}}}q2druMcaaP_`*JA9820~g*oI18KTD+muLf=CgH*Vi#brYL=BL=fG081Tg`JA;yQq6 z42S5s|qf;c$YUgD5Wj{Q#9a z4pt{Ih>uzyG4${w1+rWQv1b+lyJe7y1wf>&l?2Y11Wjm~IxIj;U!n9c!Qwh1eIElq zhoE7T0b+bj(&cD08qP$-D;z!CI+X#DR6 zzT@ltrBvkARBy2uG_Ezjps4golkL7}b5S#b*{c4d#CWx^BedBzSpM zO6Y`vYfS1SX4VBmIt7}bktNIsq_EIC)2wcppHw`bX9O2~_#C*7^Sjh^Xq>Q^^NG0x#@g zeK;>kVnPpss?SL1McZII1YHn&Fo-EHpgJZ6Re1{t;sycqCWN6SSUv()d$F+d*IWt3 z@&3%81mx~vT}D@+D)T7vAx!a3fXMGd?hSz;`FD_l0R#h_)U`NYBQj^Ogy;h-rBx7g zBQL~*0>Dy1e&K!-2EwpGs={c5&^KW45Q>l}r@~MInV1;wkIj~Z;?OT~M7ZdRs8u@T zVhPCR9k(Hb9BdCj@uzKzfY&Dpih`+_9a*@{2Nq^@_WF(tXp7<5jzkGJOhu>QAZf{9 z0J%sU)DK|5Ag(^c6~yiyg9X>7tRO4YgheQLwL)&+bw}_*83Vp1fzV3;Misa0zoE*n55%0bN(W)ep9+LSuWXai95-VtJs1F2IXGtf=2`^;n5N}^Vw(0t7&~(gTVCDaZ znkkC+4}$yu2xA{sENl_GU&OC%m*aq41IR=LonOp}@je4V;IV@K#d3(xs%^NqNv*H{ zdtz~+M)ZwBFe3?0;!O6?G|QL^LAaLz!8d>hdI1b$93k>7_raYQbR!pGJOIunAs=Gk z($b7^zKg~|$dZ1z$iS${0n%n%%~CihuU_v(5)nOQ;lf1evJWz}J*@!02vE3eFGGOE z3*crFoWld$LIV)b(Lfb98<9&a0Hz5X@&pTD{Ntt~D}2iUB4rTVzJ!5UeR<>fwFQW_ z0+qWE0hAxDhEa!RAqOL6Oen7aa)4ChY}9(ACFE#7*4D^|!1hu7`{Ip&tM|_0jikoC z3W&}>Bhea!48z+;k7Ctu5JS2p(i5hLQ?(1J+062&Nbft)q zRZi`W4+vX&Z0k$V{tXa!-_J{bcGl|MTg=03{~iBaTUR49w`Mt_0j0))M6PLew9N@LW3P{ z#B~p$4p}lQL=grcq5<9nu%}L-dk}zn44)&w5kV=&9&*-bI#>=Qgc%xFqIeQ%KVAlT zoXn78(jlDLmW`+>uE5S@5x8&5LP5kOa8cDq9K+xNhPTKwuwjDuHUlnB@DBw<^~ijS zQEodR8^T#{w_S5T!xG&K{;5jLAw^uJ&iT&3I=~5GCO_=M0`oe_L&|I1q_A!!(T$j`R5AGKL1l2?k3hO z|MpgVov`}EQ@_N&wC{DC#X2T1f5$k!_i!2TIrxifN3(jvpTkcrG<{>Tyany2jbf;R$&mJK4Gte4=sT(fS+sOA^<+2@oNdrr2t9_;9LmKNnkIGEC81dydKnNz652s zAQ4={m6WFga8ov%lL5IsCuB|>-d?|mJu!6YSO^daP;ak)W@uc1LL$jgR>rRoG&da% zALaxAuC7|}N(zZU5|NV;5)u*-BH>S2af>S;M$;V8adM%hqvzUxNb$6;WjKoQp!o4~ z&Not?ai5P!W>C_5!=rrRCN<|_$Je|P`fXOOx7v4pDvD45ZO!@uKm4W|tzUGe-?0Al z1j#zD)H19td3HUm=xrZWUh}HbX7sUl4T77U8&Sp+FJijODs4)(}aoz$wq#M^0hAgbV zOPqbbXs+Z}zA0eZF`0Ewr7n_7{^<;bhNPyFmx^-Zbvq|R&5L;nc2W7UPb5!#&EtNY z^ez0l(8Sn7hFsDYMw5l1t_2mgk{7o?y=p$9$j(Ib(cCS%UaaeNLKcp-+9~6@0r_=@%v?Ky_WS^{qr8;4-yS<0*aGEE z%p~1KChMQi`UR;|P^KMd>9nb9s5<58J0cuD@zC}8x@f4Yh~TUwAvMV;cd7eoQ?>q* zOlQ%WmDE>_0m&ks#k66`^RwNlYtg8xPwsx+s#OMtD~Yx-0jU-%LPOyU-IdQS9hnpk zJDT0ZuqYJaohqKGeUmg&q`2j@w7wg{wq)tBWy&6d{Q3MyTlXhLLi}Hj)yq5{{&+=d z@@~IrRBE%na~#`#HRnjg>#*$0oC#k(zb|pScxL+GK_|hYeLRk~p(Qh@yRG>HiV=hv zEFQiAritF;5zl$bj4kwvHChV;coo&T`8h{WFO~gossl=A2l*A(+5yx;34dwsq= zZ5rlnt@hD0(a??3Pp4I{SO3aK;Sx`!!;NgdS-4EOD9fI&IjOFpT6-?JGZSlQ{M_tn zKNU^4h*nizrt_U?>!06xw4UCX_3r(DqZ&P<+C)%N-`AE?$5mIM+HU8 zh4$GRIi}@%++Baa%A)&3S5WKn8 z%fE3w&VlwvQ<^Xj+nmBl)pZBWCvUPe); z;l>g#qtK*^SBADzTi~n!Ea7rlqwz=G$V#zn$#B!hOFGfFJ33

`m&67}%e~XseAD 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 Kb`SE$^uGZR^;pON literal 0 HcmV?d00001 diff --git a/backend/tests/sublet/test_sublets.py b/backend/tests/sublet/test_sublets.py index 48d49a2a..f7221865 100644 --- a/backend/tests/sublet/test_sublets.py +++ b/backend/tests/sublet/test_sublets.py @@ -3,7 +3,6 @@ from django.contrib.auth import get_user_model from django.test import TestCase from rest_framework.test import APIClient - from sublet.models import Amenity, Offer, Sublet @@ -39,9 +38,9 @@ def test_create_sublet(self): "external_link": "https://example.com", "min_price": 100, "max_price": 500, - "expires_at": "2024-02-01T10:48:02-05:00", - "start_date": "2024-04-09", - "end_date": "2024-08-07", + "expires_at": "3000-02-01T10:48:02-05:00", + "start_date": "3000-04-09", + "end_date": "3000-08-07", "amenities": ["Amenity1", "Amenity2"], } response = self.client.post("/sublet/properties/", payload) @@ -76,9 +75,9 @@ def test_update_sublet(self): "external_link": "https://example.com", "min_price": 100, "max_price": 500, - "expires_at": "2024-02-01T10:48:02-05:00", - "start_date": "2024-04-09", - "end_date": "2024-08-07", + "expires_at": "3000-02-01T10:48:02-05:00", + "start_date": "3000-04-09", + "end_date": "3000-08-07", "amenities": ["Amenity1", "Amenity2"], } response = self.client.post("/sublet/properties/", payload) @@ -106,9 +105,9 @@ def test_browse_sublets(self): "external_link": "https://example.com", "min_price": 100, "max_price": 500, - "expires_at": "2024-02-01T10:48:02-05:00", - "start_date": "2024-04-09", - "end_date": "2024-08-07", + "expires_at": "3000-02-01T10:48:02-05:00", + "start_date": "3000-04-09", + "end_date": "3000-08-07", "amenities": ["Amenity1", "Amenity2"], } response = self.client.post("/sublet/properties/", payload) @@ -132,9 +131,9 @@ def test_browse_filtered(self): "external_link": "https://example.com", "min_price": 100, "max_price": 400, - "expires_at": "2024-02-01T10:48:02-05:00", - "start_date": "2024-04-09", - "end_date": "2024-08-07", + "expires_at": "3000-02-01T10:48:02-05:00", + "start_date": "3000-04-09", + "end_date": "3000-08-07", "amenities": ["Amenity1", "Amenity2"], } response = self.client.post("/sublet/properties/", payload) @@ -161,8 +160,8 @@ def test_browse_filtered(self): "external_link": "https://example.com", "min_price": 100, "max_price": 500, - "expires_at": "2024-02-01T10:48:02-05:00", - "start_date": "2024-04-09", + "expires_at": "3000-02-01T10:48:02-05:00", + "start_date": "3000-04-09", "end_date": "5000-08-07", "amenities": ["Amenity1", "Amenity2"], } @@ -182,9 +181,9 @@ def test_browse_sublet(self): "external_link": "https://example.com", "min_price": 100, "max_price": 500, - "expires_at": "2024-02-01T10:48:02-05:00", - "start_date": "2024-04-09", - "end_date": "2024-08-07", + "expires_at": "3000-02-01T10:48:02-05:00", + "start_date": "3000-04-09", + "end_date": "3000-08-07", "amenities": ["Amenity1", "Amenity2"], } self.client.post("/sublet/properties/", payload) @@ -208,6 +207,14 @@ def test_amenities(self): for i in range(1, 6): self.assertIn(f"Amenity{i}", res_json) + def test_create_image(self): + with open("tests/sublet/mock_image.jpg", "rb") as image: + response = self.client.post( + f"/sublet/properties/{str(self.test_sublet1.id)}/images/", {"images": image} + ) + self.assertEqual(response.status_code, 201) + self.assertTrue(Sublet.objects.get(id=self.test_sublet1.id).images.all().exists()) + class TestOffers(TestCase): """Tests Create/Delete/List for offers""" From fb3e80a2bd42caed7cc6719286227a4dfc28b741 Mon Sep 17 00:00:00 2001 From: Justin Zhang Date: Thu, 8 Feb 2024 13:57:44 -0500 Subject: [PATCH 08/15] Mocked AWS call --- backend/sublet/serializers.py | 1 + backend/sublet/urls.py | 1 + backend/sublet/views.py | 1 + backend/tests/sublet/test_sublets.py | 17 +++++++++++++---- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/backend/sublet/serializers.py b/backend/sublet/serializers.py index d109b917..ea00f3c7 100644 --- a/backend/sublet/serializers.py +++ b/backend/sublet/serializers.py @@ -1,5 +1,6 @@ from phonenumber_field.serializerfields import PhoneNumberField from rest_framework import serializers + from sublet.models import Amenity, Offer, Sublet, SubletImage diff --git a/backend/sublet/urls.py b/backend/sublet/urls.py index dbae9aa5..7d3c4495 100644 --- a/backend/sublet/urls.py +++ b/backend/sublet/urls.py @@ -1,5 +1,6 @@ from django.urls import path from rest_framework import routers + from sublet.views import ( Amenities, CreateImages, diff --git a/backend/sublet/views.py b/backend/sublet/views.py index 8e359863..ab816511 100644 --- a/backend/sublet/views.py +++ b/backend/sublet/views.py @@ -6,6 +6,7 @@ from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response + from sublet.models import Amenity, Offer, Sublet, SubletImage from sublet.permissions import ( IsSuperUser, diff --git a/backend/tests/sublet/test_sublets.py b/backend/tests/sublet/test_sublets.py index f7221865..ceceb5bf 100644 --- a/backend/tests/sublet/test_sublets.py +++ b/backend/tests/sublet/test_sublets.py @@ -1,12 +1,12 @@ import json +from unittest.mock import MagicMock 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 sublet.models import Amenity, Offer, Sublet - -# , SubletImage) +from sublet.models import Amenity, Offer, Sublet, SubletImage User = get_user_model() @@ -27,6 +27,13 @@ def setUp(self): self.test_sublet1 = Sublet.objects.create(subletter=self.user, **data[0]) self.test_sublet2 = Sublet.objects.create(subletter=test_user, **data[1]) + 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" + SubletImage._meta.get_field("image").storage = storage_mock + def test_create_sublet(self): # Create a new sublet using the serializer payload = { @@ -213,7 +220,9 @@ def test_create_image(self): f"/sublet/properties/{str(self.test_sublet1.id)}/images/", {"images": image} ) self.assertEqual(response.status_code, 201) - self.assertTrue(Sublet.objects.get(id=self.test_sublet1.id).images.all().exists()) + images = Sublet.objects.get(id=self.test_sublet1.id).images.all() + self.assertTrue(images.exists()) + self.assertEqual(self.test_sublet1.id, images.first().sublet.id) class TestOffers(TestCase): From eed33cf6261bbdf076e5b0f78662f1fc9c2bc23f Mon Sep 17 00:00:00 2001 From: Jesse Zong Date: Thu, 8 Feb 2024 14:50:36 -0500 Subject: [PATCH 09/15] Add tests for multiple images + deletion --- backend/sublet/permissions.py | 4 +--- backend/sublet/serializers.py | 36 +++------------------------- backend/tests/sublet/test_sublets.py | 19 +++++++++++++++ 3 files changed, 23 insertions(+), 36 deletions(-) diff --git a/backend/sublet/permissions.py b/backend/sublet/permissions.py index 3e547f4f..c1aeb314 100644 --- a/backend/sublet/permissions.py +++ b/backend/sublet/permissions.py @@ -38,9 +38,7 @@ def has_permission(self, request, view): 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.sublet.subletter == request.user + return request.method in permissions.SAFE_METHODS or obj.sublet.subletter == request.user class OfferOwnerPermission(permissions.BasePermission): diff --git a/backend/sublet/serializers.py b/backend/sublet/serializers.py index ea00f3c7..d3a8dcb2 100644 --- a/backend/sublet/serializers.py +++ b/backend/sublet/serializers.py @@ -86,32 +86,15 @@ class Meta: "end_date", "expires_at", # "images", + # images are now created/deleted through a separate endpoint (see urls.py) + # this serializer isn't used for getting, + # but gets on sublets will include ids/urls for images ] - # def parse_amenities(self, raw_amenities): - # if isinstance(raw_amenities, list): - # ids = raw_amenities - # else: - # ids = ( - # list() if len(raw_amenities) == 0 else [str(id) for id in raw_amenities.split(",")] - # ) - # return Amenity.objects.filter(name__in=ids) - def create(self, validated_data): validated_data["subletter"] = self.context["request"].user - # images = validated_data.pop("images") if "images" in validated_data else [] instance = super().create(validated_data) - # data = self.context["request"].POST - # amenities = self.parse_amenities(data.getlist("amenities")) - # instance.amenities.set(amenities) instance.save() - # TODO: make this atomic - # img_serializers = [] - # for img in images: - # img_serializer = SubletImageSerializer(data={"sublet": instance.id, "image": img}) - # img_serializer.is_valid(raise_exception=True) - # img_serializers.append(img_serializer) - # [img_serializer.save() for img_serializer in img_serializers] return instance # delete_images is a list of image ids to delete @@ -121,21 +104,8 @@ def update(self, instance, validated_data): self.context["request"].user == instance.subletter or self.context["request"].user.is_superuser ): - # amenities_data = self.context["request"].data - # if amenities_data.get("amenities") is not None: - # amenities = self.parse_amenities(amenities_data["amenities"]) - # instance.amenities.set(amenities) - # validated_data.pop("amenities", None) - # delete_images_ids = ( - # validated_data.pop("delete_images_ids") - # if "delete_images_ids" in validated_data - # else [] - # ) instance = super().update(instance, validated_data) instance.save() - # existing_images = Sublet.objects.get(id=instance.id).images.all() - # [get_object_or_404(existing_images, id=img) for img in delete_images_ids] - # existing_images.filter(id__in=delete_images_ids).delete() return instance else: raise serializers.ValidationError("You do not have permission to update this sublet.") diff --git a/backend/tests/sublet/test_sublets.py b/backend/tests/sublet/test_sublets.py index ceceb5bf..8a132f66 100644 --- a/backend/tests/sublet/test_sublets.py +++ b/backend/tests/sublet/test_sublets.py @@ -224,6 +224,25 @@ def test_create_image(self): self.assertTrue(images.exists()) self.assertEqual(self.test_sublet1.id, images.first().sublet.id) + def test_create_delete_images(self): + with open("tests/sublet/mock_image.jpg", "rb") as image: + with open("tests/sublet/mock_image.jpg", "rb") as image2: + response = self.client.post( + f"/sublet/properties/{str(self.test_sublet1.id)}/images/", + {"images": [image, image2]}, + "multipart", + ) + self.assertEqual(response.status_code, 201) + images = Sublet.objects.get(id=self.test_sublet1.id).images.all() + image_id1 = images.first().id + self.assertTrue(images.exists()) + self.assertEqual(2, images.count()) + self.assertEqual(self.test_sublet1.id, images.first().sublet.id) + response = self.client.delete(f"/sublet/properties/images/{image_id1}/") + self.assertEqual(response.status_code, 204) + self.assertFalse(SubletImage.objects.filter(id=image_id1).exists()) + self.assertEqual(1, SubletImage.objects.all().count()) + class TestOffers(TestCase): """Tests Create/Delete/List for offers""" From 78fc33e083388939b2f4e093151c4949a5602be2 Mon Sep 17 00:00:00 2001 From: Jesse Zong Date: Thu, 8 Feb 2024 15:00:14 -0500 Subject: [PATCH 10/15] How did this not fail in previous runs haha --- backend/tests/sublet/test_sublets.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/tests/sublet/test_sublets.py b/backend/tests/sublet/test_sublets.py index 8a132f66..c5e17e39 100644 --- a/backend/tests/sublet/test_sublets.py +++ b/backend/tests/sublet/test_sublets.py @@ -268,9 +268,10 @@ def test_create_offer(self): "phone_number": "+12155733333", "message": "Message", } - self.client.post(prop_url, payload) + response = self.client.post(prop_url, payload) + # test duplicate prevention self.assertEqual(self.client.post(prop_url, payload).status_code, 406) - offer = Offer.objects.get(pk=1) + offer = Offer.objects.get(id=response.data["id"]) offer_list = [offer.email, offer.phone_number, offer.message, offer.user, offer.sublet] payload_list = [ payload["email"], From f4efde6fe26aa369d50704961fe5ab9c0780b162 Mon Sep 17 00:00:00 2001 From: Jesse Zong Date: Fri, 9 Feb 2024 17:07:31 -0500 Subject: [PATCH 11/15] Add sublet negotiable, swap max/min to reg price fields --- .../migrations/0002_auto_20240209_1649.py | 18 +++++++++++ backend/sublet/models.py | 5 ++- backend/sublet/serializers.py | 12 +++---- backend/sublet/views.py | 9 +++--- backend/tests/sublet/mock_sublets.json | 8 ++--- backend/tests/sublet/test_sublets.py | 31 ++++++++++--------- 6 files changed, 51 insertions(+), 32 deletions(-) create mode 100644 backend/sublet/migrations/0002_auto_20240209_1649.py diff --git a/backend/sublet/migrations/0002_auto_20240209_1649.py b/backend/sublet/migrations/0002_auto_20240209_1649.py new file mode 100644 index 00000000..8b31233d --- /dev/null +++ b/backend/sublet/migrations/0002_auto_20240209_1649.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.24 on 2024-02-09 21:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("sublet", "0001_initial"), + ] + + operations = [ + migrations.RenameField(model_name="sublet", old_name="max_price", new_name="price",), + migrations.RemoveField(model_name="sublet", name="min_price",), + migrations.AddField( + model_name="sublet", name="negotiable", field=models.BooleanField(default=True), + ), + ] diff --git a/backend/sublet/models.py b/backend/sublet/models.py index aad9f155..0a8c1bc7 100644 --- a/backend/sublet/models.py +++ b/backend/sublet/models.py @@ -42,9 +42,8 @@ class Sublet(models.Model): baths = models.IntegerField(null=True, blank=True) description = models.TextField(null=True, blank=True) external_link = models.URLField(max_length=255) - # TODO: change to just one price field and migrateeeee - min_price = models.IntegerField() - max_price = models.IntegerField() + price = models.IntegerField() + negotiable = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) expires_at = models.DateTimeField() start_date = models.DateField() diff --git a/backend/sublet/serializers.py b/backend/sublet/serializers.py index d3a8dcb2..fecceef7 100644 --- a/backend/sublet/serializers.py +++ b/backend/sublet/serializers.py @@ -80,8 +80,8 @@ class Meta: "baths", "description", "external_link", - "min_price", - "max_price", + "price", + "negotiable", "start_date", "end_date", "expires_at", @@ -138,8 +138,8 @@ class Meta: "baths", "description", "external_link", - "min_price", - "max_price", + "price", + "negotiable", "start_date", "end_date", "expires_at", @@ -162,8 +162,8 @@ class Meta: "address", "beds", "baths", - "min_price", - "max_price", + "price", + "negotiable", "start_date", "end_date", "images", diff --git a/backend/sublet/views.py b/backend/sublet/views.py index ab816511..99ac50f7 100644 --- a/backend/sublet/views.py +++ b/backend/sublet/views.py @@ -136,6 +136,7 @@ def list(self, request, *args, **kwargs): 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) @@ -158,10 +159,10 @@ def list(self, request, *args, **kwargs): 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(min_price__gte=min_price) - if max_price: - queryset = queryset.filter(max_price__lte=max_price) + if min_price and max_price: + queryset = queryset.filter(price__gte=min_price).filter(price__lte=max_price) + if negotiable: + queryset = queryset.filter(negotiable=negotiable) if beds: queryset = queryset.filter(beds=beds) if baths: diff --git a/backend/tests/sublet/mock_sublets.json b/backend/tests/sublet/mock_sublets.json index d890016c..9f679304 100644 --- a/backend/tests/sublet/mock_sublets.json +++ b/backend/tests/sublet/mock_sublets.json @@ -6,8 +6,8 @@ "baths": 2, "description": "Test sublet 1", "external_link": "https://pennlabs.org/", - "min_price": 10, - "max_price": 500, + "price": 1000, + "negotiable": true, "expires_at": "3000-02-01T10:48:02-05:00", "start_date": "3000-04-09", "end_date": "3000-08-07" @@ -19,8 +19,8 @@ "baths": 1, "description": "This is test sublet2!!!", "external_link": "https://www.google.com/maps", - "min_price": 0, - "max_price": 1000000, + "price": 1000, + "negotiable": false, "expires_at": "2999-11-28T10:49:10-05:00", "start_date": "3000-12-19", "end_date": "3001-03-07" diff --git a/backend/tests/sublet/test_sublets.py b/backend/tests/sublet/test_sublets.py index c5e17e39..ced48494 100644 --- a/backend/tests/sublet/test_sublets.py +++ b/backend/tests/sublet/test_sublets.py @@ -43,8 +43,8 @@ def test_create_sublet(self): "baths": 1, "description": "This is a test sublet.", "external_link": "https://example.com", - "min_price": 100, - "max_price": 500, + "price": 1000, + "negotiable": True, "expires_at": "3000-02-01T10:48:02-05:00", "start_date": "3000-04-09", "end_date": "3000-08-07", @@ -59,8 +59,8 @@ def test_create_sublet(self): "baths", "description", "external_link", - "min_price", - "max_price", + "price", + "negotiable", "expires_at", "start_date", "end_date", @@ -80,8 +80,8 @@ def test_update_sublet(self): "baths": 1, "description": "This is an old sublet.", "external_link": "https://example.com", - "min_price": 100, - "max_price": 500, + "price": 1000, + "negotiable": True, "expires_at": "3000-02-01T10:48:02-05:00", "start_date": "3000-04-09", "end_date": "3000-08-07", @@ -110,8 +110,8 @@ def test_browse_sublets(self): "baths": 1, "description": "This is a test sublet.", "external_link": "https://example.com", - "min_price": 100, - "max_price": 500, + "price": 1000, + "negotiable": True, "expires_at": "3000-02-01T10:48:02-05:00", "start_date": "3000-04-09", "end_date": "3000-08-07", @@ -136,8 +136,8 @@ def test_browse_filtered(self): "baths": 1, "description": "This is a test sublet.", "external_link": "https://example.com", - "min_price": 100, - "max_price": 400, + "price": 500, + "negotiable": True, "expires_at": "3000-02-01T10:48:02-05:00", "start_date": "3000-04-09", "end_date": "3000-08-07", @@ -147,7 +147,8 @@ def test_browse_filtered(self): old_id = json.loads(response.content)["id"] payload = { "title": "Sublet2", - "max_price": 450, + "max_price": 999, + "min_price": 499, } response = self.client.get("/sublet/properties/", payload) res_json = json.loads(response.content) @@ -165,8 +166,8 @@ def test_browse_filtered(self): "baths": 1, "description": "This is a test sublet.", "external_link": "https://example.com", - "min_price": 100, - "max_price": 500, + "price": 1000, + "negotiable": True, "expires_at": "3000-02-01T10:48:02-05:00", "start_date": "3000-04-09", "end_date": "5000-08-07", @@ -186,8 +187,8 @@ def test_browse_sublet(self): "baths": 1, "description": "This is a test sublet.", "external_link": "https://example.com", - "min_price": 100, - "max_price": 500, + "price": 1000, + "negotiable": True, "expires_at": "3000-02-01T10:48:02-05:00", "start_date": "3000-04-09", "end_date": "3000-08-07", From b6eaa2021f8cbb8ab434c59e7c2532799abed831 Mon Sep 17 00:00:00 2001 From: Jesse Zong Date: Fri, 9 Feb 2024 17:14:49 -0500 Subject: [PATCH 12/15] Fix filtering --- backend/sublet/views.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/sublet/views.py b/backend/sublet/views.py index 99ac50f7..8e2218ee 100644 --- a/backend/sublet/views.py +++ b/backend/sublet/views.py @@ -159,8 +159,10 @@ def list(self, request, *args, **kwargs): queryset = queryset.filter(end_date__lt=ends_before) if ends_after: queryset = queryset.filter(end_date__gt=ends_after) - if min_price and max_price: - queryset = queryset.filter(price__gte=min_price).filter(price__lte=max_price) + 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: From ca0104217188459e8c0c614c2636247cf69a9a33 Mon Sep 17 00:00:00 2001 From: Jesse Zong Date: Tue, 5 Mar 2024 16:00:32 -0500 Subject: [PATCH 13/15] Change amenities return formatting + add comments for sublet/urls --- backend/sublet/serializers.py | 8 ++++++-- backend/sublet/urls.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/backend/sublet/serializers.py b/backend/sublet/serializers.py index fecceef7..549e9e2b 100644 --- a/backend/sublet/serializers.py +++ b/backend/sublet/serializers.py @@ -122,7 +122,9 @@ def destroy(self, instance): class SubletSerializerRead(serializers.ModelSerializer): - amenities = AmenitySerializer(many=True, required=False) + amenities = serializers.PrimaryKeyRelatedField( + many=True, queryset=Amenity.objects.all(), required=False + ) images = SubletImageURLSerializer(many=True, required=False) class Meta: @@ -149,7 +151,9 @@ class Meta: # simple sublet serializer for use when pulling all serializers/etc class SubletSerializerSimple(serializers.ModelSerializer): - amenities = AmenitySerializer(many=True, required=False) + amenities = serializers.PrimaryKeyRelatedField( + many=True, queryset=Amenity.objects.all(), required=False + ) images = SubletImageURLSerializer(many=True, required=False) class Meta: diff --git a/backend/sublet/urls.py b/backend/sublet/urls.py index 7d3c4495..edfde8bf 100644 --- a/backend/sublet/urls.py +++ b/backend/sublet/urls.py @@ -19,18 +19,30 @@ router.register(r"properties", Properties, basename="properties") additional_urls = [ + # List of all amenities path("amenities/", Amenities.as_view(), name="amenities"), + # All favorites for user path("favorites/", UserFavorites.as_view(), name="user-favorites"), + # All offers made by user path("offers/", UserOffers.as_view(), name="user-offers"), + # Favorites + # post: add a sublet to the user's favorites + # delete: remove a sublet from the user's favorites path( "properties//favorites/", Favorites.as_view({"post": "create", "delete": "destroy"}), ), + # Offers + # get: list all offers for a sublet + # post: create an offer for a sublet + # delete: delete an offer for a sublet path( "properties//offers/", Offers.as_view({"get": "list", "post": "create", "delete": "destroy"}), ), + # Image Creation path("properties//images/", CreateImages.as_view()), + # Image Deletion path("properties/images//", DeleteImage.as_view()), ] From 9f47afd4e56ad79006904efcad5d6e69a7df399c Mon Sep 17 00:00:00 2001 From: Jesse Zong Date: Tue, 5 Mar 2024 16:44:53 -0500 Subject: [PATCH 14/15] Alter baths to be 1-point decimal --- .../migrations/0003_alter_sublet_baths.py | 18 ++++++++++++++++++ backend/sublet/models.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 backend/sublet/migrations/0003_alter_sublet_baths.py diff --git a/backend/sublet/migrations/0003_alter_sublet_baths.py b/backend/sublet/migrations/0003_alter_sublet_baths.py new file mode 100644 index 00000000..7f8b65b9 --- /dev/null +++ b/backend/sublet/migrations/0003_alter_sublet_baths.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.9 on 2024-03-05 21:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("sublet", "0002_auto_20240209_1649"), + ] + + operations = [ + migrations.AlterField( + model_name="sublet", + name="baths", + field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True), + ), + ] diff --git a/backend/sublet/models.py b/backend/sublet/models.py index 0a8c1bc7..747634c3 100644 --- a/backend/sublet/models.py +++ b/backend/sublet/models.py @@ -39,7 +39,7 @@ class Sublet(models.Model): 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.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) price = models.IntegerField() From 19a351c49fd87dedcaf5fecc4aa058dcbf72d7f8 Mon Sep 17 00:00:00 2001 From: Jesse Zong Date: Tue, 5 Mar 2024 19:36:21 -0500 Subject: [PATCH 15/15] Fix testing for decimal baths + add testing for new amenities formatting --- backend/tests/sublet/test_sublets.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/tests/sublet/test_sublets.py b/backend/tests/sublet/test_sublets.py index ced48494..38199c85 100644 --- a/backend/tests/sublet/test_sublets.py +++ b/backend/tests/sublet/test_sublets.py @@ -40,7 +40,7 @@ def test_create_sublet(self): "title": "Test Sublet1", "address": "1234 Test Street", "beds": 2, - "baths": 1, + "baths": "1.5", "description": "This is a test sublet.", "external_link": "https://example.com", "price": 1000, @@ -64,6 +64,7 @@ def test_create_sublet(self): "expires_at", "start_date", "end_date", + "amenities", ] [self.assertEqual(payload[key], res_json[key]) for key in match_keys] self.assertIn("id", res_json) @@ -184,7 +185,7 @@ def test_browse_sublet(self): "title": "Test Sublet2", "address": "1234 Test Street", "beds": 2, - "baths": 1, + "baths": "1.5", "description": "This is a test sublet.", "external_link": "https://example.com", "price": 1000, @@ -201,7 +202,8 @@ def test_browse_sublet(self): self.assertEqual(res_json["title"], "Test Sublet2") self.assertEqual(res_json["address"], "1234 Test Street") self.assertEqual(res_json["beds"], 2) - self.assertEqual(res_json["baths"], 1) + self.assertEqual(res_json["baths"], "1.5") + self.assertEqual(res_json["amenities"], ["Amenity1", "Amenity2"]) def test_delete_sublet(self): sublets_count = Sublet.objects.all().count()