Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
13 changes: 12 additions & 1 deletion backend/app/api/donations.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from ..database import get_db
from ..models import Donation
from ..schemas import Donation as DonationSchema
from ..schemas import DonationCreate, DonationUpdate
from ..schemas import DonationCreate, DonationDetail, DonationUpdate
from ..services import donations_service

router = APIRouter()
Expand Down Expand Up @@ -41,6 +41,17 @@ async def get_donation(donation: Donation = Depends(get_donation_or_404)):
return donation


@router.get("/{donation_id}/detail", response_model=DonationDetail)
async def get_donation_detail(donation_id: str, db: AsyncSession = Depends(get_db)):
"""Donation with donor, items, item photos and pickup — one call for the review screen."""
donation = await donations_service.get_donation_detail(db, donation_id)
if not donation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Donation not found"
)
return donations_service.build_donation_detail(donation)


@router.put("/{donation_id}", response_model=DonationSchema)
async def update_donation(
payload: DonationUpdate,
Expand Down
56 changes: 55 additions & 1 deletion backend/app/api/furniture.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@
from ..database import get_db
from ..models import Furniture
from ..schemas import Furniture as FurnitureSchema
from ..schemas import FurnitureCreate, FurnitureUpdate
from ..schemas import (
FurnitureCreate,
FurnitureDetail,
FurniturePhoto,
FurniturePhotoBase,
FurnitureReject,
FurnitureUpdate,
)
from ..services import furniture_service

router = APIRouter()
Expand Down Expand Up @@ -55,6 +62,53 @@ async def update_furniture(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))


@router.get("/{furniture_id}/detail", response_model=FurnitureDetail)
async def get_furniture_detail(furniture_id: str, db: AsyncSession = Depends(get_db)):
"""Furniture with its ordered photo set."""
furniture = await furniture_service.get_furniture_with_photos(db, furniture_id)
if not furniture:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Furniture not found"
)
return furniture


@router.put("/{furniture_id}/photos", response_model=list[FurniturePhoto])
async def replace_furniture_photos(
payload: list[FurniturePhotoBase],
furniture: Furniture = Depends(get_furniture_or_404),
db: AsyncSession = Depends(get_db),
):
"""Replace the item's whole photo set; list order becomes display order."""
try:
return await furniture_service.replace_furniture_photos(db, furniture, payload)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))


@router.post("/{furniture_id}/approve", response_model=FurnitureSchema)
async def approve_furniture(
furniture: Furniture = Depends(get_furniture_or_404),
db: AsyncSession = Depends(get_db),
):
try:
return await furniture_service.approve_furniture(db, furniture)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))


@router.post("/{furniture_id}/reject", response_model=FurnitureSchema)
async def reject_furniture(
payload: FurnitureReject,
furniture: Furniture = Depends(get_furniture_or_404),
db: AsyncSession = Depends(get_db),
):
try:
return await furniture_service.reject_furniture(db, furniture, payload)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))


@router.delete("/{furniture_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_furniture(
furniture: Furniture = Depends(get_furniture_or_404),
Expand Down
11 changes: 11 additions & 0 deletions backend/app/api/pickups.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ async def update_pickup(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))


@router.post("/{pickup_id}/confirm", response_model=PickupSchema)
async def confirm_pickup(
pickup: Pickup = Depends(get_pickup_or_404),
db: AsyncSession = Depends(get_db),
):
try:
return await pickups_service.confirm_pickup(db, pickup)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))


