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) - `settings.py` — Environment-driven runtime settings (no external deps)
- `security.py` — Minimal JWT utilities (HS256) + helpers - `security.py` — Minimal JWT utilities (HS256) + helpers
- `db.py` — aiosqlite connect + `create()` bootstraps all domain tables - `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 - `auth.py` — register, login, refresh, logout
- `households.py` — create/list, members, invitations, scoped routes - `households.py` — create/list, members, invitations, scoped routes
- `ingredients.py` — household-scoped NLP parsing - `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 - `shopping.py` — household-scoped current list, purchase, request/unrequest
- `openapi.py` — OpenAPI augmentation (cookie auth, problem+json) - `openapi.py` — OpenAPI augmentation (cookie auth, problem+json)
- `deps.py` — DB/session, auth, household scoping, error helpers - `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/` - `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 - `tests/` — API and domain tests with fixtures and sample files
## Quickstart ## Quickstart
@ -86,7 +90,7 @@ These are read from the environment (see `settings.py`):
- `DOOF_DB` — SQLite file path (default `./data/doof.sqlite`) - `DOOF_DB` — SQLite file path (default `./data/doof.sqlite`)
- `DOOF_PROD``true/false` controls frontend proxy vs. static serving (default `false`) - `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_ISSUER`, `DOOF_JWT_AUDIENCE` — JWT claims
- `DOOF_JWT_ACCESS_TTL`, `DOOF_JWT_REFRESH_TTL` — TTLs in seconds (default 900/2592000) - `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!) - `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 api.deps import get_current_user, get_db, error_response, get_household_from_slug
from pydantic import Field from pydantic import Field
from common import ApiModel from common import ApiModel
from households import repository as households_repo
from users.models import User from users.models import User
router = APIRouter(tags=["households"]) router = APIRouter(tags=["households"])
@ -35,19 +36,8 @@ async def list_my_households(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
): ):
results: list[HouseholdResponse] = [] results = await households_repo.list_for_user(conn, user.id)
async with conn.execute( return [HouseholdResponse.model_validate(h) for h in results]
"""
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
@router.post("/households", response_model=HouseholdResponse) @router.post("/households", response_model=HouseholdResponse)
@ -58,21 +48,10 @@ async def create_household(
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
): ):
slug = slugify(body.name) slug = slugify(body.name)
try: household = await households_repo.create_for_user(conn, body.name, slug, user.id)
async with conn.execute( if household is None:
"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:
return error_response(request, 400, "Unable to create household") return error_response(request, 400, "Unable to create household")
return HouseholdResponse.model_validate(household)
# Household-scoped router and endpoint to validate scoping mechanics # Household-scoped router and endpoint to validate scoping mechanics
@ -101,20 +80,8 @@ async def list_members(
household=Depends(get_household_from_slug), household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
): ):
members: list[HouseholdMember] = [] member_data = await households_repo.list_members(conn, household["id"])
async with conn.execute( return [HouseholdMember.model_validate(m) for m in member_data]
"""
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
# Invitations # Invitations
@ -147,24 +114,22 @@ async def create_invitation(
token = secrets.token_urlsafe(24) token = secrets.token_urlsafe(24)
expires_at = (datetime.utcnow() + timedelta(days=14)).isoformat() + "Z" expires_at = (datetime.utcnow() + timedelta(days=14)).isoformat() + "Z"
try:
await conn.execute( success = await households_repo.create_invitation(
""" conn, household["id"], body.email, user.id, token, expires_at
INSERT INTO HouseholdInvitation (household_id, email, invited_by_user_id, token, expires_at, status) )
VALUES (?, ?, ?, ?, ?, ?)
""", if not success:
(household["id"], body.email, user.id, token, expires_at, "pending"),
)
base = settings.frontend_dev_url
# Ensure base ends with a slash for urljoin
if not base.endswith("/"):
base = base + "/"
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") 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("/"):
base = base + "/"
path_with_query = f"invitations/accept?{urlencode({'token': token})}"
invite_link = urljoin(base, path_with_query)
return InviteLinkResponse(invite_link=invite_link)
class AcceptInvitationBody(ApiModel): class AcceptInvitationBody(ApiModel):
token: str token: str
@ -183,39 +148,25 @@ async def accept_invitation(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
): ):
token = body.token
# Lookup invitation # Lookup invitation
async with conn.execute( invitation = await households_repo.get_invitation_by_token(conn, body.token)
"SELECT id, household_id, status FROM HouseholdInvitation WHERE token = ?", if not invitation:
(token,),
) as c:
row = await c.fetchone()
if not row:
return error_response(request, 404, "Invitation not found") return error_response(request, 404, "Invitation not found")
inv_id = int(row[0])
hid = int(row[1]) if invitation.status != "pending":
status = row[2]
if status != "pending":
return error_response(request, 400, "Invitation not pending") return error_response(request, 400, "Invitation not pending")
# Add membership if not exists
await conn.execute( # Add membership and mark invitation accepted
"INSERT OR IGNORE INTO HouseholdMember (user_id, household_id, role) VALUES (?, ?, ?)", await households_repo.accept_invitation(
(user.id, hid, "member"), conn, invitation.id, user.id, invitation.household_id
)
# Mark invitation accepted
await conn.execute(
"UPDATE HouseholdInvitation SET status = 'accepted' WHERE id = ?",
(inv_id,),
) )
# Load household details for response # Load household details for response
async with conn.execute( household = await households_repo.get_household_by_id(conn, invitation.household_id)
"SELECT id, name, slug FROM Household WHERE id = ?", if not household:
(hid,),
) as c:
hrow = await c.fetchone()
if not hrow:
return error_response(request, 404, "Household not found") return error_response(request, 404, "Household not found")
return AcceptInvitationResponse( return AcceptInvitationResponse(
status="accepted", 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 typing import ClassVar
from common import ApiModel from common import ApiModel
@ -6,7 +7,7 @@ from common import ApiModel
class Household(ApiModel): class Household(ApiModel):
KEYS: ClassVar[list[str]] = ["id", "name", "slug"] KEYS: ClassVar[list[str]] = ["id", "name", "slug"]
id: int = -1 id: int
name: str name: str
slug: str slug: str
@ -14,8 +15,8 @@ class Household(ApiModel):
class HouseholdMember(ApiModel): class HouseholdMember(ApiModel):
KEYS: ClassVar[list[str]] = ["user_id", "household_id", "role"] KEYS: ClassVar[list[str]] = ["user_id", "household_id", "role"]
user_id: int id: int
household_id: int display_name: str
role: str role: str
@ -30,7 +31,7 @@ class HouseholdInvitation(ApiModel):
"status", "status",
] ]
id: int = -1 id: int
household_id: int household_id: int
email: str email: str
invited_by_user_id: int 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): async def create(conn):
# Households table # Households table
await conn.execute( await conn.execute(
@ -75,3 +80,105 @@ async def are_members(conn, household_id: int, user_ids: list[int]) -> bool:
return True return True
found = await member_ids_in_household(conn, household_id, user_ids) found = await member_ids_in_household(conn, household_id, user_ids)
return found == set(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])