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
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
12 changes: 5 additions & 7 deletions backend/app/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,10 +221,10 @@ class Route(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

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


Expand All @@ -251,7 +251,7 @@ class Furniture(Base):
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
dispatch_id = Column(String(36), ForeignKey("routes.id"), nullable=True)
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)
donor_id = Column(String(36), ForeignKey("donors.id"), nullable=False)
Expand All @@ -274,9 +274,7 @@ class Furniture(Base):
# Relationships
donor = relationship("Donor", back_populates="furniture_donated")
client = relationship("Client", back_populates="furniture_received")
dispatch = relationship(
"Route", back_populates="furniture_pickups", foreign_keys=[dispatch_id]
)
route = relationship("Route", back_populates="furniture", foreign_keys=[route_id])


# ---------------------------------------------------------------------------
Expand Down
4 changes: 2 additions & 2 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ class FurnitureBase(BaseModel):
address_dropoff: Optional[str] = None
client_id: Optional[str] = None
change_log: Optional[str] = None # JSON array of strings
dispatch_id: Optional[str] = None
route_id: Optional[str] = None
condition: Optional[str] = None # excellent, good, fair, poor
colour: Optional[str] = None
donor_id: str
Expand Down Expand Up @@ -362,7 +362,7 @@ class FurnitureUpdate(BaseModel):
address_dropoff: Optional[str] = None
client_id: Optional[str] = None
change_log: Optional[str] = None
dispatch_id: Optional[str] = None
route_id: Optional[str] = None
condition: Optional[str] = None
colour: Optional[str] = None
donor_id: Optional[str] = None
Expand Down
17 changes: 9 additions & 8 deletions backend/app/services/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""Services package.

Business logic layer; API routes depend on services, not the reverse.
"""

from . import clients as clients_service

__all__ = ["clients_service"]
"""Services package.

Business logic layer; API routes depend on services, not the reverse.
"""

from . import furniture as furniture_service
from . import clients as clients_service

__all__ = ["furniture_service", "clients_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,43 @@
"""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():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"furniture", sa.Column("route_id", sa.String(length=36), nullable=True)
)
op.drop_constraint("furniture_dispatch_id_fkey", "furniture", type_="foreignkey")
op.create_foreign_key(None, "furniture", "routes", ["route_id"], ["id"])
op.drop_column("furniture", "dispatch_id")
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"furniture",
sa.Column(
"dispatch_id", sa.VARCHAR(length=36), autoincrement=False, nullable=True
),
)
op.drop_constraint(None, "furniture", type_="foreignkey")
op.create_foreign_key(
"furniture_dispatch_id_fkey", "furniture", "routes", ["dispatch_id"], ["id"]
)
op.drop_column("furniture", "route_id")
# ### end Alembic commands ###
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