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"]
|
||||
_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
|
||||
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-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.
|
||||
This file intentionally contains no imports or runtime definitions to avoid
|
||||
lint/type issues while the file remains present during refactors.
|
||||
"""
|
||||
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__ = []
|
||||
|
|
|
|||
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,
|
||||
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)
|
||||
|
|
|
|||
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
|
||||
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] = []
|
||||
|
|
|
|||
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
|
||||
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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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<T>` 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)`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
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