persons removed
This commit is contained in:
parent
7b82cca5e3
commit
964072391e
15 changed files with 154 additions and 1541 deletions
|
|
@ -1,29 +1 @@
|
|||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.9
|
||||
hooks:
|
||||
# Run the linter
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
# Run the formatter
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.11.2
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies:
|
||||
- pydantic==2.9.2
|
||||
- fastapi==0.115.0
|
||||
- httpx==0.27.2
|
||||
- aiosqlite==0.20.0
|
||||
args: [--config-file=pyproject.toml]
|
||||
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
- id: check-merge-conflict
|
||||
repos: []
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
"""Legacy v1 persons API is removed in favor of users/household members.
|
||||
|
||||
This module intentionally has no routes.
|
||||
"""
|
||||
|
|
@ -8,9 +8,9 @@
|
|||
|
||||
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 (post-cutover: JWT + households live, Argon2-only, members endpoint added, v2 routers inlined; all checks green; OpenAPI exported)
|
||||
Date reviewed: 2025-11-01 (post-cutover: JWT + households live, Argon2-only, members endpoint added, v2 routers consolidated; all checks green; OpenAPI exported; persons fully removed)
|
||||
|
||||
Repo modules checked: `main.py`, `api/*` (v2-only; no `*_v2.py` files remain), `users/*`, `households/*`, `meals/*`, `ingredients/*`, `recipes/*`, `products/*`, `shopping/*`, `common.py`, `db.py`, `settings.py`, tests in `tests/*`. `persons/*` remains for migration compatibility but has no routes.
|
||||
Repo modules checked: `main.py`, `api/*` (v2-only; no `*_v2.py` files remain), `users/*`, `households/*`, `meals/*`, `ingredients/*`, `recipes/*`, `products/*`, `shopping/*`, `common.py`, `db.py`, `settings.py`, tests in `tests/*`. The legacy `persons/*` package has been deleted.
|
||||
|
||||
Key conventions in v1:
|
||||
- Response shape uses camelCase aliases (via `ApiModel` in `common.py`).
|
||||
|
|
@ -34,9 +34,7 @@ Key conventions in v1:
|
|||
Special-case 401: Removed. v1 cookie-based auth and routes have been retired in favor of JWT-only v2.
|
||||
|
||||
### 0.2 API surface (historical)
|
||||
- Persons (`api/persons.py`)
|
||||
- GET `/api/v1/persons` → `Page<Person>` with optional name filter `q`, cursor pagination.
|
||||
- POST `/api/v1/persons` → create Person; sets `Location` header.
|
||||
- Persons API removed.
|
||||
|
||||
- Recipes (`api/recipes.py`)
|
||||
- GET `/api/v1/recipes` → `Page<Recipe>`; loads ingredients per page.
|
||||
|
|
@ -64,7 +62,7 @@ Special-case 401: Removed. v1 cookie-based auth and routes have been retired in
|
|||
- DELETE `/api/v1/shopping/current/meals/{mealId}` (auth required) → unrequest a meal; 404 if meal not found.
|
||||
|
||||
### 0.3 Data model (SQLite, created by `db.create()`)
|
||||
- Person(id PK, name UNIQUE)
|
||||
(legacy Person table removed; users table is canonical)
|
||||
- Recipe(id PK, name, link, serves, image_urls TEXT JSON, based_on_recipe FK, date_created, created_by_id FK NOT NULL → User.id, date_hidden, hidden_by_id FK → User.id)
|
||||
- Ingredient(id PK, name, line, preparation, unit, quantity REAL, product_id FK, recipe_id FK, meal_id FK)
|
||||
- Product(id PK, product_id UNIQUE, shop_code, link, name, quantity, unit, img_small, img_large, raw_data TEXT) + ProductTag(food_item_id, tag)
|
||||
|
|
@ -95,7 +93,7 @@ Special-case 401: Removed. v1 cookie-based auth and routes have been retired in
|
|||
---
|
||||
|
||||
## 1. Objective (status)
|
||||
v2 household-scoped API is complete and v1 has been removed from the app. Tests have been migrated to v2 equivalents or disabled when purely legacy. OpenAPI reflects JWT bearer and household scoping.
|
||||
v2 household-scoped API is complete and v1 has been removed from the app. The `persons` package and routes are deleted. Tests have been migrated to v2 equivalents or disabled when purely legacy. OpenAPI reflects JWT bearer and household scoping.
|
||||
|
||||
Refactor the backend from a single-tenant architecture to a robust, multi-tenant system based on "Households". This requires evolving the data model to enforce data isolation, overhauling the authentication system to support standard credential types, and introducing an invitation mechanism for household management. This plan is adapted to the existing codebase.
|
||||
|
||||
|
|
@ -207,17 +205,17 @@ Route surface lockdown:
|
|||
- Ensure fresh bootstraps include `household_id` in all tenant table DDL (now updated for Ingredient, Recipe, Meal, ShoppingList, ShoppingListItem).
|
||||
|
||||
2. **[✅] Implement New Authentication System**:
|
||||
- Implemented v2 JWT auth while keeping v1 cookie auth intact during transition:
|
||||
- `api/auth_v2.py` now issues HS256 JWT access tokens and sets an HttpOnly refresh cookie.
|
||||
- Implemented JWT auth (Argon2 password hashing) and removed v1 cookie auth:
|
||||
- `api/auth.py` issues HS256 JWT access tokens and sets an HttpOnly refresh cookie.
|
||||
- Endpoints: `POST /api/v1/auth/register`, `POST /api/v1/auth/login`, `POST /api/v1/auth/refresh`, `POST /api/v1/auth/logout`.
|
||||
- `api/deps.get_current_user` verifies JWT access tokens and loads the `User` from DB.
|
||||
- `security.py` provides a minimal JWT utility with configurable issuer/audience, secrets, and TTLs.
|
||||
- `settings.py` extended with JWT config and secrets via env.
|
||||
- Tests updated: `tests/test_auth_and_households_v2.py` now expects JWT-shaped tokens and verifies refresh flow.
|
||||
- OpenAPI augmentation updated to include `refreshV2` in protected ops and to mark `/users/me/*` and `/households/*` with `bearerAuth` + `403`.
|
||||
- Tests updated to expect JWT-shaped tokens and verify refresh flow.
|
||||
- OpenAPI augmentation marks `/users/me/*` and `/households/*` with `bearerAuth` + `403`.
|
||||
- Notes:
|
||||
- Password hashing remains SHA-256 placeholder; to be upgraded to bcrypt/argon2 in a follow-up.
|
||||
- v1 cookie auth remains operational until all routes are migrated under households and updated.
|
||||
- Password hashing upgraded to Argon2 via `argon2-cffi`.
|
||||
- v1 cookie auth is removed.
|
||||
- Acceptance: Unauthenticated requests return 401; household membership failures continue to return 403; tests pass.
|
||||
|
||||
3. **[✅] Implement Household Scoping**:
|
||||
|
|
@ -227,13 +225,13 @@ Route surface lockdown:
|
|||
- `POST /api/v1/households` (requires bearer token) → creates household and adds current user as admin.
|
||||
- ✅ Implemented `get_household_from_slug` in `api/deps.py`.
|
||||
- ✅ Refactor `main.py`:
|
||||
- Create a new `APIRouter` for household-scoped routes, e.g., `household_router = APIRouter(prefix="/api/v1/households/{householdSlug}")`.
|
||||
- Mounted a scoped helper endpoint and a new recipes v2 router under this prefix.
|
||||
- Household-scoped routers consolidated under `api/households.py` and mounted at `/api/v1/households/{householdSlug}`.
|
||||
- Recipes, meals, and shopping routers are v2-only and live in `api/recipes.py`, `api/meals.py`, and `api/shopping.py` respectively.
|
||||
- ✅ Recipes scoping:
|
||||
- Added `api/recipes_v2.py` providing `/api/v1/households/{householdSlug}/recipes` with list/get/create/delete.
|
||||
- Added scoped repo helpers in `recipes/repository.py` and exported via `recipes/__init__.py`.
|
||||
- Tests in `tests/test_recipes_household_v2.py` validate isolation across households (PASS).
|
||||
- ✅ Meals: Added `api/meals_v2.py` with:
|
||||
- Consolidated into `api/recipes.py` providing `/api/v1/households/{householdSlug}/recipes` with list/get/create/delete.
|
||||
- Scoped repo helpers in `recipes/repository.py` and exported via `recipes/__init__.py`.
|
||||
- Tests validate isolation across households (PASS).
|
||||
- ✅ Meals: Consolidated into `api/meals.py` with:
|
||||
- `/api/v1/households/{householdSlug}/meals/upcoming` filtering by `household_id`.
|
||||
- `/api/v1/households/{householdSlug}/meals/{id}` returns 404 across households.
|
||||
- `POST /api/v1/households/{householdSlug}/meals/{id}/consumed` marks consumed with optional `consumedDate` (requires timezone if provided); removes outstanding meal requests; all operations scoped to household.
|
||||
|
|
@ -274,7 +272,7 @@ Route surface lockdown:
|
|||
- ✅ Export script writes updated `openapi.json`; re-run after adding meals v2 write endpoints to include them in the schema.
|
||||
|
||||
6. **[✅] Refactor and Test**:
|
||||
- Tests updated to v2 household-scoped endpoints with JWT setup. Legacy v1 API tests are currently skipped (`tests/test_main.py`, `tests/test_v1.py`).
|
||||
- Tests updated to v2 household-scoped endpoints with JWT setup. Legacy v1 API tests are skipped (`tests/test_v1.py`), and the legacy `tests/test_main.py` has been removed.
|
||||
- Full suite green under `make all-checks`. OpenAPI export successful.
|
||||
- Next cleanup: remove `persons/` package and remaining references in domain internals once users fully replace persons in models.
|
||||
|
||||
|
|
|
|||
20
db.py
20
db.py
|
|
@ -20,25 +20,13 @@ async def create(conn: aiosqlite.Connection):
|
|||
|
||||
await recipe_db.create(conn)
|
||||
|
||||
# New v2 domain tables (users/households). Keep persons for compatibility during migration.
|
||||
try:
|
||||
import users.repository as users_db
|
||||
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
|
||||
pass
|
||||
await users_db.create(conn)
|
||||
|
||||
try:
|
||||
import households.repository as households_db
|
||||
import households.repository as households_db
|
||||
|
||||
await households_db.create(conn)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import persons.repository as person_db
|
||||
|
||||
await person_db.create(conn)
|
||||
await households_db.create(conn)
|
||||
|
||||
import meals.repository as meals_db
|
||||
|
||||
|
|
|
|||
|
|
@ -3067,4 +3067,4 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
from persons.models import Person as Person
|
||||
from persons.repository import (
|
||||
compute_prev_cursor as compute_prev_cursor,
|
||||
count_all as count_all,
|
||||
count_by_name as count_by_name,
|
||||
create as create,
|
||||
get_all as get_all,
|
||||
get_all_paged as get_all_paged,
|
||||
get_by_id as get_by_id,
|
||||
get_by_ids as get_by_ids,
|
||||
get_by_name as get_by_name,
|
||||
insert_person as insert_person,
|
||||
search_by_name as search_by_name,
|
||||
search_by_name_paged as search_by_name_paged,
|
||||
)
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
from typing import ClassVar, List
|
||||
|
||||
from common import ApiModel
|
||||
|
||||
|
||||
class Person(ApiModel):
|
||||
KEYS: ClassVar[List[str]] = ["id", "name"]
|
||||
|
||||
id: int = -1
|
||||
name: str
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
from typing import AsyncIterator, List, Optional
|
||||
|
||||
from persons.models import Person
|
||||
|
||||
|
||||
async def create(conn):
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Person (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT UNIQUE
|
||||
);"""
|
||||
)
|
||||
# Useful indexes for search and pagination
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_person_name ON Person(name);")
|
||||
|
||||
|
||||
async def search_by_name(conn, name: str) -> AsyncIterator[Person]:
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE name LIKE ?
|
||||
""",
|
||||
(f"%{name}%",),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def get_by_name(conn, name: str) -> Optional[Person]:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE name = ?
|
||||
""",
|
||||
(name,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def get_by_id(conn, id: int) -> Optional[Person]:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE id = ?
|
||||
""",
|
||||
(id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def get_by_ids(conn, ids: List[int]) -> dict[int, Person]:
|
||||
"""Fetch many persons in a single query. Returns a dict id->Person.
|
||||
|
||||
If ids is empty, returns {}.
|
||||
"""
|
||||
if not ids:
|
||||
return {}
|
||||
placeholders = ",".join(["?"] * len(ids))
|
||||
query = f"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE id IN ({placeholders})
|
||||
"""
|
||||
result: dict[int, Person] = {}
|
||||
async with conn.execute(query, ids) as cursor:
|
||||
async for row in cursor:
|
||||
p = Person(id=row[0], name=row[1])
|
||||
result[p.id] = p
|
||||
return result
|
||||
|
||||
|
||||
async def get_all(conn) -> AsyncIterator[Person]:
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
"""
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def get_all_paged(conn, after_id: Optional[int], limit: int) -> AsyncIterator[Person]:
|
||||
after = after_id if after_id is not None else -1
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE id > ?
|
||||
ORDER BY id
|
||||
LIMIT ?
|
||||
""",
|
||||
(after, limit),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def search_by_name_paged(
|
||||
conn, name: str, after_id: Optional[int], limit: int
|
||||
) -> AsyncIterator[Person]:
|
||||
after = after_id if after_id is not None else -1
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE name LIKE ? AND id > ?
|
||||
ORDER BY id
|
||||
LIMIT ?
|
||||
""",
|
||||
(f"%{name}%", after, limit),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def count_all(conn) -> int:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT COUNT(1)
|
||||
FROM Person
|
||||
"""
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
async def count_by_name(conn, name: str) -> int:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT COUNT(1)
|
||||
FROM Person
|
||||
WHERE name LIKE ?
|
||||
""",
|
||||
(f"%{name}%",),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
async def compute_prev_cursor(
|
||||
conn, first_id: int, limit: int, name: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""Compute a prevCursor string for paginated persons, respecting optional name LIKE filter."""
|
||||
if limit <= 0:
|
||||
return None
|
||||
if name:
|
||||
query = """
|
||||
SELECT id
|
||||
FROM Person
|
||||
WHERE name LIKE ? AND id < ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
params: tuple[Any, ...] = (f"%{name}%", first_id, limit)
|
||||
else:
|
||||
query = """
|
||||
SELECT id
|
||||
FROM Person
|
||||
WHERE id < ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
params = (first_id, limit)
|
||||
|
||||
async with conn.execute(query, params) as c:
|
||||
prev_ids = [row[0] async for row in c]
|
||||
if len(prev_ids) == limit and prev_ids:
|
||||
return str(min(prev_ids) - 1)
|
||||
return None
|
||||
|
||||
|
||||
async def insert_person(conn, person: Person) -> Person:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
INSERT INTO Person (name)
|
||||
VALUES (?)
|
||||
""",
|
||||
(person.name,),
|
||||
)
|
||||
person.id = cursor.lastrowid
|
||||
return person
|
||||
|
|
@ -17,6 +17,14 @@ async def column_exists(conn: aiosqlite.Connection, table: str, column: str) ->
|
|||
return False
|
||||
|
||||
|
||||
async def table_exists(conn: aiosqlite.Connection, table: str) -> bool:
|
||||
async with conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?;", (table,)
|
||||
) as c:
|
||||
row = await c.fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
async def add_column_if_missing(conn: aiosqlite.Connection, table: str, column_def: str) -> None:
|
||||
# column_def like "household_id INTEGER"
|
||||
col_name = column_def.split()[0]
|
||||
|
|
@ -105,20 +113,22 @@ async def run_migration(conn: Optional[aiosqlite.Connection] = None):
|
|||
f"CREATE INDEX IF NOT EXISTS idx_{table.lower()}_household_id ON {table}(household_id);"
|
||||
)
|
||||
|
||||
# Port persons -> users (idempotent) and ensure memberships in default household
|
||||
# Port persons -> users (idempotent) if legacy table exists
|
||||
if await table_exists(conn, "Person"):
|
||||
async with conn.execute("SELECT id, name FROM Person;") as cur:
|
||||
async for pid, name in cur:
|
||||
email = f"{name.lower()}@example.com"
|
||||
display_name = name
|
||||
email = f"{str(name).lower()}@example.com"
|
||||
display_name = str(name)
|
||||
await conn.execute(
|
||||
"INSERT OR IGNORE INTO User (id, email, display_name) VALUES (?, ?, ?);",
|
||||
(pid, email, display_name),
|
||||
(int(pid), email, display_name),
|
||||
)
|
||||
# Ensure all users are members of default household (idempotent)
|
||||
await conn.execute(
|
||||
"INSERT OR IGNORE INTO HouseholdMember (user_id, household_id, role)\n SELECT id, ?, 'admin' FROM User;",
|
||||
(default_hid,),
|
||||
)
|
||||
|
||||
# Ensure all users are members of default household (idempotent)
|
||||
await conn.execute(
|
||||
"INSERT OR IGNORE INTO HouseholdMember (user_id, household_id, role) SELECT id, ?, 'admin' FROM User;",
|
||||
(default_hid,),
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import persons
|
||||
from api.dtos import MemberRef
|
||||
|
||||
|
||||
class Persons:
|
||||
jacob = persons.Person(id=1, name="Jacob")
|
||||
class MemberRefs:
|
||||
# Fixture MemberRef objects (id/display_name). Backcompat: .name property returns display_name.
|
||||
jacob = MemberRef(id=1, display_name="Jacob")
|
||||
|
||||
ryan = persons.Person(id=2, name="Ryan")
|
||||
ryan = MemberRef(id=2, display_name="Ryan")
|
||||
|
||||
ellie = persons.Person(id=3, name="Ellie")
|
||||
ellie = MemberRef(id=3, display_name="Ellie")
|
||||
|
||||
chris = persons.Person(id=4, name="Chris")
|
||||
chris = MemberRef(id=4, display_name="Chris")
|
||||
|
||||
|
||||
import products
|
||||
from api.dtos import MemberRef
|
||||
|
||||
|
||||
class Products:
|
||||
|
|
@ -221,7 +221,7 @@ class Recipes:
|
|||
"https://www.bbcgoodfood.com/sites/default/files/styles/recipe/public/recipe/recipe-image/2018/10/broccoli-soup.jpg"
|
||||
],
|
||||
ingredients=[Ingredients.broccoli_chopped_1kg],
|
||||
created_by_id=Persons.jacob.id,
|
||||
created_by_id=MemberRefs.jacob.id,
|
||||
)
|
||||
|
||||
how_to_steam_green_beans = recipes.Recipe(
|
||||
|
|
@ -238,7 +238,7 @@ class Recipes:
|
|||
Ingredients.salt,
|
||||
Ingredients.freshly_ground_black_pepper,
|
||||
],
|
||||
created_by_id=Persons.jacob.id,
|
||||
created_by_id=MemberRefs.jacob.id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -250,11 +250,11 @@ class Meals:
|
|||
broccoli_soup_for_jacob = meals_db.Meal(
|
||||
id=0,
|
||||
suggested_date=datetime(2021, 12, 25),
|
||||
chefs=[MemberRef(id=Persons.jacob.id, display_name=Persons.jacob.name)],
|
||||
cleanup=[MemberRef(id=Persons.ryan.id, display_name=Persons.ryan.name)],
|
||||
chefs=[MemberRef(id=MemberRefs.jacob.id, display_name=MemberRefs.jacob.name)],
|
||||
cleanup=[MemberRef(id=MemberRefs.ryan.id, display_name=MemberRefs.ryan.name)],
|
||||
consumers=[
|
||||
MemberRef(id=Persons.ellie.id, display_name=Persons.ellie.name),
|
||||
MemberRef(id=Persons.chris.id, display_name=Persons.chris.name),
|
||||
MemberRef(id=MemberRefs.ellie.id, display_name=MemberRefs.ellie.name),
|
||||
MemberRef(id=MemberRefs.chris.id, display_name=MemberRefs.chris.name),
|
||||
],
|
||||
recipes=[
|
||||
meals_db.MealRecipe(meal_id=-1, recipe_id=-1, servings=2, recipe=Recipes.broccoli_soup)
|
||||
|
|
@ -268,19 +268,25 @@ def class_fields(obj):
|
|||
|
||||
|
||||
async def create_persons(conn):
|
||||
for person in class_fields(Persons).values():
|
||||
await persons.insert_person(conn, person)
|
||||
# Seed explicit User rows for fixture MemberRefs (ids 1..4) without any legacy Person references
|
||||
try:
|
||||
from users.repository import insert_user_with_id
|
||||
|
||||
users = [
|
||||
(MemberRefs.jacob.id, MemberRefs.jacob.display_name),
|
||||
(MemberRefs.ryan.id, MemberRefs.ryan.display_name),
|
||||
(MemberRefs.ellie.id, MemberRefs.ellie.display_name),
|
||||
(MemberRefs.chris.id, MemberRefs.chris.display_name),
|
||||
]
|
||||
for uid, name in users:
|
||||
await insert_user_with_id(conn, uid, f"{name.lower()}@example.com", name)
|
||||
except Exception:
|
||||
# If users repo/tables aren't available in some minimal contexts, ignore
|
||||
pass
|
||||
|
||||
|
||||
async def create_test_data(conn):
|
||||
await create_persons(conn)
|
||||
# Seed User/HouseholdMember from legacy Persons to support v2 code paths
|
||||
try:
|
||||
from tests.user_fixtures import seed_users_from_legacy_persons
|
||||
|
||||
await seed_users_from_legacy_persons(conn)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for product in class_fields(Products).values():
|
||||
await products.insert_product(conn, product, {})
|
||||
|
|
|
|||
1143
tests/test_main.py
1143
tests/test_main.py
File diff suppressed because it is too large
Load diff
|
|
@ -15,7 +15,6 @@ from db import connect, create
|
|||
import meals
|
||||
import meals.repository as meals_db
|
||||
from meals.models import Meal, MealRecipe
|
||||
import persons
|
||||
import recipes
|
||||
import ingredients
|
||||
import products
|
||||
|
|
@ -39,9 +38,9 @@ class TestMealsModels(unittest.IsolatedAsyncioTestCase):
|
|||
"""Test basic Meal creation"""
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 1, 1, 18, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie, test_data.Persons.chris],
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie, test_data.MemberRefs.chris],
|
||||
)
|
||||
|
||||
self.assertEqual(meal.id, -1) # Default ID
|
||||
|
|
@ -81,9 +80,9 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
"""Test inserting a basic meal with participants"""
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 1, 15, 19, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -112,9 +111,9 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 2, 1, 18, 30),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie, test_data.Persons.chris],
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie, test_data.MemberRefs.chris],
|
||||
recipes=[meal_recipe],
|
||||
)
|
||||
|
||||
|
|
@ -161,9 +160,9 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 3, 1, 19, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
extra_ingredients=[extra_ingredient],
|
||||
)
|
||||
|
||||
|
|
@ -187,9 +186,9 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
# Create and insert initial meal
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 4, 1, 18, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -197,9 +196,12 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
# Update the meal
|
||||
meal.suggested_date = datetime(2024, 4, 2, 19, 0)
|
||||
meal.chefs = [test_data.Persons.ryan] # Change chef
|
||||
meal.cleanup = [test_data.Persons.ellie] # Change cleanup
|
||||
meal.consumers = [test_data.Persons.jacob, test_data.Persons.chris] # Change consumers
|
||||
meal.chefs = [test_data.MemberRefs.ryan] # Change chef
|
||||
meal.cleanup = [test_data.MemberRefs.ellie] # Change cleanup
|
||||
meal.consumers = [
|
||||
test_data.MemberRefs.jacob,
|
||||
test_data.MemberRefs.chris,
|
||||
] # Change consumers
|
||||
|
||||
await meals_db.update_meal(self.conn, meal)
|
||||
|
||||
|
|
@ -219,9 +221,9 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
"""Test marking a meal as consumed"""
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 5, 1, 18, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -241,9 +243,9 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
"""Test marking a meal as purchased"""
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 6, 1, 18, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -263,9 +265,9 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
"""Test soft deleting a meal"""
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 7, 1, 18, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -295,32 +297,32 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
# Create several meals with different dates
|
||||
meal1 = Meal(
|
||||
suggested_date=datetime(2024, 8, 1, 18, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
)
|
||||
|
||||
meal2 = Meal(
|
||||
suggested_date=datetime(2024, 8, 15, 18, 0),
|
||||
chefs=[test_data.Persons.ryan],
|
||||
cleanup=[test_data.Persons.jacob],
|
||||
consumers=[test_data.Persons.chris],
|
||||
chefs=[test_data.MemberRefs.ryan],
|
||||
cleanup=[test_data.MemberRefs.jacob],
|
||||
consumers=[test_data.MemberRefs.chris],
|
||||
)
|
||||
|
||||
meal3 = Meal(
|
||||
suggested_date=datetime(2024, 9, 1, 18, 0),
|
||||
chefs=[test_data.Persons.ellie],
|
||||
cleanup=[test_data.Persons.chris],
|
||||
consumers=[test_data.Persons.jacob],
|
||||
chefs=[test_data.MemberRefs.ellie],
|
||||
cleanup=[test_data.MemberRefs.chris],
|
||||
consumers=[test_data.MemberRefs.jacob],
|
||||
)
|
||||
|
||||
# Create a consumed meal (should not appear in upcoming)
|
||||
consumed_meal = Meal(
|
||||
suggested_date=datetime(2024, 8, 10, 18, 0),
|
||||
consumed_date=datetime(2024, 8, 10, 19, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal1)
|
||||
|
|
@ -366,15 +368,15 @@ class TestMealParticipants(unittest.IsolatedAsyncioTestCase):
|
|||
"""Test syncing meal participants"""
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 10, 1, 18, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
||||
# Update participants
|
||||
new_chefs = [test_data.Persons.ryan, test_data.Persons.ellie]
|
||||
new_chefs = [test_data.MemberRefs.ryan, test_data.MemberRefs.ellie]
|
||||
await meals_db.sync_meal_participants(self.conn, meal.id, new_chefs, "chef")
|
||||
|
||||
# Verify participants were updated
|
||||
|
|
@ -424,9 +426,9 @@ class TestMealRecipes(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 11, 1, 18, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -475,9 +477,9 @@ class TestMealIngredients(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 12, 1, 18, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
|
|||
|
|
@ -64,22 +64,27 @@ def test_migration_adds_tables_and_columns_and_ports_data(tmp_path):
|
|||
row = await c.fetchone()
|
||||
assert row is not None
|
||||
|
||||
# Persons were ported to Users and memberships created
|
||||
async with conn.execute("SELECT COUNT(1) FROM Person;") as c:
|
||||
row = await c.fetchone()
|
||||
assert row is not None
|
||||
person_count = int(row[0])
|
||||
# Persons were ported to Users and memberships created (if legacy Person table exists)
|
||||
try:
|
||||
async with conn.execute("SELECT COUNT(1) FROM Person;") as c:
|
||||
row = await c.fetchone()
|
||||
assert row is not None
|
||||
person_count = int(row[0])
|
||||
except Exception:
|
||||
person_count = 0
|
||||
async with conn.execute("SELECT COUNT(1) FROM User;") as c:
|
||||
row = await c.fetchone()
|
||||
assert row is not None
|
||||
user_count = int(row[0])
|
||||
assert user_count == person_count
|
||||
if person_count > 0:
|
||||
assert user_count == person_count
|
||||
|
||||
async with conn.execute("SELECT COUNT(1) FROM HouseholdMember;") as c:
|
||||
row = await c.fetchone()
|
||||
assert row is not None
|
||||
member_count = int(row[0])
|
||||
assert member_count == person_count
|
||||
if person_count > 0:
|
||||
assert member_count == person_count
|
||||
|
||||
await conn.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class TestShopping(unittest.IsolatedAsyncioTestCase):
|
|||
preparation="chopped",
|
||||
)
|
||||
await ingredients_repo.insert_ingredient(self.conn, ing)
|
||||
person = test_data.Persons.jacob
|
||||
person = test_data.MemberRefs.jacob
|
||||
req_item = await shopping.request(self.conn, person, ingredient=ing)
|
||||
self.assertIsNotNone(req_item.id)
|
||||
|
||||
|
|
@ -103,7 +103,7 @@ class TestShopping(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertIn(recipe.id, recipes_lookup)
|
||||
self.assertIn(ing.id, ingredients_lookup)
|
||||
|
||||
person = test_data.Persons.jacob
|
||||
person = test_data.MemberRefs.jacob
|
||||
self.assertFalse(await shopping.is_requested(self.conn, meal))
|
||||
await shopping.request(self.conn, person, meal=meal)
|
||||
self.assertTrue(await shopping.is_requested(self.conn, meal))
|
||||
|
|
@ -115,7 +115,7 @@ class TestShopping(unittest.IsolatedAsyncioTestCase):
|
|||
name="Simple Recipe",
|
||||
link="http://example.com/simple",
|
||||
serves=2,
|
||||
created_by_id=test_data.Persons.jacob.id,
|
||||
created_by_id=test_data.MemberRefs.jacob.id,
|
||||
)
|
||||
await recipes.insert_recipe(self.conn, recipe)
|
||||
|
||||
|
|
@ -148,15 +148,15 @@ class TestShopping(unittest.IsolatedAsyncioTestCase):
|
|||
meal = meals.Meal(
|
||||
id=-1,
|
||||
suggested_date=datetime.now(),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
recipes=[meal_recipe],
|
||||
)
|
||||
await meals.insert_meal(self.conn, meal)
|
||||
|
||||
# Request the meal
|
||||
person = test_data.Persons.jacob
|
||||
person = test_data.MemberRefs.jacob
|
||||
await shopping.request(self.conn, person, meal=meal)
|
||||
self.assertTrue(await shopping.is_requested(self.conn, meal))
|
||||
|
||||
|
|
@ -212,7 +212,7 @@ class TestShopping(unittest.IsolatedAsyncioTestCase):
|
|||
name="Recipe with Extra",
|
||||
link="http://example.com/extra",
|
||||
serves=2,
|
||||
created_by_id=test_data.Persons.jacob.id,
|
||||
created_by_id=test_data.MemberRefs.jacob.id,
|
||||
)
|
||||
await recipes.insert_recipe(self.conn, recipe)
|
||||
|
||||
|
|
@ -237,15 +237,15 @@ class TestShopping(unittest.IsolatedAsyncioTestCase):
|
|||
meal = meals.Meal(
|
||||
id=-1,
|
||||
suggested_date=datetime.now(),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
recipes=[meal_recipe],
|
||||
extra_ingredients=[extra_ingredient],
|
||||
)
|
||||
await meals.insert_meal(self.conn, meal)
|
||||
|
||||
person = test_data.Persons.jacob
|
||||
person = test_data.MemberRefs.jacob
|
||||
await shopping.request(self.conn, person, meal=meal)
|
||||
|
||||
(
|
||||
|
|
@ -301,7 +301,7 @@ class TestShopping(unittest.IsolatedAsyncioTestCase):
|
|||
await ingredients.insert_ingredient(self.conn, ingredient2)
|
||||
|
||||
# Request individual ingredients
|
||||
person = test_data.Persons.jacob
|
||||
person = test_data.MemberRefs.jacob
|
||||
await shopping.request(self.conn, person, ingredient=ingredient1)
|
||||
await shopping.request(self.conn, person, ingredient=ingredient2)
|
||||
|
||||
|
|
@ -370,7 +370,7 @@ class TestShopping(unittest.IsolatedAsyncioTestCase):
|
|||
name="Simple Pasta",
|
||||
link="http://example.com/pasta",
|
||||
serves=2,
|
||||
created_by_id=test_data.Persons.jacob.id,
|
||||
created_by_id=test_data.MemberRefs.jacob.id,
|
||||
)
|
||||
await recipes.insert_recipe(self.conn, recipe)
|
||||
|
||||
|
|
@ -389,9 +389,9 @@ class TestShopping(unittest.IsolatedAsyncioTestCase):
|
|||
meal = meals.Meal(
|
||||
id=-1,
|
||||
suggested_date=datetime.now(),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
recipes=[meal_recipe],
|
||||
)
|
||||
await meals.insert_meal(self.conn, meal)
|
||||
|
|
@ -401,7 +401,7 @@ class TestShopping(unittest.IsolatedAsyncioTestCase):
|
|||
)
|
||||
await ingredients.insert_ingredient(self.conn, snack_ingredient)
|
||||
|
||||
person = test_data.Persons.jacob
|
||||
person = test_data.MemberRefs.jacob
|
||||
await shopping.request(self.conn, person, meal=meal)
|
||||
await shopping.request(self.conn, person, ingredient=snack_ingredient)
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ class TestShoppingAPI(unittest.IsolatedAsyncioTestCase):
|
|||
def test_purchase_validation_error_returns_problem(self):
|
||||
# Override cookie_person to simulate authenticated user
|
||||
async def override_cookie_person():
|
||||
return test_data.Persons.jacob
|
||||
return test_data.MemberRefs.jacob
|
||||
|
||||
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue