households api cleanup
This commit is contained in:
parent
c71863b7bf
commit
3782477dda
4 changed files with 155 additions and 92 deletions
12
README.md
12
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!)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
Loading…
Reference in a new issue