Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
.env*.local

# Python
**/__pycache__/
__pycache__/
*.py[cod]
*.pyc
*.pyo
*.pyd
.Python
Expand Down
121 changes: 121 additions & 0 deletions backend/market/management/commands/create_listing_offers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from decimal import Decimal

from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand

from market.models import Listing, Offer


# Create two pending test offers on each listing sold by USER_ID.
# manage.py create_listing_offers <USER_ID>

User = get_user_model()


def _seed_two_buyers(stdout, style):
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()

stdout.write(style.SUCCESS("Buyers ready: Alice Johnson, Bob Williams"))
return alice, bob


def _add_offers_to_listing(listing, alice, bob):
"""Returns (created_count, skipped_count) for this listing."""
_, 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 = int(created_alice) + int(created_bob)
return new_count, 2 - new_count


class Command(BaseCommand):
help = "Create two pending test offers on each listing for USER_ID."

def add_arguments(self, parser):
parser.add_argument(
"user_id",
type=int,
help="Seller's User.id; offers are added to all of their listings.",
)

def handle(self, *args, **options):
user_id = options["user_id"]

try:
seller = User.objects.get(pk=user_id)
except User.DoesNotExist:
self.stdout.write(self.style.ERROR(f"No user with id={user_id}"))
return

alice, bob = _seed_two_buyers(self.stdout, self.style)

listings = list(Listing.objects.filter(seller=seller).order_by("id"))
if not listings:
self.stdout.write(
self.style.WARNING(
f"User {seller.username} (id={user_id}) has no listings."
)
)
return
self.stdout.write(
self.style.SUCCESS(
f"Seeding offers on {len(listings)} listing(s) for "
f"{seller.username} (id={user_id})"
)
)

total_created = 0
total_skipped = 0
for listing in listings:
c, s = _add_offers_to_listing(listing, alice, bob)
total_created += c
total_skipped += s
self.stdout.write(
f" listing id={listing.id} {listing.title!r}: +{c} new, {s} skipped"
)

self.stdout.write(
self.style.SUCCESS(
f"\nDone! Created {total_created} offers, "
f"skipped {total_skipped} (already existed)."
)
)
26 changes: 26 additions & 0 deletions backend/market/migrations/0005_offer_status.py
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,
),
),
]
12 changes: 11 additions & 1 deletion backend/market/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ class User(AbstractUser):


class Offer(models.Model):
class Status(models.TextChoices):
PENDING = "pending", "Pending"
ACCEPTED = "accepted", "Accepted"
REJECTED = "rejected", "Rejected"

class Meta:
constraints = [
models.UniqueConstraint(
Expand All @@ -42,11 +47,15 @@ class Meta:
max_digits=10, decimal_places=2, validators=[MinValueValidator(0)]
)
message = models.TextField(max_length=500, blank=True)
status = models.CharField(
max_length=10, choices=Status.choices, default=Status.PENDING
)
created_at = models.DateTimeField(auto_now_add=True)

def __str__(self):
return f"Offer for {self.listing} made by {self.user}"


class Category(models.Model):
name = models.CharField(max_length=100, unique=True)

Expand Down Expand Up @@ -170,7 +179,8 @@ def _calculate_approximate_location(self, latitude, longitude):
def approximate_location(self):
if self.latitude is not None and self.longitude is not None:
approximate_location = self._calculate_approximate_location(
self.latitude, self.longitude)
self.latitude, self.longitude
)
return approximate_location
return None, None

Expand Down
21 changes: 16 additions & 5 deletions backend/market/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,27 @@ def has_object_permission(self, request, view, obj):
)


class OfferOwnerPermission(permissions.BasePermission):
class ListingOwnerOffersPermission(permissions.BasePermission):
"""
Custom permission to allow owner of an offer to delete it.
The listing seller may act on an Offer for their listing.
Use only on views/actions where that is intended (e.g. list offers, PATCH 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
return obj.listing.seller == request.user
return obj.listing.seller == request.user


class OfferOwnerPermission(permissions.BasePermission):
"""
The user who created the offer may act on that Offer.
Use only on buyer-facing views (e.g. withdraw offer, PATCH details).
"""

return obj.user == request.user
def has_permission(self, request, view):
return request.user.is_authenticated

def has_object_permission(self, request, view, obj):
return obj.user_id == request.user.id
42 changes: 38 additions & 4 deletions backend/market/serializers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError as ModelValidationError
from profanity_check import predict
Expand Down Expand Up @@ -47,14 +46,49 @@ class OfferSerializer(ModelSerializer):

class Meta:
model = Offer
fields = ["id", "user", "listing", "offered_price", "message", "created_at"]
read_only_fields = ["id", "created_at", "user"]
fields = [
"id",
"user",
"listing",
"offered_price",
"message",
"status",
"created_at",
]
read_only_fields = ["id", "created_at", "user", "status"]

def create(self, validated_data):
validated_data["user"] = self.context["request"].user
return super().create(validated_data)


class OfferStatusSerializer(ModelSerializer):
class Meta:
model = Offer
fields = ["id", "status"]
read_only_fields = ["id"]

def validate_status(self, value):
valid_statuses = [choice[0] for choice in Offer.Status.choices]
if value not in valid_statuses:
raise ValidationError(
f"Invalid status. Must be one of: {', '.join(valid_statuses)}"
)
return value


class OfferDetailsSerializer(ModelSerializer):
"""
Allows the offer owner to edit the offer's offered_price and message.
Status is intentionally read-only (managed by the listing owner).
"""

class Meta:
model = Offer
fields = ["id", "offered_price", "message", "status"]
read_only_fields = ["id", "status"]


# Create/Update Image Serializer
class ListingImageSerializer(ModelSerializer):
image = ImageField(write_only=True, required=False, allow_null=True)
Expand Down Expand Up @@ -126,6 +160,7 @@ def get_longitude(self, obj):
return float(approx_lon)
return None


# Unified serializer for all listing types (Items and Sublets); used for CRUD operations
class ListingSerializer(ListingTypeMixin, ModelSerializer):
LISTING_TYPE_CONFIG = {
Expand Down Expand Up @@ -291,7 +326,6 @@ def _create_sublet(self, validated_data, additional_data):
latitude = additional_data.get("latitude")
longitude = additional_data.get("longitude")


if latitude is not None:
latitude = float(latitude)
if longitude is not None:
Expand Down
24 changes: 23 additions & 1 deletion backend/market/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
DeleteImage,
Favorites,
Listings,
MyOfferForListing,
OfferDetailsUpdate,
Offers,
OffersMade,
OffersReceived,
OfferStatusUpdate,
Tags,
UserFavorites,
get_current_user,
Expand Down Expand Up @@ -46,9 +49,28 @@
# 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"}),
),
# Current user's offer for an individual listing
# (Returns 404 when the user has no offer for that listing.)
path(
"listings/<int:listing_id>/offers/mine/",
MyOfferForListing.as_view(),
name="offers-mine",
),
# Update offer status only (PATCH; listing seller or superuser)
path(
"offers/<int:offer_id>/status/",
OfferStatusUpdate.as_view(),
name="offer-status",
),
# Update offer offered_price + message (PATCH; offer owner or superuser)
path(
"offers/<int:offer_id>/details/",
OfferDetailsUpdate.as_view(),
name="offer-details",
),
# Image Creation
path("listings/<listing_id>/images/", CreateImages.as_view()),
# Image Deletion
Expand Down
Loading
Loading