Cutover cleanup: inline meals/shopping v2 routers, neutralize legacy auth_v2

This commit is contained in:
jableader 2025-11-01 17:24:15 +11:00
parent bd44a6acbe
commit 584732e497
9 changed files with 822 additions and 778 deletions

View file

@ -1,10 +1,192 @@
"""Canonical auth router now delegates to auth_v2 (JWT-based). from __future__ import annotations
This preserves `api.auth` import path while using the v2 implementation. import base64
""" import hashlib
import hmac
import os
from typing import Optional
import aiosqlite
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from api.deps import error_response, get_db
from common import ApiModel
from security import JwtConfig, create_jwt
from settings import settings
from users import repository as users_db
# Prefer Argon2 for new passwords; keep PBKDF2 verify for backward compatibility
try:
from argon2 import PasswordHasher
_ph: PasswordHasher | None = PasswordHasher()
except Exception: # pragma: no cover - optional dependency in some environments
_ph = None
from users.models import User
router = APIRouter(prefix="/auth", tags=["auth"])
class RegisterBody(ApiModel):
email: str
password: str
display_name: str
class LoginBody(ApiModel):
email: str
password: str
class TokenResponse(ApiModel):
access_token: str
token_type: str = "bearer"
user: User
PBKDF2_ALG = "pbkdf2_sha256"
PBKDF2_ITER = 390000 # kept for verifying older hashes
SALT_BYTES = 16
def _hash_pw(pw: str) -> str:
"""Hash a password.
Uses Argon2 when available; falls back to PBKDF2 for environments without argon2-cffi.
"""
if _ph is not None:
return _ph.hash(pw)
# Fallback
salt = os.urandom(SALT_BYTES)
dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), salt, PBKDF2_ITER)
return f"{PBKDF2_ALG}${PBKDF2_ITER}${base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}"
def _verify_pw(pw: str, stored: str) -> bool:
"""Verify password against either Argon2 or PBKDF2 stored hashes."""
# Try Argon2 first
if _ph is not None and stored.startswith("$argon2"):
try:
return _ph.verify(stored, pw)
except Exception:
return False
# PBKDF2 fallback
try:
alg, iter_s, salt_b64, hash_b64 = stored.split("$", 3)
if alg != PBKDF2_ALG:
return False
iters = int(iter_s)
salt = base64.b64decode(salt_b64)
expected = base64.b64decode(hash_b64)
dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), salt, iters)
return hmac.compare_digest(dk, expected)
except Exception:
return False
def _jwt_config() -> JwtConfig:
# Secrets can be provided base64-encoded via env; fallback to deterministic dev defaults (NOT for prod)
if settings.access_secret_b64:
access = base64.b64decode(settings.access_secret_b64)
else:
access = b"dev-access-secret-change-me-32bytes!!"[:32]
if settings.refresh_secret_b64:
refresh = base64.b64decode(settings.refresh_secret_b64)
else:
refresh = b"dev-refresh-secret-change-me-32bytes!!"[:32]
return JwtConfig(
issuer=settings.jwt_issuer,
audience=settings.jwt_audience,
access_secret=access,
refresh_secret=refresh,
access_ttl_seconds=settings.access_ttl_seconds,
refresh_ttl_seconds=settings.refresh_ttl_seconds,
)
def _token_pair_for_user(user: User) -> tuple[str, str]:
cfg = _jwt_config()
sub = str(user.id)
access = create_jwt(cfg, sub, kind="access", extra={"user_id": user.id, "email": user.email})
refresh = create_jwt(cfg, sub, kind="refresh")
return access, refresh
@router.post("/register", response_model=TokenResponse, operation_id="register")
async def register(
request: Request, body: RegisterBody, conn: aiosqlite.Connection = Depends(get_db)
):
existing = await users_db.get_by_email(conn, body.email)
if existing:
return error_response(request, 400, "Email already registered")
uid = await users_db.insert_user(conn, body.email, body.display_name)
await users_db.set_local_credentials(conn, uid, _hash_pw(body.password))
user = await users_db.get_by_email(conn, body.email)
assert user is not None
access, refresh = _token_pair_for_user(user)
resp = JSONResponse(TokenResponse(access_token=access, user=user).model_dump(by_alias=True))
# HttpOnly refresh cookie
resp.set_cookie(
key="refresh_token",
value=refresh,
httponly=True,
secure=False,
samesite="lax",
max_age=settings.refresh_ttl_seconds,
path="/api/v1/auth/refresh",
)
return resp
@router.post("/login", response_model=TokenResponse, operation_id="loginV2")
async def login(request: Request, body: LoginBody, conn: aiosqlite.Connection = Depends(get_db)):
user: Optional[User] = await users_db.get_by_email(conn, body.email)
if not user:
return error_response(request, 401, "Invalid credentials")
stored = await users_db.get_local_password_hash(conn, user.id)
if not stored or not _verify_pw(body.password, stored):
return error_response(request, 401, "Invalid credentials")
access, refresh = _token_pair_for_user(user)
resp = JSONResponse(TokenResponse(access_token=access, user=user).model_dump(by_alias=True))
resp.set_cookie(
key="refresh_token",
value=refresh,
httponly=True,
secure=False,
samesite="lax",
max_age=settings.refresh_ttl_seconds,
path="/api/v1/auth/refresh",
)
return resp
class RefreshResponse(ApiModel):
access_token: str
token_type: str = "bearer"
@router.post("/refresh", response_model=RefreshResponse, operation_id="refreshV2")
async def refresh(request: Request):
token = request.cookies.get("refresh_token")
if not token:
return error_response(request, 401, "Unauthorized")
from security import verify_jwt
try:
_h, payload = verify_jwt(_jwt_config(), token, expected_kind="refresh")
except Exception:
return error_response(request, 401, "Unauthorized")
user_id = str(payload.get("sub"))
access = create_jwt(_jwt_config(), user_id, kind="access")
return RefreshResponse(access_token=access)
@router.post("/logout", operation_id="logoutV2")
async def logout():
resp = JSONResponse({"ok": True})
resp.delete_cookie("refresh_token", path="/api/v1/auth/refresh")
return resp
from .auth_v2 import router # re-export canonical router
# Hint to linters that the symbol is intentionally re-exported
__all__ = ["router"] __all__ = ["router"]
_UNUSED = (router,)

View file

