households api cleanup

This commit is contained in:
jableader 2025-11-05 20:24:47 +11:00
parent c71863b7bf
commit 3782477dda
4 changed files with 155 additions and 92 deletions

View file

@ -40,7 +40,7 @@ FastAPI backend for collaborative meal planning, recipe wrangling, and grocery s
- `settings.py` — Environment-driven runtime settings (no external deps)
- `security.py` — Minimal JWT utilities (HS256) + helpers
- `db.py` — aiosqlite connect + `create()` bootstraps all domain tables
- `api/` — HTTP surface (versioned under `/api/v1`)
- `api/` — HTTP surface (versioned under `/api/v1`) - STRICTLY NO SQL AT THIS LAYER!
- `auth.py` — register, login, refresh, logout
- `households.py` — create/list, members, invitations, scoped routes
- `ingredients.py` — household-scoped NLP parsing
@ -49,9 +49,13 @@ FastAPI backend for collaborative meal planning, recipe wrangling, and grocery s
- `shopping.py` — household-scoped current list, purchase, request/unrequest
- `openapi.py` — OpenAPI augmentation (cookie auth, problem+json)
- `deps.py` — DB/session, auth, household scoping, error helpers
- Domain packages (models + repository + helpers):
- Domain packages (models + repository + sql mutation + helpers):
- `persons/`
- `users/`, `households/`, `ingredients/`, `recipes/` (incl. `scraping.py`), `meals/`, `products/` (Coles/Woolworths helpers), `shopping/`
- `scripts/export_openapi.py` — writes `openapi.json` from the live app
- Strive to be useful as a fairly portal package independant of the http layer
- `scripts/` — Utility scripts
- `export_openapi.py` — writes `openapi.json` from the live app
- `manual_parse_recipes.py` — Test parsing against a variety of sources
- `tests/` — API and domain tests with fixtures and sample files
## Quickstart
@ -86,7 +90,7 @@ These are read from the environment (see `settings.py`):
- `DOOF_DB` — SQLite file path (default `./data/doof.sqlite`)
- `DOOF_PROD``true/false` controls frontend proxy vs. static serving (default `false`)
- `FRONTEND_DEV_URL` — dev server to reverse-proxy in non-prod (default `http://localhost:8080/`)
- `FRONTEND_DEV_URL` — dev server to reverse-proxy in non-prod (default `http://localhost:8000/`)
- `DOOF_JWT_ISSUER`, `DOOF_JWT_AUDIENCE` — JWT claims
- `DOOF_JWT_ACCESS_TTL`, `DOOF_JWT_REFRESH_TTL` — TTLs in seconds (default 900/2592000)
- `DOOF_JWT_ACCESS_SECRET_B64`, `DOOF_JWT_REFRESH_SECRET_B64` — base64 secrets (use in prod!)

View file

