Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3d5210f
Add CRUD operations for Furniture resource in API (DEV-45)
petersenmatthew Feb 20, 2026
779bdff
Refactor Furniture API endpoints to use service layer for CRUD operat…
petersenmatthew Feb 24, 2026
3df19ae
Merge remote-tracking branch 'origin/main' into feature/DEV-45/furnit…
petersenmatthew Feb 27, 2026
a54012c
Reduced characters in some lines to pass linting
petersenmatthew Feb 27, 2026
f54bf98
Refactor furniture service methods to accept schema objects instead o…
petersenmatthew Feb 27, 2026
d6edf86
Refactor update_furniture function for improved readability (DEV-45)
petersenmatthew Feb 27, 2026
2feafaf
Rename dispatch_id to route_id for Furniture (DEV-45)
kenzysoror Mar 5, 2026
a70c8f2
Clean up furniture service helpers (DEV-45)
petersenmatthew Mar 9, 2026
96e9569
Handle furniture write route errors consistently (DEV-45)
petersenmatthew Mar 9, 2026
945a68f
Merge branch 'main' into feature/DEV-45/furniture-crud
petersenmatthew Mar 9, 2026
017b99e
Consolidated imports and exports for both services (DEV-45)
petersenmatthew Mar 9, 2026
1a4c186
Regenerate migration (DEV-45)
kenzysoror Mar 9, 2026
f6c8fbe
Refactor migration to rename dispatch_id to route_id and update forei…
petersenmatthew Mar 16, 2026
07ab46b
Merge remote-tracking branch 'origin/main' into feature/DEV-45/furnit…
petersenmatthew Mar 16, 2026
4d29a00
Fix formatting by adding a blank line in the Route class definition i…
petersenmatthew Mar 16, 2026
dcb1f65
Remove conflicting fields from FurnitureBase and FurnitureUpdate sche…
petersenmatthew Mar 16, 2026
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
121 changes: 110 additions & 11 deletions backend/app/api/furniture.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,118 @@
Endpoints for the Furniture resource.
"""

from fastapi import APIRouter, Response, status
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError

from ..config import get_settings
from ..database import get_db
from ..schemas import FurnitureCreate, FurnitureUpdate, Furniture as FurnitureSchema
from ..services import furniture_service

router = APIRouter()


@router.get("")
async def list_furniture():
"""List furniture items.
@router.get("", response_model=list[FurnitureSchema])
async def list_furniture(db: AsyncSession = Depends(get_db)):
"""List furniture items."""
return await furniture_service.list_furniture(db)


@router.post("", response_model=FurnitureSchema, status_code=status.HTTP_201_CREATED)
async def create_furniture(
furniture: FurnitureCreate, db: AsyncSession = Depends(get_db)
):
"""Create a new furniture item."""
try:
return await furniture_service.create_furniture(furniture, db)
except IntegrityError as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Invalid reference: donor_id, client_id, or route_id must "
"reference existing rows."
),
) from e
except Exception as e:
await db.rollback()
if get_settings().DEBUG:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
) from e
raise


@router.get("/{furniture_id}", response_model=FurnitureSchema)
async def get_furniture(furniture_id: str, db: AsyncSession = Depends(get_db)):
"""Get a single furniture item by ID."""
furniture = await furniture_service.get_furniture(furniture_id, db)
if not furniture:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Furniture not found",
)
return furniture


@router.put("/{furniture_id}", response_model=FurnitureSchema)
async def update_furniture(
furniture_id: str,
payload: FurnitureUpdate,
db: AsyncSession = Depends(get_db),
):
"""Update a furniture item."""
try:
furniture = await furniture_service.update_furniture(furniture_id, payload, db)
if not furniture:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Furniture not found",
)
return furniture
except HTTPException:
raise
except IntegrityError as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Invalid reference: donor_id, client_id, or route_id must "
"reference existing rows."
),
) from e
except Exception as e:
await db.rollback()
if get_settings().DEBUG:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
) from e
raise