@ -1,189 +1,7 @@
from __future__ import annotations """Deprecated: v2 auth is inlined into api/auth.py.
import base64 This file intentionally contains no imports or runtime definitions to avoid
import hashlib lint/type issues while the file remains present during refactors.
import hmac """
import os
from typing import Optional
import aiosqlite __all__ = []
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from api.deps import error_response, get_db
from common import ApiModel
from security import JwtConfig, create_jwt
from settings import settings
from users import repository as users_db
# Prefer Argon2 for new passwords; keep PBKDF2 verify for backward compatibility
try:
from argon2 import PasswordHasher
_ph: PasswordHasher | None = PasswordHasher()
except Exception: # pragma: no cover - optional dependency in some environments
_ph = None
from users.models import User
router = APIRouter(prefix="/auth", tags=["auth-v2"])
class RegisterBody(ApiModel):
email: str
password: str
display_name: str
class LoginBody(ApiModel):
email: str
password: str
class TokenResponse(ApiModel):
access_token: str
token_type: str = "bearer"
user: User
PBKDF2_ALG = "pbkdf2_sha256"
PBKDF2_ITER = 390000 # kept for verifying older hashes
SALT_BYTES = 16
def _hash_pw(pw: str) -> str:
"""Hash a password.
Uses Argon2 when available; falls back to PBKDF2 for environments without argon2-cffi.
"""
if _ph is not None:
return _ph.hash(pw)
# Fallback
salt = os.urandom(SALT_BYTES)
dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), salt, PBKDF2_ITER)
return f"{PBKDF2_ALG}${PBKDF2_ITER}${base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}"
def _verify_pw(pw: str, stored: str) -> bool:
"""Verify password against either Argon2 or PBKDF2 stored hashes."""
# Try Argon2 first
if _ph is not None and stored.startswith("$argon2"):
try:
return _ph.verify(stored, pw)
except Exception:
return False
# PBKDF2 fallback
try:
alg, iter_s, salt_b64, hash_b64 = stored.split("$", 3)
if alg != PBKDF2_ALG:
return False
iters = int(iter_s)
salt = base64.b64decode(salt_b64)
expected = base64.b64decode(hash_b64)
dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), salt, iters)
return hmac.compare_digest(dk, expected)
except Exception:
return False
def _jwt_config() -> JwtConfig:
# Secrets can be provided base64-encoded via env; fallback to deterministic dev defaults (NOT for prod)
if settings.access_secret_b64:
access = base64.b64decode(settings.access_secret_b64)
else:
access = b"dev-access-secret-change-me-32bytes!!"[:32]
if settings.refresh_secret_b64:
refresh = base64.b64decode(settings.refresh_secret_b64)
else:
refresh = b"dev-refresh-secret-change-me-32bytes!!"[:32]
return JwtConfig(
issuer=settings.jwt_issuer,
audience=settings.jwt_audience,
access_secret=access,
refresh_secret=refresh,
access_ttl_seconds=settings.access_ttl_seconds,
refresh_ttl_seconds=settings.refresh_ttl_seconds,
)
def _token_pair_for_user(user: User) -> tuple[str, str]:
cfg = _jwt_config()
sub = str(user.id)
access = create_jwt(cfg, sub, kind="access", extra={"user_id": user.id, "email": user.email})
refresh = create_jwt(cfg, sub, kind="refresh")
return access, refresh
@router.post("/register", response_model=TokenResponse, operation_id="register")
async def register(
request: Request, body: RegisterBody, conn: aiosqlite.Connection = Depends(get_db)
):
existing = await users_db.get_by_email(conn, body.email)
if existing:
return error_response(request, 400, "Email already registered")
uid = await users_db.insert_user(conn, body.email, body.display_name)
await users_db.set_local_credentials(conn, uid, _hash_pw(body.password))
user = await users_db.get_by_email(conn, body.email)
assert user is not None
access, refresh = _token_pair_for_user(user)
resp = JSONResponse(TokenResponse(access_token=access, user=user).model_dump(by_alias=True))
# HttpOnly refresh cookie
resp.set_cookie(
key="refresh_token",
value=refresh,
httponly=True,
secure=False,
samesite="lax",
max_age=settings.refresh_ttl_seconds,
path="/api/v1/auth/refresh",
)
return resp
@router.post("/login", response_model=TokenResponse, operation_id="loginV2")
async def login(request: Request, body: LoginBody, conn: aiosqlite.Connection = Depends(get_db)):
user: Optional[User] = await users_db.get_by_email(conn, body.email)
if not user:
return error_response(request, 401, "Invalid credentials")
stored = await users_db.get_local_password_hash(conn, user.id)
if not stored or not _verify_pw(body.password, stored):
return error_response(request, 401, "Invalid credentials")
access, refresh = _token_pair_for_user(user)
resp = JSONResponse(TokenResponse(access_token=access, user=user).model_dump(by_alias=True))
resp.set_cookie(
key="refresh_token",
value=refresh,
httponly=True,
secure=False,
samesite="lax",
max_age=settings.refresh_ttl_seconds,
path="/api/v1/auth/refresh",
)
return resp
class RefreshResponse(ApiModel):
access_token: str
token_type: str = "bearer"
@router.post("/refresh", response_model=RefreshResponse, operation_id="refreshV2")
async def refresh(request: Request):
token = request.cookies.get("refresh_token")
if not token:
return error_response(request, 401, "Unauthorized")
from security import verify_jwt
try:
_h, payload = verify_jwt(_jwt_config(), token, expected_kind="refresh")
except Exception:
return error_response(request, 401, "Unauthorized")
user_id = str(payload.get("sub"))
access = create_jwt(_jwt_config(), user_id, kind="access")
return RefreshResponse(access_token=access)
@router.post("/logout", operation_id="logoutV2")
async def logout():
resp = JSONResponse({"ok": True})
resp.delete_cookie("refresh_token", path="/api/v1/auth/refresh")
return resp

View file