@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Request
from api.deps import get_current_user, get_db, error_response, get_household_from_slug
from pydantic import Field
from common import ApiModel
from households import repository as households_repo
from users.models import User
router = APIRouter(tags=["households"])
@ -35,19 +36,8 @@ async def list_my_households(
user: User = Depends(get_current_user),
conn: aiosqlite.Connection = Depends(get_db),
):
results: list[HouseholdResponse] = []
async with conn.execute(
"""
SELECT h.id, h.name, h.slug FROM Household h
JOIN HouseholdMember m ON m.household_id = h.id
WHERE m.user_id = ?
ORDER BY h.id
""",
(user.id,),
) as c:
async for row in c:
results.append(HouseholdResponse(id=int(row[0]), name=row[1], slug=row[2]))
return results
results = await households_repo.list_for_user(conn, user.id)
return [HouseholdResponse.model_validate(h) for h in results]
@router.post("/households", response_model=HouseholdResponse)
@ -58,21 +48,10 @@ async def create_household(
conn: aiosqlite.Connection = Depends(get_db),
):
slug = slugify(body.name)
try:
async with conn.execute(
"INSERT INTO Household (name, slug) VALUES (?, ?)", (body.name, slug)
) as cur:
lrid = cur.lastrowid
if lrid is None:
return error_response(request, 400, "Unable to create household")
hid = int(lrid)
await conn.execute(
"INSERT INTO HouseholdMember (user_id, household_id, role) VALUES (?, ?, ?)",
(user.id, hid, "admin"),
)
return HouseholdResponse(id=hid, name=body.name, slug=slug)
except Exception:
household = await households_repo.create_for_user(conn, body.name, slug, user.id)
if household is None:
return error_response(request, 400, "Unable to create household")
return HouseholdResponse.model_validate(household)
# Household-scoped router and endpoint to validate scoping mechanics
@ -101,20 +80,8 @@ async def list_members(
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
):
members: list[HouseholdMember] = []
async with conn.execute(
"""
SELECT u.id, u.display_name, m.role
FROM HouseholdMember m
JOIN User u ON u.id = m.user_id
WHERE m.household_id = ?
ORDER BY lower(u.display_name), u.id
""",
(household["id"],),
) as c:
async for row in c:
members.append(HouseholdMember(id=int(row[0]), display_name=row[1], role=row[2]))
return members
member_data = await households_repo.list_members(conn, household["id"])
return [HouseholdMember.model_validate(m) for m in member_data]
# Invitations
@ -147,14 +114,14 @@ async def create_invitation(
token = secrets.token_urlsafe(24)
expires_at = (datetime.utcnow() + timedelta(days=14)).isoformat() + "Z"
try:
await conn.execute(
"""
INSERT INTO HouseholdInvitation (household_id, email, invited_by_user_id, token, expires_at, status)
VALUES (?, ?, ?, ?, ?, ?)
""",
(household["id"], body.email, user.id, token, expires_at, "pending"),
success = await households_repo.create_invitation(
conn, household["id"], body.email, user.id, token, expires_at
)
if not success:
return error_response(request, 400, "Unable to create invitation")
base = settings.frontend_dev_url
# Ensure base ends with a slash for urljoin
if not base.endswith("/"):
@ -162,8 +129,6 @@ async def create_invitation(
path_with_query = f"invitations/accept?{urlencode({'token': token})}"
invite_link = urljoin(base, path_with_query)
return InviteLinkResponse(invite_link=invite_link)
except Exception:
return error_response(request, 400, "Unable to create invitation")
class AcceptInvitationBody(ApiModel):
@ -183,39 +148,25 @@ async def accept_invitation(
user: User = Depends(get_current_user),
conn: aiosqlite.Connection = Depends(get_db),
):
token = body.token
# Lookup invitation
async with conn.execute(
"SELECT id, household_id, status FROM HouseholdInvitation WHERE token = ?",
(token,),
) as c:
row = await c.fetchone()
if not row:
invitation = await households_repo.get_invitation_by_token(conn, body.token)
if not invitation:
return error_response(request, 404, "Invitation not found")
inv_id = int(row[0])
hid = int(row[1])
status = row[2]
if status != "pending":
if invitation.status != "pending":
return error_response(request, 400, "Invitation not pending")
# Add membership if not exists
await conn.execute(
"INSERT OR IGNORE INTO HouseholdMember (user_id, household_id, role) VALUES (?, ?, ?)",
(user.id, hid, "member"),
)
# Mark invitation accepted
await conn.execute(
"UPDATE HouseholdInvitation SET status = 'accepted' WHERE id = ?",
(inv_id,),
# Add membership and mark invitation accepted
await households_repo.accept_invitation(
conn, invitation.id, user.id, invitation.household_id
)
# Load household details for response
async with conn.execute(
"SELECT id, name, slug FROM Household WHERE id = ?",
(hid,),
) as c:
hrow = await c.fetchone()
if not hrow:
household = await households_repo.get_household_by_id(conn, invitation.household_id)
if not household:
return error_response(request, 404, "Household not found")
return AcceptInvitationResponse(
status="accepted",
household=HouseholdResponse(id=int(hrow[0]), name=hrow[1], slug=hrow[2]),
household=HouseholdResponse.model_validate(household),
)

View file

@ -1,3 +1,4 @@
from __future__ import annotations
from typing import ClassVar
from common import ApiModel
@ -6,7 +7,7 @@ from common import ApiModel
class Household(ApiModel):
KEYS: ClassVar[list[str]] = ["id", "name", "slug"]
id: int = -1
id: int
name: str
slug: str
@ -14,8 +15,8 @@ class Household(ApiModel):
class HouseholdMember(ApiModel):
KEYS: ClassVar[list[str]] = ["user_id", "household_id", "role"]
user_id: int
household_id: int
id: int
display_name: str
role: str
@ -30,7 +31,7 @@ class HouseholdInvitation(ApiModel):
"status",
]
id: int = -1
id: int
household_id: int
email: str
invited_by_user_id: int

View file

@ -1,3 +1,8 @@
from __future__ import annotations
from typing import List
from households.models import Household, HouseholdMember, HouseholdInvitation
async def create(conn):
# Households table
await conn.execute(
@ -75,3 +80,105 @@ async def are_members(conn, household_id: int, user_ids: list[int]) -> bool:
return True
found = await member_ids_in_household(conn, household_id, user_ids)
return found == set(user_ids)
async def list_for_user(conn, user_id: int) -> List[Household]:
results: List[Household] = []
async with conn.execute(
"""
SELECT h.id, h.name, h.slug FROM Household h
JOIN HouseholdMember m ON m.household_id = h.id
WHERE m.user_id = ?
ORDER BY h.id
""",
(user_id,),
) as c:
async for row in c:
results.append(Household(id=int(row[0]), name=row[1], slug=row[2]))
return results
async def create_for_user(conn, name: str, slug: str, user_id: int) -> Household | None:
try:
async with conn.execute(
"INSERT INTO Household (name, slug) VALUES (?, ?)", (name, slug)
) as cur:
lrid = cur.lastrowid
if lrid is None:
return None
hid = int(lrid)
await conn.execute(
"INSERT INTO HouseholdMember (user_id, household_id, role) VALUES (?, ?, ?)",
(user_id, hid, "admin"),
)
return Household(id=hid, name=name, slug=slug)
except Exception:
return None
async def list_members(conn, household_id: int) -> List[HouseholdMember]:
members: List[HouseholdMember] = []
async with conn.execute(
"""
SELECT u.id, u.display_name, m.role
FROM HouseholdMember m
JOIN User u ON u.id = m.user_id
WHERE m.household_id = ?
ORDER BY lower(u.display_name), u.id
""",
(household_id,),
) as c:
async for row in c:
members.append(
HouseholdMember(id=int(row[0]), display_name=row[1], role=row[2])
)
return members
async def create_invitation(
conn, household_id: int, email: str, invited_by_user_id: int, token: str, expires_at: str
) -> bool:
try:
await conn.execute(
"""
INSERT INTO HouseholdInvitation (household_id, email, invited_by_user_id, token, expires_at, status)
VALUES (?, ?, ?, ?, ?, ?)
""",
(household_id, email, invited_by_user_id, token, expires_at, "pending"),
)
return True
except Exception:
return False
async def get_invitation_by_token(conn, token: str) -> HouseholdInvitation | None:
async with conn.execute(
"SELECT id, household_id, status FROM HouseholdInvitation WHERE token = ?",
(token,),
) as c:
row = await c.fetchone()
if not row:
return None
return HouseholdInvitation(id=int(row[0]), household_id=int(row[1]), status=row[2])
async def accept_invitation(conn, invitation_id: int, user_id: int, household_id: int):
await conn.execute(
"INSERT OR IGNORE INTO HouseholdMember (user_id, household_id, role) VALUES (?, ?, ?)",
(user_id, household_id, "member"),
)
await conn.execute(
"UPDATE HouseholdInvitation SET status = 'accepted' WHERE id = ?",
(invitation_id,),
)
async def get_household_by_id(conn, household_id: int) -> Household | None:
async with conn.execute(
"SELECT id, name, slug FROM Household WHERE id = ?",
(household_id,),
) as c:
row = await c.fetchone()
if not row:
return None
return Household(id=int(row[0]), name=row[1], slug=row[2])