Placeholder implementation. See `docs/STARTER_BACKEND_GUIDE.md`
for details on implementing this handler with the Furniture model and schemas.
"""
return Response(
content="Not implemented — see docs/STARTER_BACKEND_GUIDE.md",
status_code=status.HTTP_501_NOT_IMPLEMENTED,
)
@router.delete("/{furniture_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_furniture(furniture_id: str, db: AsyncSession = Depends(get_db)):
"""Delete a furniture item."""
try:
deleted = await furniture_service.delete_furniture(furniture_id, db)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Furniture not found",
)
except HTTPException:
raise
except IntegrityError as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Unable to delete furniture because related records still reference it.",
) from e
except Exception as e:
await db.rollback()
if get_settings().DEBUG:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
) from e
raise
17 changes: 17 additions & 0 deletions backend/app/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,13 @@ class Route(Base):
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

# Relationships
furniture = relationship(
"Furniture",
foreign_keys="Furniture.route_id",
back_populates="route",
)


# ---------------------------------------------------------------------------
# Furniture
Expand All @@ -241,6 +248,13 @@ class Furniture(Base):
) # PICKUP_PENDING, APPROVED, OFFERED, SCHEDULED, DELIVERED, CLOSED
image_url = Column(String(500), nullable=True)
description = Column(Text, nullable=True)
date_donated = Column(DateTime, nullable=True)
date_received = Column(DateTime, nullable=True)
address_pickup = Column(String(255), nullable=True)
address_dropoff = Column(String(255), nullable=True)
client_id = Column(String(36), ForeignKey("clients.id"), nullable=True)
change_log = Column(Text, nullable=True) # JSON array of strings
route_id = Column(String(36), ForeignKey("routes.id"), nullable=True)
condition = Column(String(50), nullable=True) # excellent, good, fair, poor
colour = Column(String(50), nullable=True)
category = Column(String(100), nullable=True)
Expand All @@ -251,6 +265,9 @@ class Furniture(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

# Relationships
donor = relationship("Donor", back_populates="furniture_donated")
client = relationship("Client", back_populates="furniture_received")
route = relationship("Route", back_populates="furniture", foreign_keys=[route_id])
donation = relationship("Donation", back_populates="furniture_items")
referral = relationship("Referral", back_populates="furniture_items")

Expand Down
14 changes: 14 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,13 @@ class FurnitureBase(BaseModel):
name: str
image_url: Optional[str] = None
description: Optional[str] = None
date_donated: Optional[datetime] = None
date_received: Optional[datetime] = None
address_pickup: Optional[str] = None
address_dropoff: Optional[str] = None
client_id: Optional[str] = None
change_log: Optional[str] = None # JSON array of strings
route_id: Optional[str] = None
referral_id: Optional[str] = None
condition: Optional[str] = None # excellent, good, fair, poor
colour: Optional[str] = None
Expand All @@ -339,6 +346,13 @@ class FurnitureUpdate(BaseModel):
name: Optional[str] = None
image_url: Optional[str] = None
description: Optional[str] = None
date_donated: Optional[datetime] = None
date_received: Optional[datetime] = None
address_pickup: Optional[str] = None
address_dropoff: Optional[str] = None
client_id: Optional[str] = None
change_log: Optional[str] = None
route_id: Optional[str] = None
referral_id: Optional[str] = None
condition: Optional[str] = None
colour: Optional[str] = None
Expand Down
8 changes: 7 additions & 1 deletion backend/app/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
"""

from . import agencies as agencies_service
from . import furniture as furniture_service
from . import clients as clients_service
from . import donors as donor_service

__all__ = ["agencies_service", "donor_service", "clients_service"]
__all__ = [
"agencies_service",
"furniture_service",
"clients_service",
"donor_service",
]
58 changes: 58 additions & 0 deletions backend/app/services/furniture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Furniture service.