@ -1,13 +1,370 @@
"""Canonical meals router now delegates to v2 (household-scoped) implementation. from __future__ import annotations
This file preserves the public import path `api.meals` for tests and app wiring, import datetime
and exposes the FastAPI router defined in meals_v2. from typing import List, Optional
"""
from .meals_v2 import router # re-export canonical router import aiosqlite
from fastapi import APIRouter, Depends, Query, Request, Response
import meals
import persons
import shopping
import ingredients
from common import ProblemDetails, ApiModel
from api.dtos import MemberRef
from api.deps import error_response
from api.deps import get_db, get_household_from_slug
# Keep validate_meal import surface for tests that reference api.meals.validate_meal # Keep validate_meal import surface for tests that reference api.meals.validate_meal
from meals.service import validate_meal from meals.service import validate_meal
router = APIRouter(prefix="/households/{householdSlug}/meals", tags=["meals"])
# MemberRef now imported from api.dtos
class MealRecipeIn(ApiModel):
meal_id: int
recipe_id: int
servings: float
class MealIn(ApiModel):
id: int = -1
suggested_date: datetime.datetime
consumed_date: Optional[datetime.datetime] = None
chefs: List[MemberRef]
cleanup: List[MemberRef]
consumers: List[MemberRef]
recipes: List[MealRecipeIn] = []
extra_ingredients: List[ingredients.Ingredient] = []
class MealOut(ApiModel):
id: int = -1
suggested_date: datetime.datetime
consumed_date: Optional[datetime.datetime] = None
chefs: List[MemberRef]
cleanup: List[MemberRef]
consumers: List[MemberRef]
recipes: List[meals.MealRecipe]
extra_ingredients: List[ingredients.Ingredient]
purchase_date: Optional[datetime.datetime] = None
class MarkConsumedBody(ApiModel):
consumed_date: Optional[datetime.datetime] = None
@router.get(
"/upcoming",
operation_id="getUpcomingMealsV2",
summary="List upcoming meals in a date range (scoped)",
)
async def get_upcoming_meals_scoped(
household=Depends(get_household_from_slug),
date_from: datetime.datetime = Query(..., alias="from"),
to: datetime.datetime = Query(...),
conn: aiosqlite.Connection = Depends(get_db),
) -> List[MealOut]:
hid = household["id"]
# Use a scoped repository function, falling back to filtering in SQL until repository is fully scoped
try:
async for _ in meals.find_upcoming_meals_by_date_range_scoped(conn, date_from, to, hid):
# if function exists, break immediately to use it
break
use_scoped = True
except AttributeError:
use_scoped = False
result: List[meals.Meal] = []
if use_scoped:
async for meal in meals.find_upcoming_meals_by_date_range_scoped(conn, date_from, to, hid):
result.append(meal)
else:
# Temporary path: direct query with household_id filter
async with conn.execute(
f"""
SELECT {",".join(meals.Meal.KEYS)} FROM Meal
WHERE suggested_date >= ? AND suggested_date <= ? AND consumed_date IS NULL AND deleted_date IS NULL AND household_id = ?
""",
(date_from, to, hid),
) as cursor:
async for row in cursor:
result.append(meals.Meal(**{k: v for k, v in zip(meals.Meal.KEYS, row)}))
if not result:
return []
# Load relateds similar to v1
await meals.bulk_load_participants(conn, result)
for meal in result:
await meals.load_recipes(conn, meal)
await meals.load_extra_ingredients(conn, meal)
# Map domain Meal -> outward MealOut
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
out: List[MealOut] = []
for m in result:
out.append(
MealOut(
id=m.id,
suggested_date=m.suggested_date,
consumed_date=m.consumed_date,
chefs=[_to_member(p) for p in m.chefs],
cleanup=[_to_member(p) for p in m.cleanup],
consumers=[_to_member(p) for p in m.consumers],
recipes=m.recipes,
extra_ingredients=m.extra_ingredients,
purchase_date=m.purchase_date,
)
)
return out
@router.get(
"/{meal_id}",
operation_id="getMealV2",
summary="Get a meal by id (scoped)",
response_model=MealOut,
responses={404: {"model": ProblemDetails}},
)
async def get_meal_scoped(
meal_id: int,
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> MealOut | Response:
hid = household["id"]
meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
if not meal:
return error_response(request, 404, "Meal not found")
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=meal.id,
suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date,
chefs=[_to_member(p) for p in meal.chefs],
cleanup=[_to_member(p) for p in meal.cleanup],
consumers=[_to_member(p) for p in meal.consumers],
recipes=meal.recipes,
extra_ingredients=meal.extra_ingredients,
purchase_date=meal.purchase_date,
)
@router.post(
"/{meal_id}/consumed",
operation_id="markMealConsumedV2",
summary="Mark a meal as consumed (scoped)",
response_model=MealOut,
responses={
400: {"model": ProblemDetails},
404: {"model": ProblemDetails},
},
)
async def mark_meal_consumed_scoped(
meal_id: int,
request: Request,
body: Optional[MarkConsumedBody] = None,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> MealOut | Response:
hid = household["id"]
consumed_date: Optional[datetime.datetime] = None
if body is not None:
# Model aliasing handles consumedDate -> consumed_date
consumed_date = getattr(body, "consumed_date", None)
if consumed_date is not None and not getattr(consumed_date, "tzinfo", None):
return error_response(request, 400, "Consumed date must include timezone")
meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
if not meal:
return error_response(request, 404, "Meal not found")
await meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone())
# Clear any outstanding meal request entries for this meal
await shopping.remove_request(conn, person=None, meal=meal)
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=meal.id,
suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date,
chefs=[_to_member(p) for p in meal.chefs],
cleanup=[_to_member(p) for p in meal.cleanup],
consumers=[_to_member(p) for p in meal.consumers],
recipes=meal.recipes,
extra_ingredients=meal.extra_ingredients,
purchase_date=meal.purchase_date,
)
@router.post(
"",
operation_id="createMealV2",
summary="Create a new meal (scoped)",
response_model=MealOut,
responses={400: {"model": ProblemDetails}},
)
async def create_meal_scoped(
meal: MealIn,
response: Response,
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> MealOut | Response:
# Validate using existing service logic
# Map MealIn -> domain Meal
def _from_member(m: MemberRef) -> persons.Person:
return persons.Person(id=m.id, name=m.display_name)
domain_meal = meals.Meal(
id=meal.id,
suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date,
chefs=[_from_member(p) for p in meal.chefs],
cleanup=[_from_member(p) for p in meal.cleanup],
consumers=[_from_member(p) for p in meal.consumers],
recipes=[
meals.MealRecipe(meal_id=r.meal_id, recipe_id=r.recipe_id, servings=r.servings)
for r in meal.recipes
],
extra_ingredients=list(meal.extra_ingredients),
)
msg = meals.validate_meal(domain_meal)
if msg:
return error_response(request, 400, msg)
hid = household["id"]
await meals.insert_meal_scoped(conn, domain_meal, hid)
response.headers["Location"] = f"/api/v1/households/{household['slug']}/meals/{domain_meal.id}"
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=domain_meal.id,
suggested_date=domain_meal.suggested_date,
consumed_date=domain_meal.consumed_date,
chefs=[_to_member(p) for p in domain_meal.chefs],
cleanup=[_to_member(p) for p in domain_meal.cleanup],
consumers=[_to_member(p) for p in domain_meal.consumers],
recipes=domain_meal.recipes,
extra_ingredients=domain_meal.extra_ingredients,
purchase_date=domain_meal.purchase_date,
)
@router.put(
"/{meal_id}",
operation_id="updateMealV2",
summary="Update an existing meal (scoped)",
response_model=MealOut,
responses={400: {"model": ProblemDetails}, 404: {"model": ProblemDetails}},
)
async def update_meal_scoped(
meal_id: int,
meal: MealIn,
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> MealOut | Response:
if meal.id != meal_id:
return error_response(request, 400, "Meal ID in URL does not match meal ID in body")
hid = household["id"]
existing = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
if not existing:
return error_response(request, 404, "Meal not found")
def _from_member(m: MemberRef) -> persons.Person:
return persons.Person(id=m.id, name=m.display_name)
domain_meal = meals.Meal(
id=meal.id,
suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date,
chefs=[_from_member(p) for p in meal.chefs],
cleanup=[_from_member(p) for p in meal.cleanup],
consumers=[_from_member(p) for p in meal.consumers],
recipes=[
meals.MealRecipe(meal_id=r.meal_id, recipe_id=r.recipe_id, servings=r.servings)
for r in meal.recipes
],
extra_ingredients=list(meal.extra_ingredients),
)
msg = meals.validate_meal(domain_meal)
if msg:
return error_response(request, 400, msg)
await meals.update_meal(conn, domain_meal)
# Return updated state
updated = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
assert updated is not None
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=updated.id,
suggested_date=updated.suggested_date,
consumed_date=updated.consumed_date,
chefs=[_to_member(p) for p in updated.chefs],
cleanup=[_to_member(p) for p in updated.cleanup],
consumers=[_to_member(p) for p in updated.consumers],
recipes=updated.recipes,
extra_ingredients=updated.extra_ingredients,
purchase_date=updated.purchase_date,
)
@router.delete(
"/{meal_id}",
operation_id="deleteMealV2",
summary="Delete a meal (scoped)",
response_model=MealOut,
responses={404: {"model": ProblemDetails}},
)
async def delete_meal_scoped(
meal_id: int,
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> MealOut | Response:
hid = household["id"]
meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
if not meal:
return error_response(request, 404, "Meal not found")
# Remove outstanding requests for this meal in current household
try:
from shopping.repository import remove_meal_request_scoped
await remove_meal_request_scoped(conn, meal_id, hid)
except Exception:
# Fallback: remove regardless of household (legacy cleanup)
await shopping.remove_request(conn, person=None, meal=meal)
await meals.delete_meal(conn, meal.id)
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=meal.id,
suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date,
chefs=[_to_member(p) for p in meal.chefs],
cleanup=[_to_member(p) for p in meal.cleanup],
consumers=[_to_member(p) for p in meal.consumers],
recipes=meal.recipes,
extra_ingredients=meal.extra_ingredients,
purchase_date=meal.purchase_date,
)
__all__ = ["router", "validate_meal"] __all__ = ["router", "validate_meal"]
_UNUSED = (router, validate_meal)

