persons removed

This commit is contained in:
jableader 2025-11-01 22:35:10 +11:00
parent 7b82cca5e3
commit 964072391e
15 changed files with 154 additions and 1541 deletions

View file

@ -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: []

View file

@ -1,4 +0,0 @@
"""Legacy v1 persons API is removed in favor of users/household members.
This module intentionally has no routes.
"""

View file

@ -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
View file

@ -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

View file

@ -3067,4 +3067,4 @@
}
}
}
}
}

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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, {})

File diff suppressed because it is too large Load diff

View file

@ -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)

View file

@ -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()

View file

@ -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)

View file

@ -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