-
Notifications
You must be signed in to change notification settings - Fork 0
Lau/listing view #58
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Lau/listing view #58
Changes from 6 commits
fd9c0e7
5a10647
28cb881
34ae14f
9226799
5bbc124
8a00698
b553214
da542e0
35175a8
ca8ac1c
075c60b
bd8f917
79cafc1
35ed950
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| from decimal import Decimal | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This file is a script to artificially add offers to one listing
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok maybe make a comment in the file. Also not sure |
||
|
|
||
| from django.contrib.auth import get_user_model | ||
| from django.core.management.base import BaseCommand | ||
| from django.utils import timezone | ||
|
|
||
| from market.models import Category, Item, Listing, Offer | ||
|
|
||
|
|
||
| User = get_user_model() | ||
|
|
||
|
|
||
| class Command(BaseCommand): | ||
| help = "Seed a listing with two pending offers for testing" | ||
|
|
||
| def add_arguments(self, parser): | ||
| parser.add_argument( | ||
| "--listing-id", | ||
| type=int, | ||
| default=None, | ||
| help="Add offers to an existing listing by ID instead of creating a new one", | ||
| ) | ||
|
|
||
| def handle(self, *args, **options): | ||
| alice, _ = User.objects.get_or_create( | ||
| username="alice", | ||
| defaults={ | ||
| "email": "alice@example.com", | ||
| "first_name": "Alice", | ||
| "last_name": "Johnson", | ||
| "phone_number": "+12155551234", | ||
| "phone_verified": True, | ||
| }, | ||
| ) | ||
| alice.set_password("testpassword123") | ||
| alice.save() | ||
|
|
||
| bob, _ = User.objects.get_or_create( | ||
| username="bob", | ||
| defaults={ | ||
| "email": "bob@example.com", | ||
| "first_name": "Bob", | ||
| "last_name": "Williams", | ||
| "phone_number": "+12155555678", | ||
| "phone_verified": True, | ||
| }, | ||
| ) | ||
| bob.set_password("testpassword123") | ||
| bob.save() | ||
|
|
||
| self.stdout.write(self.style.SUCCESS("Buyers ready: Alice Johnson, Bob Williams")) | ||
|
|
||
| listing_id = options["listing_id"] | ||
| if listing_id: | ||
| try: | ||
| listing = Listing.objects.get(pk=listing_id) | ||
| except Listing.DoesNotExist: | ||
| self.stdout.write(self.style.ERROR(f"Listing with id={listing_id} not found")) | ||
| return | ||
| self.stdout.write(self.style.SUCCESS(f"Using existing listing: {listing.title} (id={listing.id})")) | ||
| else: | ||
| seller, _ = User.objects.get_or_create( | ||
| username="lautaro", | ||
| defaults={ | ||
| "email": "lautaro@example.com", | ||
| "first_name": "Lautaro", | ||
| "last_name": "Beck", | ||
| }, | ||
| ) | ||
| seller.set_password("testpassword123") | ||
| seller.save() | ||
| self.stdout.write(self.style.SUCCESS(f"Seller ready: {seller.get_full_name()}")) | ||
|
|
||
| category, _ = Category.objects.get_or_create(name="Furniture") | ||
| listing = Item.objects.create( | ||
| seller=seller, | ||
| title="New offer", | ||
| description="ASDFASFAFS", | ||
| price=Decimal("12312.00"), | ||
| negotiable=True, | ||
| expires_at=timezone.now() + timezone.timedelta(days=60), | ||
| condition=Item.Condition.GOOD, | ||
| category=category, | ||
| ) | ||
| self.stdout.write(self.style.SUCCESS(f"Created listing: {listing.title}")) | ||
|
|
||
| _, created_alice = Offer.objects.get_or_create( | ||
| user=alice, | ||
| listing=listing, | ||
| defaults={ | ||
| "offered_price": Decimal("40.00"), | ||
| "message": "Would you take $40? I can pick up today.", | ||
| }, | ||
| ) | ||
|
|
||
| _, created_bob = Offer.objects.get_or_create( | ||
| user=bob, | ||
| listing=listing, | ||
| defaults={ | ||
| "offered_price": Decimal("45.00"), | ||
| "message": "Interested! Is the price negotiable?", | ||
| }, | ||
| ) | ||
|
|
||
| new_count = sum([created_alice, created_bob]) | ||
| skipped = 2 - new_count | ||
|
|
||
| self.stdout.write(self.style.SUCCESS(f"Created {new_count} offers, skipped {skipped} (already existed)")) | ||
| self.stdout.write(self.style.SUCCESS(f"\nDone! Offers added to listing id={listing.id}")) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| # Generated by Django 5.0.2 on 2026-03-27 21:34 | ||
|
|
||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ("market", "0004_rename_address_sublet_street_address_and_more"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AddField( | ||
| model_name="offer", | ||
| name="status", | ||
| field=models.CharField( | ||
| choices=[ | ||
| ("pending", "Pending"), | ||
| ("accepted", "Accepted"), | ||
| ("rejected", "Rejected"), | ||
| ], | ||
| default="pending", | ||
| max_length=10, | ||
| ), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -45,14 +45,19 @@ def has_object_permission(self, request, view, obj): | |||||||||||||||||
|
|
||||||||||||||||||
| class OfferOwnerPermission(permissions.BasePermission): | ||||||||||||||||||
| """ | ||||||||||||||||||
| Custom permission to allow owner of an offer to delete it. | ||||||||||||||||||
| - GET: offer owner can view offers on their listing | ||||||||||||||||||
| - DELETE: offer owner can delete their own offer | ||||||||||||||||||
| - PATCH: offer owner can update offer status | ||||||||||||||||||
| """ | ||||||||||||||||||
|
|
||||||||||||||||||
| def has_permission(self, request, view): | ||||||||||||||||||
| return request.user.is_authenticated | ||||||||||||||||||
|
|
||||||||||||||||||
| def has_object_permission(self, request, view, obj): | ||||||||||||||||||
| if request.method in permissions.SAFE_METHODS: # GET | ||||||||||||||||||
| if request.method in permissions.SAFE_METHODS: | ||||||||||||||||||
| return obj.listing.seller == request.user | ||||||||||||||||||
|
|
||||||||||||||||||
| if request.method in ("PATCH", "PUT"): | ||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. you can prob combine these 2 conditions since they are doing the same thing |
||||||||||||||||||
| return obj.listing.seller == request.user | ||||||||||||||||||
|
|
||||||||||||||||||
| return obj.user == request.user | ||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ | |
| OffersReceived, | ||
| Tags, | ||
| UserFavorites, | ||
| change_offer_status, | ||
| get_current_user, | ||
| get_phone_status, | ||
| send_verification_code, | ||
|
|
@@ -46,9 +47,11 @@ | |
| # post: create an offer for an listing | ||
| # delete: delete an offer for an listing | ||
| path( | ||
| "listings/<listing_id>/offers/", | ||
| "listings/<int:listing_id>/offers/", | ||
| Offers.as_view({"get": "list", "post": "create", "delete": "destroy"}), | ||
| ), | ||
| # Update offer status (PATCH) | ||
| path("offers/<int:offer_id>/", change_offer_status, name="offer-status"), | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make it clear that this is for modifying status by adding /status at the end (like how you did for details) |
||
| # Image Creation | ||
| path("listings/<listing_id>/images/", CreateImages.as_view()), | ||
| # Image Deletion | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,7 @@ | |
| ListingSerializerList, | ||
| ListingSerializerPublic, | ||
| OfferSerializer, | ||
| OfferStatusSerializer, | ||
| TagSerializer, | ||
| UserSerializer, | ||
| ) | ||
|
|
@@ -192,6 +193,11 @@ def retrieve(self, request, *args, **kwargs): | |
| serializer = serializer_class(instance) | ||
| return Response(serializer.data) | ||
|
|
||
| def destroy(self, request, *args, **kwargs): | ||
| instance = self.get_object() | ||
| self.perform_destroy(instance) | ||
| return Response({"deleted": True}, status=status.HTTP_200_OK) | ||
|
|
||
|
|
||
| # TODO: This doesn't use CreateAPIView's functionality | ||
| # since we overrode the create method. | ||
|
|
@@ -300,6 +306,11 @@ class Offers(viewsets.ModelViewSet): | |
| serializer_class = OfferSerializer | ||
| pagination_class = PageSizeOffsetPagination | ||
|
|
||
| def get_serializer_class(self): | ||
| if self.action in ["partial_update", "update"]: | ||
| return OfferStatusSerializer | ||
| return OfferSerializer | ||
|
|
||
| def get_queryset(self): | ||
| if Listing.objects.filter(pk=int(self.kwargs["listing_id"])).exists(): | ||
| return Offer.objects.filter( | ||
|
|
@@ -332,7 +343,7 @@ def destroy(self, request, *args, **kwargs): | |
| obj = get_object_or_404(queryset, **filter) | ||
| self.check_object_permissions(self.request, obj) | ||
| self.perform_destroy(obj) | ||
| return Response(status=status.HTTP_204_NO_CONTENT) | ||
| return Response({"deleted": True}, status=status.HTTP_204) | ||
|
|
||
| def list(self, request, *args, **kwargs): | ||
| if not Listing.objects.filter(pk=int(self.kwargs["listing_id"])).exists(): | ||
|
|
@@ -342,6 +353,21 @@ def list(self, request, *args, **kwargs): | |
| return super().list(request, *args, **kwargs) | ||
|
|
||
|
|
||
| @api_view(["PATCH"]) | ||
| @permission_classes([OfferOwnerPermission | IsSuperUser]) | ||
| def change_offer_status(request, offer_id): | ||
| offer = get_object_or_404(Offer, pk=offer_id) | ||
| if not any( | ||
| perm.has_object_permission(request, None, offer) | ||
| for perm in [OfferOwnerPermission(), IsSuperUser()] | ||
| ): | ||
| raise exceptions.PermissionDenied() | ||
| serializer = OfferStatusSerializer(offer, data=request.data, partial=True) | ||
| serializer.is_valid(raise_exception=True) | ||
| serializer.save() | ||
| return Response(OfferSerializer(offer).data) | ||
|
|
||
|
Comment on lines
362
to
+365
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 3 things:
Remember, once you make model changes you need to make migration files, then commit that to version control This technically worksd but it breaks the project's pattern. Every other view delegates authorization to a permission class in |
||
|
|
||
| @api_view(["POST"]) | ||
| @permission_classes([IsAuthenticated]) | ||
| def send_verification_code(request): | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,20 @@ | ||
| import { ListingDetail } from "@/components/listings/detail/ListingDetail"; | ||
| import { getListingOrNotFound } from "@/lib/actions"; | ||
| import { getCurrentUser, getListingOrNotFound, getOffersForListing } from "@/lib/actions"; | ||
|
|
||
| export default async function ItemPage({ params }: { params: Promise<{ id: string }> }) { | ||
| const { id } = await params; | ||
| const item = await getListingOrNotFound(id); | ||
| const [item, currentUser] = await Promise.all([getListingOrNotFound(id), getCurrentUser()]); | ||
| const isOwner = currentUser?.id === item.seller.id; | ||
| const offersResponse = isOwner ? await getOffersForListing(item.id) : null; | ||
| const offers = offersResponse?.results ?? []; | ||
|
|
||
| return <ListingDetail listing={item} initialIsFavorited={item.is_favorited ?? false} />; | ||
| return ( | ||
| <ListingDetail | ||
| listing={item} | ||
| initialIsFavorited={item.is_favorited ?? false} | ||
| offers={offers} | ||
| offersMode={isOwner ? "received" : "made"} | ||
| isOwner={isOwner} | ||
| /> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,20 @@ | ||
| import { ListingDetail } from "@/components/listings/detail/ListingDetail"; | ||
| import { getListingOrNotFound } from "@/lib/actions"; | ||
| import { getCurrentUser, getListingOrNotFound, getOffersForListing } from "@/lib/actions"; | ||
|
|
||
| export default async function SubletPage({ params }: { params: Promise<{ id: string }> }) { | ||
| const { id } = await params; | ||
| const sublet = await getListingOrNotFound(id); | ||
| const [sublet, currentUser] = await Promise.all([getListingOrNotFound(id), getCurrentUser()]); | ||
| const isOwner = currentUser?.id === sublet.seller.id; | ||
| const offersResponse = isOwner ? await getOffersForListing(sublet.id) : null; | ||
| const offers = offersResponse?.results ?? []; | ||
|
|
||
| return <ListingDetail listing={sublet} initialIsFavorited={sublet.is_favorited ?? false} />; | ||
| return ( | ||
| <ListingDetail | ||
| listing={sublet} | ||
| initialIsFavorited={sublet.is_favorited ?? false} | ||
| offers={offers} | ||
| offersMode={isOwner ? "received" : "made"} | ||
| isOwner={isOwner} | ||
| /> | ||
| ); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.