This commit is contained in:
jableader 2025-11-01 15:34:01 +11:00
parent f4ad388e36
commit 59c08ecb84
20 changed files with 66 additions and 55 deletions

View file

@ -36,6 +36,7 @@ class TokenResponse(ApiModel):
token_type: str = "bearer"
user: User
PBKDF2_ALG = "pbkdf2_sha256"
PBKDF2_ITER = 390000 # similar to Django default; adjust in settings if needed
SALT_BYTES = 16
@ -91,7 +92,9 @@ def _token_pair_for_user(user: User) -> tuple[str, str]:
@router.post("/register", response_model=TokenResponse, operation_id="register")
async def register(request: Request, body: RegisterBody, conn: aiosqlite.Connection = Depends(get_db)):
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")

View file

@ -30,7 +30,9 @@ def slugify(name: str) -> str:
@router.get("/users/me/households", response_model=List[HouseholdResponse])
async def list_my_households(
request: Request, user: User = Depends(get_current_user), conn: aiosqlite.Connection = Depends(get_db)
request: Request,
user: User = Depends(get_current_user),
conn: aiosqlite.Connection = Depends(get_db),
):
results: list[HouseholdResponse] = []
async with conn.execute(

View file

@ -48,7 +48,7 @@ async def get_upcoming_meals_scoped(
# Temporary path: direct query with household_id filter
async with conn.execute(
f"""
SELECT {','.join(meals.Meal.KEYS)} FROM Meal
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),

View file

@ -169,9 +169,8 @@ def extend_with_problem_and_cookie_auth(app: FastAPI) -> None:
continue
if not isinstance(ops, dict):
continue
needs_bearer = (
path.startswith("/api/v1/users/me/")
or path.startswith("/api/v1/households/")
needs_bearer = path.startswith("/api/v1/users/me/") or path.startswith(
"/api/v1/households/"
)
if not needs_bearer:
continue

View file

@ -68,13 +68,17 @@ async def list_recipes(
for r in items:
r.ingredients = by_recipe.get(r.id, [])
next_cursor = str(items[-1].id) if has_more and items else None
total = await (recipes.count_by_name_scoped(conn, q, hid) if q else recipes.count_all_scoped(conn, hid))
total = await (
recipes.count_by_name_scoped(conn, q, hid) if q else recipes.count_all_scoped(conn, hid)
)
return Page(items=items, nextCursor=next_cursor, prevCursor=None, total=total)
@router.get("/{recipe_id}", response_model=RecipeOut, responses={404: {"model": ProblemDetails}})
async def get_recipe(
recipe_id: int, household=Depends(get_household_from_slug), conn: aiosqlite.Connection = Depends(get_db)
recipe_id: int,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
):
r = await recipes.find_recipe_by_id_scoped(conn, recipe_id, household["id"])
if not r:

View file

@ -74,10 +74,16 @@ router = APIRouter(prefix="/shopping", tags=["shopping"])
class CurrentShoppingList(ApiModel):
outstanding_items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
requested_meals: List[RequestedMealItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
outstanding_items: List[ListIngredientItem] = Field(
min_length=0, json_schema_extra={"minItems": 0}
)
requested_meals: List[RequestedMealItem] = Field(
min_length=0, json_schema_extra={"minItems": 0}
)
# Make all collections required to avoid undefined/null semantics in clients
purchased_items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
purchased_items: List[ListIngredientItem] = Field(
min_length=0, json_schema_extra={"minItems": 0}
)
ingredients_lookup: Dict[int, ingredients.Ingredient]
meals_lookup: Dict[int, meals.Meal]

View file

@ -51,7 +51,9 @@ async def get_current_shopping_list_scoped(
# 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)
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()}

2
db.py
View file

@ -23,6 +23,7 @@ async def create(conn: aiosqlite.Connection):
# New v2 domain tables (users/households). Keep persons for compatibility during migration.
try:
import users.repository as users_db
await users_db.create(conn)
except Exception:
# Be tolerant if table already exists or module missing in some setups
@ -30,6 +31,7 @@ async def create(conn: aiosqlite.Connection):
try:
import households.repository as households_db
await households_db.create(conn)
except Exception:
pass

View file

@ -42,9 +42,7 @@ async def create(conn):
)
# Indices
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_household_slug ON Household(slug);"
)
await conn.execute("CREATE INDEX IF NOT EXISTS idx_household_slug ON Household(slug);")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_household_member_household ON HouseholdMember(household_id);"
)

View file

@ -98,7 +98,9 @@ async def request_validation_exc_handler(request: Request, exc: Exception):
errors.setdefault(loc, []).append(e.get("msg"))
# Special-case: Missing user_id cookie on POST /api/v1/shopping should be 401
try:
is_shopping_post = request.method.upper() == "POST" and request.url.path == "/api/v1/shopping"
is_shopping_post = (
request.method.upper() == "POST" and request.url.path == "/api/v1/shopping"
)
except Exception:
is_shopping_post = False
if is_shopping_post:

View file

@ -108,9 +108,7 @@ async def find_recipe_by_id(conn, recipe_id: int) -> Optional[Recipe]:
return None
async def find_recipe_by_id_scoped(
conn, recipe_id: int, household_id: int
) -> Optional[Recipe]:
async def find_recipe_by_id_scoped(conn, recipe_id: int, household_id: int) -> Optional[Recipe]:
async with conn.execute(
f"""
SELECT {",".join(Recipe.KEYS)} FROM Recipe

View file

@ -43,7 +43,9 @@ def _encode_payload(claims: Dict[str, Any]) -> str:
return _b64url_encode(json.dumps(claims, separators=(",", ":")).encode("utf-8"))
def create_jwt(config: JwtConfig, subject: str, kind: str = "access", extra: Dict[str, Any] | None = None) -> str:
def create_jwt(
config: JwtConfig, subject: str, kind: str = "access", extra: Dict[str, Any] | None = None
) -> str:
now = int(time.time())
ttl = config.access_ttl_seconds if kind == "access" else config.refresh_ttl_seconds
secret = config.access_secret if kind == "access" else config.refresh_secret
@ -64,7 +66,9 @@ def create_jwt(config: JwtConfig, subject: str, kind: str = "access", extra: Dic
return f"{header}.{payload}.{signature}"
def verify_jwt(config: JwtConfig, token: str, expected_kind: str = "access") -> Tuple[Dict[str, Any], Dict[str, Any]]:
def verify_jwt(
config: JwtConfig, token: str, expected_kind: str = "access"
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
try:
header_b64, payload_b64, sig = token.split(".")
except ValueError:

View file

@ -430,9 +430,7 @@ async def load_shopping_list(conn, id: int) -> Optional[ShoppingList]:
return shopping_list
async def load_shopping_list_scoped(
conn, id: int, household_id: int
) -> Optional[ShoppingList]:
async def load_shopping_list_scoped(conn, id: int, household_id: int) -> Optional[ShoppingList]:
shopping_list: Optional[ShoppingList] = None
async with conn.execute(
f"""

View file

@ -37,9 +37,7 @@ class TestHouseholdScoping(unittest.IsolatedAsyncioTestCase):
assert r.status_code in (403, 404) # may be 404 if default household missing
def test_scoped_whoami_ok_after_creating_household(self):
r = self.client.post(
"/api/v1/households", json={"name": "Family"}, headers=self.headers
)
r = self.client.post("/api/v1/households", json={"name": "Family"}, headers=self.headers)
assert r.status_code == 200, r.text
slug = r.json()["slug"]
r = self.client.get(f"/api/v1/households/{slug}/whoami", headers=self.headers)

View file

@ -95,16 +95,12 @@ class TestMealsConsumedV2(unittest.IsolatedAsyncioTestCase):
assert meal["consumedDate"] is not None
# H1 meal request should be gone; H2 remains
r1 = self.client.get(
f"/api/v1/households/{self.h1}/shopping/current", headers=self.headers
)
r1 = self.client.get(f"/api/v1/households/{self.h1}/shopping/current", headers=self.headers)
assert r1.status_code == 200
cur1 = r1.json()
assert all(i.get("mealId") != self.meal_h1 for i in cur1["requestedMeals"]) # none for h1
r2 = self.client.get(
f"/api/v1/households/{self.h2}/shopping/current", headers=self.headers
)
r2 = self.client.get(f"/api/v1/households/{self.h2}/shopping/current", headers=self.headers)
assert r2.status_code == 200
cur2 = r2.json()
assert any(i.get("mealId") == self.meal_h2 for i in cur2["requestedMeals"]) # still present

View file

@ -31,7 +31,14 @@ def test_migration_adds_tables_and_columns_and_ports_data(tmp_path):
await run_migration(conn)
# Verify new tables exist
for tbl in ["User", "LocalCredentials", "OAuthCredentials", "Household", "HouseholdMember", "HouseholdInvitation"]:
for tbl in [
"User",
"LocalCredentials",
"OAuthCredentials",
"Household",
"HouseholdMember",
"HouseholdInvitation",
]:
async with conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?;", (tbl,)
) as c:
@ -51,7 +58,9 @@ def test_migration_adds_tables_and_columns_and_ports_data(tmp_path):
assert await table_has_column(conn, tbl, "household_id"), f"{tbl} lacks household_id"
# Default household exists
async with conn.execute("SELECT id, slug FROM Household WHERE slug='default' LIMIT 1;") as c:
async with conn.execute(
"SELECT id, slug FROM Household WHERE slug='default' LIMIT 1;"
) as c:
row = await c.fetchone()
assert row is not None

View file

@ -80,7 +80,5 @@ class TestRecipesHouseholdV2(unittest.IsolatedAsyncioTestCase):
assert not any(it["id"] == rid for it in items2)
# Get in H2 by id should 404
r = self.client.get(
f"/api/v1/households/{self.h2}/recipes/{rid}", headers=self.headers
)
r = self.client.get(f"/api/v1/households/{self.h2}/recipes/{rid}", headers=self.headers)
assert r.status_code == 404

View file

@ -75,17 +75,13 @@ class TestShoppingHouseholdV2(unittest.IsolatedAsyncioTestCase):
main.app.dependency_overrides.clear()
def test_current_is_scoped(self):
r1 = self.client.get(
f"/api/v1/households/{self.h1}/shopping/current", headers=self.headers
)
r1 = self.client.get(f"/api/v1/households/{self.h1}/shopping/current", headers=self.headers)
assert r1.status_code == 200, r1.text
cur1 = r1.json()
assert len(cur1["outstandingItems"]) == 1
assert cur1["outstandingItems"][0]["ingredientId"] == 1
r2 = self.client.get(
f"/api/v1/households/{self.h2}/shopping/current", headers=self.headers
)
r2 = self.client.get(f"/api/v1/households/{self.h2}/shopping/current", headers=self.headers)
assert r2.status_code == 200, r2.text
cur2 = r2.json()
assert len(cur2["outstandingItems"]) == 1

View file

@ -103,23 +103,21 @@ class TestShoppingPurchaseV2(unittest.IsolatedAsyncioTestCase):
assert len(data["list"]["items"]) == 1
# Verify via API: H1 has no outstanding items; H2 still has one
r1 = self.client.get(
f"/api/v1/households/{self.h1}/shopping/current", headers=self.headers
)
r1 = self.client.get(f"/api/v1/households/{self.h1}/shopping/current", headers=self.headers)
assert r1.status_code == 200, r1.text
cur1 = r1.json()
assert len(cur1["outstandingItems"]) == 0
r2 = self.client.get(
f"/api/v1/households/{self.h2}/shopping/current", headers=self.headers
)
r2 = self.client.get(f"/api/v1/households/{self.h2}/shopping/current", headers=self.headers)
assert r2.status_code == 200, r2.text
cur2 = r2.json()
assert len(cur2["outstandingItems"]) == 1
def test_purchase_validation_error(self):
resp = self.client.post(
f"/api/v1/households/{self.h1}/shopping", headers=self.headers, json={"storeName": "woolworths", "items": []}
f"/api/v1/households/{self.h1}/shopping",
headers=self.headers,
json={"storeName": "woolworths", "items": []},
)
assert resp.status_code == 400, resp.text
assert "application/problem+json" in resp.headers.get("content-type", "")

View file

@ -36,9 +36,7 @@ async def create(conn):
)
# Helpful indices
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_user_email ON User(email);"
)
await conn.execute("CREATE INDEX IF NOT EXISTS idx_user_email ON User(email);")
async def get_by_email(conn, email: str):