View file

@ -1,364 +1,3 @@
from __future__ import annotations """Deprecated: content inlined into api/meals.py"""
import datetime __all__: list[str] = []
from typing import List, Optional
import aiosqlite
from fastapi import APIRouter, Depends, Query, Request, Response
import meals
import persons
import shopping
import ingredients
from common import ProblemDetails, ApiModel
from api.dtos import MemberRef
from api.deps import error_response
from api.deps import get_db, get_household_from_slug
router = APIRouter(prefix="/households/{householdSlug}/meals", tags=["meals"])
# MemberRef now imported from api.dtos
class MealRecipeIn(ApiModel):
meal_id: int
recipe_id: int
servings: float
class MealIn(ApiModel):
id: int = -1
suggested_date: datetime.datetime
consumed_date: Optional[datetime.datetime] = None
chefs: List[MemberRef]
cleanup: List[MemberRef]
consumers: List[MemberRef]
recipes: List[MealRecipeIn] = []
extra_ingredients: List[ingredients.Ingredient] = []
class MealOut(ApiModel):
id: int = -1
suggested_date: datetime.datetime
consumed_date: Optional[datetime.datetime] = None
chefs: List[MemberRef]
cleanup: List[MemberRef]
consumers: List[MemberRef]
recipes: List[meals.MealRecipe]
extra_ingredients: List[ingredients.Ingredient]
purchase_date: Optional[datetime.datetime] = None
class MarkConsumedBody(ApiModel):
consumed_date: Optional[datetime.datetime] = None
@router.get(
"/upcoming",
operation_id="getUpcomingMealsV2",
summary="List upcoming meals in a date range (scoped)",
)
async def get_upcoming_meals_scoped(
household=Depends(get_household_from_slug),
date_from: datetime.datetime = Query(..., alias="from"),
to: datetime.datetime = Query(...),
conn: aiosqlite.Connection = Depends(get_db),
) -> List[MealOut]:
hid = household["id"]
# Use a scoped repository function, falling back to filtering in SQL until repository is fully scoped
try:
async for _ in meals.find_upcoming_meals_by_date_range_scoped(conn, date_from, to, hid):
# if function exists, break immediately to use it
break
use_scoped = True
except AttributeError:
use_scoped = False
result: List[meals.Meal] = []
if use_scoped:
async for meal in meals.find_upcoming_meals_by_date_range_scoped(conn, date_from, to, hid):
result.append(meal)
else:
# Temporary path: direct query with household_id filter
async with conn.execute(
f"""
SELECT {",".join(meals.Meal.KEYS)} FROM Meal
WHERE suggested_date >= ? AND suggested_date <= ? AND consumed_date IS NULL AND deleted_date IS NULL AND household_id = ?
""",
(date_from, to, hid),
) as cursor:
async for row in cursor:
result.append(meals.Meal(**{k: v for k, v in zip(meals.Meal.KEYS, row)}))
if not result:
return []
# Load relateds similar to v1
await meals.bulk_load_participants(conn, result)
for meal in result:
await meals.load_recipes(conn, meal)
await meals.load_extra_ingredients(conn, meal)
# Map domain Meal -> outward MealOut
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
out: List[MealOut] = []
for m in result:
out.append(
MealOut(
id=m.id,
suggested_date=m.suggested_date,
consumed_date=m.consumed_date,
chefs=[_to_member(p) for p in m.chefs],
cleanup=[_to_member(p) for p in m.cleanup],
consumers=[_to_member(p) for p in m.consumers],
recipes=m.recipes,
extra_ingredients=m.extra_ingredients,
purchase_date=m.purchase_date,
)
)
return out
@router.get(
"/{meal_id}",
operation_id="getMealV2",
summary="Get a meal by id (scoped)",
response_model=MealOut,
responses={404: {"model": ProblemDetails}},
)
async def get_meal_scoped(
meal_id: int,
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> MealOut | Response:
hid = household["id"]
meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
if not meal:
return error_response(request, 404, "Meal not found")
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=meal.id,
suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date,
chefs=[_to_member(p) for p in meal.chefs],
cleanup=[_to_member(p) for p in meal.cleanup],
consumers=[_to_member(p) for p in meal.consumers],
recipes=meal.recipes,
extra_ingredients=meal.extra_ingredients,
purchase_date=meal.purchase_date,
)
@router.post(
"/{meal_id}/consumed",
operation_id="markMealConsumedV2",
summary="Mark a meal as consumed (scoped)",
response_model=MealOut,
responses={
400: {"model": ProblemDetails},
404: {"model": ProblemDetails},
},
)
async def mark_meal_consumed_scoped(
meal_id: int,
request: Request,
body: Optional[MarkConsumedBody] = None,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> MealOut | Response:
hid = household["id"]
consumed_date: Optional[datetime.datetime] = None
if body is not None:
# Model aliasing handles consumedDate -> consumed_date
consumed_date = getattr(body, "consumed_date", None)
if consumed_date is not None and not getattr(consumed_date, "tzinfo", None):
return error_response(request, 400, "Consumed date must include timezone")
meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
if not meal:
return error_response(request, 404, "Meal not found")
await meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone())
# Clear any outstanding meal request entries for this meal
await shopping.remove_request(conn, person=None, meal=meal)
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=meal.id,
suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date,
chefs=[_to_member(p) for p in meal.chefs],
cleanup=[_to_member(p) for p in meal.cleanup],
consumers=[_to_member(p) for p in meal.consumers],
recipes=meal.recipes,
extra_ingredients=meal.extra_ingredients,
purchase_date=meal.purchase_date,
)
@router.post(
"",
operation_id="createMealV2",
summary="Create a new meal (scoped)",
response_model=MealOut,
responses={400: {"model": ProblemDetails}},
)
async def create_meal_scoped(
meal: MealIn,
response: Response,
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> MealOut | Response:
# Validate using existing service logic
# Map MealIn -> domain Meal
def _from_member(m: MemberRef) -> persons.Person:
return persons.Person(id=m.id, name=m.display_name)
domain_meal = meals.Meal(
id=meal.id,
suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date,
chefs=[_from_member(p) for p in meal.chefs],
cleanup=[_from_member(p) for p in meal.cleanup],
consumers=[_from_member(p) for p in meal.consumers],
recipes=[
meals.MealRecipe(meal_id=r.meal_id, recipe_id=r.recipe_id, servings=r.servings)
for r in meal.recipes
],
extra_ingredients=list(meal.extra_ingredients),
)
msg = meals.validate_meal(domain_meal)
if msg:
return error_response(request, 400, msg)
hid = household["id"]
await meals.insert_meal_scoped(conn, domain_meal, hid)
response.headers["Location"] = f"/api/v1/households/{household['slug']}/meals/{domain_meal.id}"
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=domain_meal.id,
suggested_date=domain_meal.suggested_date,
consumed_date=domain_meal.consumed_date,
chefs=[_to_member(p) for p in domain_meal.chefs],
cleanup=[_to_member(p) for p in domain_meal.cleanup],
consumers=[_to_member(p) for p in domain_meal.consumers],
recipes=domain_meal.recipes,
extra_ingredients=domain_meal.extra_ingredients,
purchase_date=domain_meal.purchase_date,
)
@router.put(
"/{meal_id}",
operation_id="updateMealV2",
summary="Update an existing meal (scoped)",
response_model=MealOut,
responses={400: {"model": ProblemDetails}, 404: {"model": ProblemDetails}},
)
async def update_meal_scoped(
meal_id: int,
meal: MealIn,
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> MealOut | Response:
if meal.id != meal_id:
return error_response(request, 400, "Meal ID in URL does not match meal ID in body")
hid = household["id"]
existing = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
if not existing:
return error_response(request, 404, "Meal not found")
def _from_member(m: MemberRef) -> persons.Person:
return persons.Person(id=m.id, name=m.display_name)
domain_meal = meals.Meal(
id=meal.id,
suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date,
chefs=[_from_member(p) for p in meal.chefs],
cleanup=[_from_member(p) for p in meal.cleanup],
consumers=[_from_member(p) for p in meal.consumers],
recipes=[
meals.MealRecipe(meal_id=r.meal_id, recipe_id=r.recipe_id, servings=r.servings)
for r in meal.recipes
],
extra_ingredients=list(meal.extra_ingredients),
)
msg = meals.validate_meal(domain_meal)
if msg:
return error_response(request, 400, msg)
await meals.update_meal(conn, domain_meal)
# Return updated state
updated = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
assert updated is not None
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=updated.id,
suggested_date=updated.suggested_date,
consumed_date=updated.consumed_date,
chefs=[_to_member(p) for p in updated.chefs],
cleanup=[_to_member(p) for p in updated.cleanup],
consumers=[_to_member(p) for p in updated.consumers],
recipes=updated.recipes,
extra_ingredients=updated.extra_ingredients,
purchase_date=updated.purchase_date,
)
@router.delete(
"/{meal_id}",
operation_id="deleteMealV2",
summary="Delete a meal (scoped)",
response_model=MealOut,
responses={404: {"model": ProblemDetails}},
)
async def delete_meal_scoped(
meal_id: int,
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> MealOut | Response:
hid = household["id"]
meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
if not meal:
return error_response(request, 404, "Meal not found")
# Remove outstanding requests for this meal in current household
try:
from shopping.repository import remove_meal_request_scoped
await remove_meal_request_scoped(conn, meal_id, hid)
except Exception:
# Fallback: remove regardless of household (legacy cleanup)
await shopping.remove_request(conn, person=None, meal=meal)
await meals.delete_meal(conn, meal.id)
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=meal.id,
suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date,
chefs=[_to_member(p) for p in meal.chefs],
cleanup=[_to_member(p) for p in meal.cleanup],
consumers=[_to_member(p) for p in meal.consumers],
recipes=meal.recipes,
extra_ingredients=meal.extra_ingredients,
purchase_date=meal.purchase_date,
)