@router.delete("/{pickup_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_pickup(
pickup: Pickup = Depends(get_pickup_or_404),
Expand Down
25 changes: 25 additions & 0 deletions backend/app/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ class DonationStatus(str, enum.Enum):
CANCELLED = "cancelled"


class DonationReviewStatus(str, enum.Enum):
"""
Progress of an admin's item-by-item review of a donation request.

Derived from the donation's furniture statuses and its pickup — never stored,
so it cannot drift when an item is re-reviewed. See
services.donations.compute_review_status.
"""

PENDING_REVIEW = "pending_review"
PARTIALLY_REVIEWED = "partially_reviewed"
REVIEWED = "reviewed"
SCHEDULED = "scheduled"


# ---------------------------------------------------------------------------
# Furniture
# ---------------------------------------------------------------------------
Expand All @@ -68,12 +83,22 @@ class DonationStatus(str, enum.Enum):
class FurnitureStatus(str, enum.Enum):
PICKUP_PENDING = "PICKUP_PENDING"
APPROVED = "APPROVED"
REJECTED = "REJECTED"
OFFERED = "OFFERED"
SCHEDULED = "SCHEDULED"
DELIVERED = "DELIVERED"
CLOSED = "CLOSED"


class FurnitureRejectionReason(str, enum.Enum):
"""Why an admin rejected a donated item. OTHER requires free-text details."""

CONDITION = "condition"
PICKUP = "pickup"
LOCATION = "location"
OTHER = "other"


class FurnitureConditionEnum(str, enum.Enum):
EXCELLENT = "excellent"
GOOD = "good"
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
Donor,
Dropoff,
Furniture,
FurniturePhoto,
Pickup,
Referral,
Route,
Expand All @@ -23,6 +24,7 @@
"Donor",
"Dropoff",
"Furniture",
"FurniturePhoto",
"Pickup",
"Referral",
"Route",
Expand Down
56 changes: 55 additions & 1 deletion backend/app/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ class Donation(Base):
city = Column(String(100), nullable=True)
postal_code = Column(String(10), nullable=True)
status = Column(String(50), nullable=True) # See DonationStatus
# Household questions are asked once on the donor form, so they live at the
# donation level. Furniture carries its own copy for items routed onward.
smoking_household = Column(Boolean, nullable=True)
has_pets = Column(Boolean, nullable=True)
created_at = Column(
DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None)
)
Expand Down Expand Up @@ -267,8 +271,14 @@ class Pickup(Base):
__tablename__ = "pickups"

id = Column(String(36), primary_key=True, default=generate_uuid)
route_id = Column(String(36), ForeignKey("routes.id"), nullable=False)
# Nullable: an admin schedules a pickup against a donation during review,
# before dispatch assigns it to a route.
route_id = Column(String(36), ForeignKey("routes.id"), nullable=True)
donation_id = Column(String(36), ForeignKey("donations.id"), nullable=True)
scheduled_date = Column(DateTime, nullable=True)
note = Column(Text, nullable=True)
# Set when the donor has been sent — and the admin has confirmed — the date.
confirmed_at = Column(DateTime, nullable=True)
created_at = Column(
DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None)
)
Expand Down Expand Up @@ -304,6 +314,11 @@ class Furniture(Base):
smoking_household = Column(Boolean, nullable=False, default=False)
has_pets = Column(Boolean, nullable=False, default=False)
status = Column(String(50), nullable=False) # See FurnitureStatus
# Set only when status is REJECTED. See FurnitureRejectionReason;
# rejection_details is required when the reason is OTHER.
rejection_reason = Column(String(50), nullable=True)
rejection_details = Column(Text, nullable=True)
reviewed_at = Column(DateTime, nullable=True)
donation_id = Column(String(36), ForeignKey("donations.id"), nullable=True)
referral_id = Column(String(36), ForeignKey("referrals.id"), nullable=True)
pickup_id = Column(String(36), ForeignKey("pickups.id"), nullable=True)
Expand All @@ -321,6 +336,45 @@ class Furniture(Base):
referral = relationship("Referral", back_populates="furniture_items")
pickup = relationship("Pickup", back_populates="furniture_items")
dropoff = relationship("Dropoff", back_populates="furniture", uselist=False)
photos = relationship(
"FurniturePhoto",
back_populates="furniture",
cascade="all, delete-orphan",
order_by="FurniturePhoto.position",
)


# ---------------------------------------------------------------------------
# Furniture photo
# ---------------------------------------------------------------------------


class FurniturePhoto(Base):
"""
One photo of a furniture item.

Donors upload several photos per item and the review UI shows them in order,
so photos live in their own table rather than on Furniture.image_url (which
stays as the single thumbnail).
"""

__tablename__ = "furniture_photos"

id = Column(String(36), primary_key=True, default=generate_uuid)
furniture_id = Column(String(36), ForeignKey("furniture.id"), nullable=False)
url = Column(String(500), nullable=False)
position = Column(Integer, nullable=False, default=0)
created_at = Column(
DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None)
)
updated_at = Column(
DateTime,
default=lambda: datetime.now(timezone.utc).replace(tzinfo=None),
onupdate=lambda: datetime.now(timezone.utc).replace(tzinfo=None),
)

# Relationships
furniture = relationship("Furniture", back_populates="photos")


# ---------------------------------------------------------------------------
Expand Down
Loading