CRUD and business logic for the Furniture resource.
"""

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from ..models import Furniture
from ..schemas import FurnitureCreate, FurnitureUpdate


async def list_furniture(db: AsyncSession) -> list[Furniture]:
"""Return furniture items ordered by name."""
result = await db.execute(select(Furniture).order_by(Furniture.name))
return result.scalars().all()


async def get_furniture(furniture_id: str, db: AsyncSession) -> Furniture | None:
"""Return a furniture item by id, or None if not found."""
result = await db.execute(select(Furniture).where(Furniture.id == furniture_id))
return result.scalar_one_or_none()


async def create_furniture(payload: FurnitureCreate, db: AsyncSession) -> Furniture:
"""Create a furniture item. IntegrityError may be raised."""
db_furniture = Furniture(**payload.model_dump())
db.add(db_furniture)
await db.commit()
await db.refresh(db_furniture)
return db_furniture


async def update_furniture(
furniture_id: str,
payload: FurnitureUpdate,
db: AsyncSession,
) -> Furniture | None:
"""Update a furniture item by id. Returns None if not found."""
furniture = await get_furniture(furniture_id, db)
if not furniture:
return None
update_data = payload.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(furniture, key, value)
await db.commit()
await db.refresh(furniture)
return furniture


async def delete_furniture(furniture_id: str, db: AsyncSession) -> bool:
"""Delete a furniture item by id. Returns True if deleted, False if not found."""
furniture = await get_furniture(furniture_id, db)
if not furniture:
return False
await db.delete(furniture)
await db.commit()
return True
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""rename_furniture_dispatch_id_to_route_id

Revision ID: 2f907bea2c75
Revises: 1fac88bbbe70
Create Date: 2026-03-09 02:50:01.205536

"""

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision = "2f907bea2c75"
down_revision = "1fac88bbbe70"
branch_labels = None
depends_on = None


def upgrade():
op.drop_constraint("furniture_dispatch_id_fkey", "furniture", type_="foreignkey")
op.alter_column(
"furniture",
"dispatch_id",
existing_type=sa.String(length=36),
existing_nullable=True,
new_column_name="route_id",
)
op.create_foreign_key(
"furniture_route_id_fkey", "furniture", "routes", ["route_id"], ["id"]
)


def downgrade():
op.drop_constraint("furniture_route_id_fkey", "furniture", type_="foreignkey")
op.alter_column(
"furniture",
"route_id",
existing_type=sa.String(length=36),
existing_nullable=True,
new_column_name="dispatch_id",
)
op.create_foreign_key(
"furniture_dispatch_id_fkey", "furniture", "routes", ["dispatch_id"], ["id"]
)
8 changes: 4 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,14 +330,14 @@ Donors
Furniture
├─ Donor (many-to-one)
├─ Client (many-to-one, nullable)
└─ Route / dispatch (many-to-one, nullable, via dispatch_id)
└─ Route (many-to-one, nullable, via route_id)

Referrals
├─ Client (many-to-one)
└─ Agency (many-to-one)

Routes
└─ Furniture (via pickup_furniture_ids / dropoff_furniture_ids and dispatch_id)
└─ Furniture (via pickup_furniture_ids / dropoff_furniture_ids and route_id)
```

### Data model relationships
Expand All @@ -347,9 +347,9 @@ Routes
- **Agency** → **Agents**, **Clients**, **Referrals** (one-to-many).
- **Client** → **Agency** (many-to-one, via `agency_id`); **Referrals**, **Furniture** (one-to-many).
- **Donor** → **Furniture** (one-to-many).
- **Furniture** → **Donor** (many-to-one); **Client** (many-to-one, nullable); **Route** (many-to-one, nullable, via `dispatch_id`).
- **Furniture** → **Donor** (many-to-one); **Client** (many-to-one, nullable); **Route** (many-to-one, nullable, via `route_id`).
- **Referral** → **Client**, **Agency** (many-to-one).
- **Route** → **Furniture** (one-to-many, via `pickup_furniture_ids`, `dropoff_furniture_ids`, and `dispatch_id`).
- **Route** → **Furniture** (one-to-many, via `pickup_furniture_ids`, `dropoff_furniture_ids`, and `route_id`).

### Migrations

Expand Down
2 changes: 1 addition & 1 deletion frontend/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export interface Furniture {
address_dropoff: string | null;
client_id: string | null;
change_log: string | null;
dispatch_id: string | null;
route_id: string | null;
condition: string | null;
colour: string | null;
donor_id: string;
Expand Down
Loading