View file

@ -1,14 +1,209 @@
"""Canonical shopping router now delegates to v2 and exposes shared DTOs. from __future__ import annotations
This keeps import path `api.shopping` available for shared models used by v2 from typing import Dict, List
and for any tests that import mapping helpers.
"""
# Re-export shared DTOs/mappers minimally for type references import aiosqlite
from .shopping_models import CurrentShoppingList, ShoppingListOut, PurchasedShoppingList from fastapi import APIRouter, Depends, Request, Response
# Expose the v2 router under the canonical module import shopping
from .shopping_v2 import router from common import ApiModel as _ApiModel
from api.deps import error_response, get_db, get_household_from_slug, get_current_user
from api.shopping_models import (
CurrentShoppingList,
ShoppingListOut,
PurchasedShoppingList,
_to_ingredient_item,
_to_meal_item,
_to_shopping_list_out,
RequestedMealItem,
PurchaseListIn,
)
__all__ = ["router", "CurrentShoppingList", "ShoppingListOut", "PurchasedShoppingList"] router = APIRouter(prefix="/households/{householdSlug}/shopping", tags=["shopping"])
_UNUSED = (router, CurrentShoppingList, ShoppingListOut, PurchasedShoppingList)
@router.get(
"/current",
response_model=CurrentShoppingList,
operation_id="getCurrentShoppingListV2",
summary="Get the current aggregated shopping list (scoped)",
)
async def get_current_shopping_list_scoped(
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> CurrentShoppingList:
hid = household["id"]
(
outstanding_requests,
purchased_requests,
meal_requests,
meals_lookup,
recipes_lookup,
ingredients_lookup,
) = await shopping.get_outstanding_requests_scoped(conn, hid)
# Load full lists for additional lookups (by household)
other_shopping_list_ids = {item.list_id for item in purchased_requests}
other_lists_domain: Dict[int, shopping.ShoppingList] = {}
for list_id in other_shopping_list_ids:
if list_id is not None:
sl = await shopping.load_shopping_list_scoped(conn, list_id, hid)
if sl is not None:
other_lists_domain[list_id] = sl
# Add any additional items from shopping lists to the existing lookups
additional_items = [item for sl in other_lists_domain.values() for item in sl.items]
if additional_items:
await shopping.to_lookups(
conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup
)
shopping_list_lookup = {k: _to_shopping_list_out(v) for k, v in other_lists_domain.items()}
return CurrentShoppingList(
outstanding_items=[_to_ingredient_item(i) for i in outstanding_requests],
requested_meals=[_to_meal_item(i) for i in meal_requests],
purchased_items=[_to_ingredient_item(i) for i in purchased_requests],
meals_lookup=meals_lookup,
shopping_list_lookup=shopping_list_lookup,
ingredients_lookup=ingredients_lookup,
recipes_lookup=recipes_lookup,
)
@router.get(
"/{list_id}",
response_model=PurchasedShoppingList,
operation_id="getShoppingListV2",
summary="Get a purchased shopping list by id (scoped)",
)
async def get_shopping_list_scoped(
list_id: int,
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> PurchasedShoppingList | Response:
hid = household["id"]
shopping_list = await shopping.load_shopping_list_scoped(conn, list_id, hid)
if not shopping_list:
return error_response(request, 404, "Shopping list not found")
meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups(
conn, shopping_list.items
)
return PurchasedShoppingList(
list=_to_shopping_list_out(shopping_list),
meals_lookup=meals_lookup,
recipes_lookup=recipes_lookup,
ingredients_lookup=ingredients_lookup,
)
@router.post(
"",
response_model=PurchasedShoppingList,
operation_id="purchaseIngredientsV2",
summary="Purchase ingredients for a shopping list (scoped)",
)
async def purchase_ingredients_scoped(
shopping_list: PurchaseListIn,
request: Request,
household=Depends(get_household_from_slug),
user=Depends(get_current_user),
conn: aiosqlite.Connection = Depends(get_db),
) -> PurchasedShoppingList | Response:
hid = household["id"]
# Map outward input DTO to domain model
domain_items: List[shopping.ShoppingListItem] = []
for it in shopping_list.items:
created = it.created_date or __import__("datetime").datetime.now().astimezone()
domain_items.append(
shopping.ShoppingListItem(
ingredient_id=it.ingredient_id,
person_id=it.person_id,
meal_id=it.meal_id,
recipe_id=it.recipe_id,
created_date=created,
)
)
domain_list = shopping.ShoppingList(
items=domain_items, store_name=shopping_list.store_name, purchased_by_id=user.id
)
try:
# Use household-scoped purchase which ensures requests belong to the same household
await shopping.purchase_scoped(conn, domain_list, hid)
except ValueError as e:
return error_response(request, 400, str(e))
# Lookups for outward response
meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups(
conn, domain_list.items
)
return PurchasedShoppingList(
list=_to_shopping_list_out(domain_list),
meals_lookup=meals_lookup,
recipes_lookup=recipes_lookup,
ingredients_lookup=ingredients_lookup,
)
class MealIdWrapper(_ApiModel):
meal_id: int
class Ok(_ApiModel):
ok: bool = True
@router.post(
"/current/meals/me",
response_model=RequestedMealItem,
operation_id="requestMealV2",
summary="Request a meal for shopping (scoped)",
)
async def request_meal_scoped(
r: MealIdWrapper,
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
):
hid = household["id"]
from meals.repository import find_meal_by_id_scoped
meal = await find_meal_by_id_scoped(conn, r.meal_id, hid)
if not meal:
return error_response(request, 404, "Meal not found")
try:
item = await shopping.request_meal_scoped(conn, meal, hid)
except ValueError as e:
return error_response(request, 400, str(e))
return _to_meal_item(item)
@router.delete(
"/current/meals/{meal_id}",
response_model=Ok,
operation_id="unrequestMealV2",
summary="Remove a meal request (scoped)",
)
async def unrequest_meal_scoped(
meal_id: int,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
):
hid = household["id"]
await shopping.remove_meal_request_scoped(conn, meal_id, hid)
return Ok()
# Re-export shared DTOs for importers
__all__ = [
"router",
"CurrentShoppingList",
"ShoppingListOut",
"PurchasedShoppingList",
]

View file

@ -1,199 +1,3 @@
from __future__ import annotations """Deprecated: content inlined into api/shopping.py"""
from typing import Dict, List __all__: list[str] = []
import aiosqlite
from fastapi import APIRouter, Depends, Request, Response
import shopping
from common import ApiModel as _ApiModel
from api.deps import error_response, get_db, get_household_from_slug, get_current_user
from api.shopping_models import (
CurrentShoppingList,
PurchasedShoppingList,
_to_ingredient_item,
_to_meal_item,
_to_shopping_list_out,
RequestedMealItem,
PurchaseListIn,
)
router = APIRouter(prefix="/households/{householdSlug}/shopping", tags=["shopping"])
@router.get(
"/current",
response_model=CurrentShoppingList,
operation_id="getCurrentShoppingListV2",
summary="Get the current aggregated shopping list (scoped)",
)
async def get_current_shopping_list_scoped(
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> CurrentShoppingList:
hid = household["id"]
(
outstanding_requests,
purchased_requests,
meal_requests,
meals_lookup,
recipes_lookup,
ingredients_lookup,
) = await shopping.get_outstanding_requests_scoped(conn, hid)
# Load full lists for additional lookups (by household)
other_shopping_list_ids = {item.list_id for item in purchased_requests}
other_lists_domain: Dict[int, shopping.ShoppingList] = {}
for list_id in other_shopping_list_ids:
if list_id is not None:
sl = await shopping.load_shopping_list_scoped(conn, list_id, hid)
if sl is not None:
other_lists_domain[list_id] = sl
# Add any additional items from shopping lists to the existing lookups
additional_items = [item for sl in other_lists_domain.values() for item in sl.items]
if additional_items:
await shopping.to_lookups(
conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup
)
shopping_list_lookup = {k: _to_shopping_list_out(v) for k, v in other_lists_domain.items()}
return CurrentShoppingList(
outstanding_items=[_to_ingredient_item(i) for i in outstanding_requests],
requested_meals=[_to_meal_item(i) for i in meal_requests],
purchased_items=[_to_ingredient_item(i) for i in purchased_requests],
meals_lookup=meals_lookup,
shopping_list_lookup=shopping_list_lookup,
ingredients_lookup=ingredients_lookup,
recipes_lookup=recipes_lookup,
)
@router.get(
"/{list_id}",
response_model=PurchasedShoppingList,
operation_id="getShoppingListV2",
summary="Get a purchased shopping list by id (scoped)",
)
async def get_shopping_list_scoped(
list_id: int,
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> PurchasedShoppingList | Response:
hid = household["id"]
shopping_list = await shopping.load_shopping_list_scoped(conn, list_id, hid)
if not shopping_list:
return error_response(request, 404, "Shopping list not found")
meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups(
conn, shopping_list.items
)
return PurchasedShoppingList(
list=_to_shopping_list_out(shopping_list),
meals_lookup=meals_lookup,
recipes_lookup=recipes_lookup,
ingredients_lookup=ingredients_lookup,
)
@router.post(
"",
response_model=PurchasedShoppingList,
operation_id="purchaseIngredientsV2",
summary="Purchase ingredients for a shopping list (scoped)",
)
async def purchase_ingredients_scoped(
shopping_list: PurchaseListIn,
request: Request,
household=Depends(get_household_from_slug),
user=Depends(get_current_user),
conn: aiosqlite.Connection = Depends(get_db),
) -> PurchasedShoppingList | Response:
hid = household["id"]
# Map outward input DTO to domain model
domain_items: List[shopping.ShoppingListItem] = []
for it in shopping_list.items:
created = it.created_date or __import__("datetime").datetime.now().astimezone()
domain_items.append(
shopping.ShoppingListItem(
ingredient_id=it.ingredient_id,
person_id=it.person_id,
meal_id=it.meal_id,
recipe_id=it.recipe_id,
created_date=created,
)
)
domain_list = shopping.ShoppingList(
items=domain_items, store_name=shopping_list.store_name, purchased_by_id=user.id
)
try:
# Use household-scoped purchase which ensures requests belong to the same household
await shopping.purchase_scoped(conn, domain_list, hid)
except ValueError as e:
return error_response(request, 400, str(e))
# Lookups for outward response
meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups(
conn, domain_list.items
)
return PurchasedShoppingList(
list=_to_shopping_list_out(domain_list),
meals_lookup=meals_lookup,
recipes_lookup=recipes_lookup,
ingredients_lookup=ingredients_lookup,
)
class MealIdWrapper(_ApiModel):
meal_id: int
class Ok(_ApiModel):
ok: bool = True
@router.post(
"/current/meals/me",
response_model=RequestedMealItem,
operation_id="requestMealV2",
summary="Request a meal for shopping (scoped)",
)
async def request_meal_scoped(
r: MealIdWrapper,
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
):
hid = household["id"]
from meals.repository import find_meal_by_id_scoped
meal = await find_meal_by_id_scoped(conn, r.meal_id, hid)
if not meal:
return error_response(request, 404, "Meal not found")
try:
item = await shopping.request_meal_scoped(conn, meal, hid)
except ValueError as e:
return error_response(request, 400, str(e))
return _to_meal_item(item)
@router.delete(
"/current/meals/{meal_id}",
response_model=Ok,
operation_id="unrequestMealV2",
summary="Remove a meal request (scoped)",
)
async def unrequest_meal_scoped(
meal_id: int,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
):
hid = household["id"]
await shopping.remove_meal_request_scoped(conn, meal_id, hid)
return Ok()

View file

@ -10,7 +10,7 @@
This document has been validated against the current codebase (v1) to ensure the plan captures all required changes. It starts with a concise baseline of what exists today, then details the v2 multi-tenancy/auth refactor with concrete, file-scoped steps and acceptance criteria. This document has been validated against the current codebase (v1) to ensure the plan captures all required changes. It starts with a concise baseline of what exists today, then details the v2 multi-tenancy/auth refactor with concrete, file-scoped steps and acceptance criteria.
Date reviewed: 2025-11-01 (updated after porting legacy coverage to v2; all checks green; OpenAPI exported) Date reviewed: 2025-11-01 (updated after v2 cutover; Argon2 enabled; members endpoint added; all checks green; OpenAPI exported)
Repo modules checked: `main.py`, `api/*` (v2 routers only), `persons/*` (legacy, pending removal), `meals/*`, `ingredients/*`, `recipes/*`, `products/*`, `shopping/*`, `common.py`, `db.py`, `settings.py`, tests in `tests/*`. Repo modules checked: `main.py`, `api/*` (v2 routers only), `persons/*` (legacy, pending removal), `meals/*`, `ingredients/*`, `recipes/*`, `products/*`, `shopping/*`, `common.py`, `db.py`, `settings.py`, tests in `tests/*`.
@ -280,14 +280,15 @@ Impact on existing routes (exact files to refactor):
Status summary: Status summary:
- Implemented: JWT auth v2 with refresh cookie; household domains and membership; invitations; full household scoping across recipes/meals/shopping; OpenAPI augmentation with bearerAuth and 403; RFC7807 preserved; tests green. - Implemented: JWT auth v2 with refresh cookie; household domains and membership; invitations; full household scoping across recipes/meals/shopping; OpenAPI augmentation with bearerAuth and 403; RFC7807 preserved; tests green.
- DTO alignment: Meals v2 now uses MemberRef { id, displayName } for chefs/cleanup/consumers (no Person in outward schema). Tests updated accordingly. - DTO alignment: Meals use MemberRef { id, displayName } (no Person in outward schema). MemberRef consolidated in `api/dtos.py`. Shopping DTOs/mappers consolidated in `api/shopping_models.py`.
- Security: Password hashing now prefers Argon2 for new accounts with PBKDF2 verification fallback. - Security: Password hashing now prefers Argon2 for new accounts with PBKDF2 verification fallback.
- New: Household members listing `GET /api/v1/households/{householdSlug}/members` returning [{ id, displayName, role }].
- Preserved: camelCase responses, `Page<T>` semantics, `Location` headers on create, shopping storeName normalization ("home"). - Preserved: camelCase responses, `Page<T>` semantics, `Location` headers on create, shopping storeName normalization ("home").
Remaining work (prioritized cleanup): Remaining work (prioritized cleanup):
1. Remove `persons/` package and any residual references; consolidate entirely on `users` / `household_members`. Keep internal mapping until repositories updated. 1. Remove `persons/` package and any residual references; consolidate entirely on `users` / `household_members` across repositories/services.
2. Recipes outward fields: migrate `createdBy`/`hiddenBy` to a user/member DTO (no Person) similar to meals MemberRef. 2. Recipes outward fields: migrate `createdBy`/`hiddenBy` to a user/member DTO (no Person) similar to meals MemberRef.
3. Finalize v1→v2 switchover: delete v1 routers (`api/auth.py`, `api/persons.py`, `api/recipes.py`, `api/meals.py`, `api/shopping.py`) and rename `*_v2.py` to canonical names. Extract shared DTOs/helpers (currently imported from `api.shopping`) into the canonical module to avoid cross-file dependencies. 3. Inline v2 content and delete `*_v2.py` files to reduce indirection; imports currently delegate cleanly and are mounted canonically.
4. Invitations: integrate email delivery provider and track send status. 4. Invitations: integrate email delivery provider and track send status.
5. DB: Add composite indices like `(household_id, id)` for pagination; evaluate additional FKs to `Household(id)`. 5. DB: Add composite indices like `(household_id, id)` for pagination; evaluate additional FKs to `Household(id)`.

View file

@ -10,7 +10,7 @@
"post": { "post": {
"tags": [ "tags": [
"auth", "auth",
"auth-v2" "auth"
], ],
"summary": "Register", "summary": "Register",
"operationId": "register", "operationId": "register",
@ -52,7 +52,7 @@
"post": { "post": {
"tags": [ "tags": [
"auth", "auth",
"auth-v2" "auth"
], ],
"summary": "Login", "summary": "Login",
"operationId": "loginV2", "operationId": "loginV2",
@ -94,7 +94,7 @@
"post": { "post": {
"tags": [ "tags": [
"auth", "auth",
"auth-v2" "auth"
], ],
"summary": "Refresh", "summary": "Refresh",
"operationId": "refreshV2", "operationId": "refreshV2",
@ -119,7 +119,7 @@
"post": { "post": {
"tags": [ "tags": [
"auth", "auth",
"auth-v2" "auth"
], ],
"summary": "Logout", "summary": "Logout",
"operationId": "logoutV2", "operationId": "logoutV2",

View file

@ -0,0 +1,48 @@
import unittest
from fastapi.testclient import TestClient
import main
from db import connect, create
from scripts.migration_to_households import run_migration
class TestHouseholdMembersV2(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.conn = await connect(":memory:")
await create(self.conn)
await run_migration(self.conn)
async def override_get_db():
try:
yield self.conn
finally:
pass
main.app.dependency_overrides[main.get_db] = override_get_db
self.client = TestClient(main.app)
# Register user and create a household
r = self.client.post(
"/api/v1/auth/register",
json={"email": "members@test.com", "password": "pw", "displayName": "Member User"},
)
assert r.status_code == 200, r.text
token = r.json()["accessToken"]
self.headers = {"Authorization": f"Bearer {token}"}
r = self.client.post("/api/v1/households", headers=self.headers, json={"name": "My Fam"})
assert r.status_code == 200, r.text
self.slug = r.json()["slug"]
async def asyncTearDown(self):
await self.conn.close()
main.app.dependency_overrides.clear()
def test_list_members_returns_creator_admin(self):
r = self.client.get(f"/api/v1/households/{self.slug}/members", headers=self.headers)
assert r.status_code == 200, r.text
items = r.json()
assert isinstance(items, list)
assert len(items) == 1
assert items[0]["displayName"] == "Member User"
assert items[0]["role"] == "admin"