format
This commit is contained in:
parent
f4ad388e36
commit
59c08ecb84
20 changed files with 66 additions and 55 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
2
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
|
||||
|
|
|
|||
|
|
@ -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);"
|
||||
)
|
||||
|
|
|
|||
4
main.py
4
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue