diff --git a/api/auth.py b/api/auth.py index 939bb60..ff58bd5 100644 --- a/api/auth.py +++ b/api/auth.py @@ -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"] -_UNUSED = (router,) diff --git a/api/auth_v2.py b/api/auth_v2.py index 25509bb..fda1ae4 100644 --- a/api/auth_v2.py +++ b/api/auth_v2.py @@ -1,189 +1,7 @@ -from __future__ import annotations +"""Deprecated: v2 auth is inlined into api/auth.py. -import base64 -import hashlib -import hmac -import os -from typing import Optional +This file intentionally contains no imports or runtime definitions to avoid +lint/type issues while the file remains present during refactors. +""" -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-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 +__all__ = [] diff --git a/api/meals.py b/api/meals.py index e460ce1..07cee20 100644 --- a/api/meals.py +++ b/api/meals.py @@ -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, -and exposes the FastAPI router defined in meals_v2. -""" +import datetime +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 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"] -_UNUSED = (router, validate_meal) diff --git a/api/meals_v2.py b/api/meals_v2.py index 74edfb8..00804fd 100644 --- a/api/meals_v2.py +++ b/api/meals_v2.py @@ -1,364 +1,3 @@ -from __future__ import annotations +"""Deprecated: content inlined into api/meals.py""" -import datetime -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, - ) +__all__: list[str] = [] diff --git a/api/shopping.py b/api/shopping.py index 3e0c0fa..7cd4ffb 100644 --- a/api/shopping.py +++ b/api/shopping.py @@ -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 -and for any tests that import mapping helpers. -""" +from typing import Dict, List -# Re-export shared DTOs/mappers minimally for type references -from .shopping_models import CurrentShoppingList, ShoppingListOut, PurchasedShoppingList +import aiosqlite +from fastapi import APIRouter, Depends, Request, Response -# Expose the v2 router under the canonical module -from .shopping_v2 import router +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, + ShoppingListOut, + PurchasedShoppingList, + _to_ingredient_item, + _to_meal_item, + _to_shopping_list_out, + RequestedMealItem, + PurchaseListIn, +) -__all__ = ["router", "CurrentShoppingList", "ShoppingListOut", "PurchasedShoppingList"] -_UNUSED = (router, CurrentShoppingList, ShoppingListOut, PurchasedShoppingList) +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() + + +# Re-export shared DTOs for importers +__all__ = [ + "router", + "CurrentShoppingList", + "ShoppingListOut", + "PurchasedShoppingList", +] diff --git a/api/shopping_v2.py b/api/shopping_v2.py index 2b3ae43..b34abeb 100644 --- a/api/shopping_v2.py +++ b/api/shopping_v2.py @@ -1,199 +1,3 @@ -from __future__ import annotations +"""Deprecated: content inlined into api/shopping.py""" -from typing import Dict, List - -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() +__all__: list[str] = [] diff --git a/backend-spec.md b/backend-spec.md index 494facc..ec23eb0 100644 --- a/backend-spec.md +++ b/backend-spec.md @@ -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. -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/*`. @@ -280,14 +280,15 @@ Impact on existing routes (exact files to refactor): 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. -- 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. +- New: Household members listing `GET /api/v1/households/{householdSlug}/members` returning [{ id, displayName, role }]. - Preserved: camelCase responses, `Page` semantics, `Location` headers on create, shopping storeName normalization ("home"). 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. -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. 5. DB: Add composite indices like `(household_id, id)` for pagination; evaluate additional FKs to `Household(id)`. diff --git a/openapi.json b/openapi.json index e3dbfb4..c953f89 100644 --- a/openapi.json +++ b/openapi.json @@ -10,7 +10,7 @@ "post": { "tags": [ "auth", - "auth-v2" + "auth" ], "summary": "Register", "operationId": "register", @@ -52,7 +52,7 @@ "post": { "tags": [ "auth", - "auth-v2" + "auth" ], "summary": "Login", "operationId": "loginV2", @@ -94,7 +94,7 @@ "post": { "tags": [ "auth", - "auth-v2" + "auth" ], "summary": "Refresh", "operationId": "refreshV2", @@ -119,7 +119,7 @@ "post": { "tags": [ "auth", - "auth-v2" + "auth" ], "summary": "Logout", "operationId": "logoutV2", diff --git a/tests/test_household_members_v2.py b/tests/test_household_members_v2.py new file mode 100644 index 0000000..0c523b3 --- /dev/null +++ b/tests/test_household_members_v2.py @@ -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"