diff --git a/api/auth_v2.py b/api/auth_v2.py index 7318cf4..5a7e656 100644 --- a/api/auth_v2.py +++ b/api/auth_v2.py @@ -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") diff --git a/api/households.py b/api/households.py index d107b94..55160e8 100644 --- a/api/households.py +++ b/api/households.py @@ -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( diff --git a/api/meals_v2.py b/api/meals_v2.py index 944b2d9..2b7ca3a 100644 --- a/api/meals_v2.py +++ b/api/meals_v2.py @@ -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), diff --git a/api/openapi.py b/api/openapi.py index 65761a3..6af1720 100644 --- a/api/openapi.py +++ b/api/openapi.py @@ -162,16 +162,15 @@ def extend_with_problem_and_cookie_auth(app: FastAPI) -> None: if isinstance(store, dict) and store.get("$ref") == "#/components/schemas/StoreEnum": props["storeName"] = {"$ref": "#/components/schemas/StoreNameOut"} - # Mark bearer security for v2 routes we know require auth + # Mark bearer security for v2 routes we know require auth # Simple heuristic: underline select paths under /api/v1/users/me and /api/v1/households/* that are protected for path, ops in paths.items(): if not isinstance(path, str) or not path.startswith("/api/v1/"): 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 diff --git a/api/recipes_v2.py b/api/recipes_v2.py index 5ebe593..ea684af 100644 --- a/api/recipes_v2.py +++ b/api/recipes_v2.py @@ -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: diff --git a/api/shopping.py b/api/shopping.py index d0a9ad7..f0b384e 100644 --- a/api/shopping.py +++ b/api/shopping.py @@ -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] diff --git a/api/shopping_v2.py b/api/shopping_v2.py index f532fc7..156cd5f 100644 --- a/api/shopping_v2.py +++ b/api/shopping_v2.py @@ -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()} diff --git a/db.py b/db.py index 511c448..af80a31 100644 --- a/db.py +++ b/db.py @@ -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 diff --git a/households/repository.py b/households/repository.py index 59d93fc..1845aee 100644 --- a/households/repository.py +++ b/households/repository.py @@ -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);" ) diff --git a/main.py b/main.py index 86bba6a..a251c07 100644 --- a/main.py +++ b/main.py @@ -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: diff --git a/recipes/repository.py b/recipes/repository.py index aa1bd11..b4ea23b 100644 --- a/recipes/repository.py +++ b/recipes/repository.py @@ -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 diff --git a/security.py b/security.py index 74a99bb..5271249 100644 --- a/security.py +++ b/security.py @@ -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: diff --git a/shopping/repository.py b/shopping/repository.py index a94417e..72cafc5 100644 --- a/shopping/repository.py +++ b/shopping/repository.py @@ -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""" diff --git a/tests/test_household_scoping.py b/tests/test_household_scoping.py index 3133ee5..aef1062 100644 --- a/tests/test_household_scoping.py +++ b/tests/test_household_scoping.py @@ -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) diff --git a/tests/test_meals_consumed_v2.py b/tests/test_meals_consumed_v2.py index e0ae913..5891e79 100644 --- a/tests/test_meals_consumed_v2.py +++ b/tests/test_meals_consumed_v2.py @@ -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 diff --git a/tests/test_migration_households.py b/tests/test_migration_households.py index ed3744f..ce9a218 100644 --- a/tests/test_migration_households.py +++ b/tests/test_migration_households.py @@ -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 diff --git a/tests/test_recipes_household_v2.py b/tests/test_recipes_household_v2.py index 7ad7f76..6c19df0 100644 --- a/tests/test_recipes_household_v2.py +++ b/tests/test_recipes_household_v2.py @@ -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 diff --git a/tests/test_shopping_household_v2.py b/tests/test_shopping_household_v2.py index 7806329..f87ef65 100644 --- a/tests/test_shopping_household_v2.py +++ b/tests/test_shopping_household_v2.py @@ -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 diff --git a/tests/test_shopping_purchase_v2.py b/tests/test_shopping_purchase_v2.py index acfa0d0..1a79f71 100644 --- a/tests/test_shopping_purchase_v2.py +++ b/tests/test_shopping_purchase_v2.py @@ -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", "") diff --git a/users/repository.py b/users/repository.py index 899c24f..9c4296f 100644 --- a/users/repository.py +++ b/users/repository.py @@ -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):