diff --git a/README.md b/README.md index 1e0d548..f738f58 100644 --- a/README.md +++ b/README.md @@ -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!) diff --git a/api/households.py b/api/households.py index 005cc26..8760dce 100644 --- a/api/households.py +++ b/api/households.py @@ -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,24 +114,22 @@ 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"), - ) - 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: + + 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("/"): + 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): token: str @@ -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), ) diff --git a/households/models.py b/households/models.py index d8e8c1d..7684737 100644 --- a/households/models.py +++ b/households/models.py @@ -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 diff --git a/households/repository.py b/households/repository.py index f8b2632..ae4e98d 100644 --- a/households/repository.py +++ b/households/repository.py @@ -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])