Cutover cleanup: inline meals/shopping v2 routers, neutralize legacy auth_v2
This commit is contained in:
parent
bd44a6acbe
commit
584732e497
9 changed files with 822 additions and 778 deletions
192
api/auth.py
192
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"]
|
__all__ = ["router"]
|
||||||
_UNUSED = (router,)
|
|
||||||
|
|
|
||||||
190
api/auth_v2.py
190
api/auth_v2.py
|
|
@ -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
|
|
||||||
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()}"
|
|
||||||
|
|
||||||
|
__all__ = []
|
||||||
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
|
|
||||||
|
|
|
||||||
369
api/meals.py
369
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,
|
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)
|
|
||||||
|
|
|
||||||
365
api/meals_v2.py
365
api/meals_v2.py
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
215
api/shopping.py
215
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
|
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",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
|
||||||
|
|
@ -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)`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
48
tests/test_household_members_v2.py
Normal file
48
tests/test_household_members_v2.py
Normal 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"
|
||||||
Loading…
Reference in a new issue