Minimal v2 endpoints scaffolded alongside v1 cookie auth
This commit is contained in:
parent
aa74972fdf
commit
f1dac94396
15 changed files with 672 additions and 23 deletions
62
api/auth_v2.py
Normal file
62
api/auth_v2.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
|
||||||
|
from api.deps import error_response, get_db
|
||||||
|
from common import ApiModel
|
||||||
|
from users import repository as users_db
|
||||||
|
from users.models import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth-v2"])
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterBody(ApiModel):
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginBody(ApiModel):
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(ApiModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
user: User
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_pw(pw: str) -> str:
|
||||||
|
# Placeholder; replace with proper hashing (bcrypt/argon2) later
|
||||||
|
return hashlib.sha256(pw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=TokenResponse, operation_id="register")
|
||||||
|
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")
|
||||||
|
uid = await users_db.insert_user(conn, body.email, body.display_name)
|
||||||
|
await users_db.set_local_credentials(conn, uid, _hash_pw(body.password))
|
||||||
|
user = await users_db.get_by_email(conn, body.email)
|
||||||
|
assert user is not None
|
||||||
|
# Token is a simple placeholder containing user id; will be replaced with JWT
|
||||||
|
token = f"user-{user.id}"
|
||||||
|
return TokenResponse(access_token=token, user=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse, operation_id="loginV2")
|
||||||
|
async def login(request: Request, body: LoginBody, conn: aiosqlite.Connection = Depends(get_db)):
|
||||||
|
user: Optional[User] = await users_db.get_by_email(conn, body.email)
|
||||||
|
if not user:
|
||||||
|
return error_response(request, 401, "Invalid credentials")
|
||||||
|
stored = await users_db.get_local_password_hash(conn, user.id)
|
||||||
|
if not stored or stored != _hash_pw(body.password):
|
||||||
|
return error_response(request, 401, "Invalid credentials")
|
||||||
|
token = f"user-{user.id}"
|
||||||
|
return TokenResponse(access_token=token, user=user)
|
||||||
28
api/deps.py
28
api/deps.py
|
|
@ -10,6 +10,8 @@ import db
|
||||||
import persons
|
import persons
|
||||||
from common import ProblemDetails
|
from common import ProblemDetails
|
||||||
from settings import settings
|
from settings import settings
|
||||||
|
from users import repository as users_db
|
||||||
|
from users.models import User
|
||||||
|
|
||||||
|
|
||||||
# Dependency to create SQLite connection with PRAGMAs and per-request transaction
|
# Dependency to create SQLite connection with PRAGMAs and per-request transaction
|
||||||
|
|
@ -77,3 +79,29 @@ def error_response(request: Optional[Request], status_code: int, message: str) -
|
||||||
status_code=status_code,
|
status_code=status_code,
|
||||||
media_type="application/problem+json",
|
media_type="application/problem+json",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(request: Request, conn: aiosqlite.Connection = Depends(get_db)) -> User:
|
||||||
|
"""Temporary bearer token auth: expects Authorization: Bearer user-<id>.
|
||||||
|
|
||||||
|
This is a stopgap until JWT is implemented. Returns 401 on failure.
|
||||||
|
"""
|
||||||
|
auth = request.headers.get("Authorization")
|
||||||
|
if not auth or not auth.lower().startswith("bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
token = auth.split(" ", 1)[1].strip()
|
||||||
|
if not token.startswith("user-"):
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
try:
|
||||||
|
user_id = int(token.split("-", 1)[1])
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
# Lookup by id
|
||||||
|
async with conn.execute(
|
||||||
|
"SELECT id, email, display_name, profile_photo_url FROM User WHERE id = ?",
|
||||||
|
(user_id,),
|
||||||
|
) as c:
|
||||||
|
row = await c.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
return User(id=int(row[0]), email=row[1], display_name=row[2], profile_photo_url=row[3])
|
||||||
|
|
|
||||||
72
api/households.py
Normal file
72
api/households.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
|
||||||
|
from api.deps import get_current_user, get_db, error_response
|
||||||
|
from common import ApiModel
|
||||||
|
from users.models import User
|
||||||
|
|
||||||
|
router = APIRouter(tags=["households"])
|
||||||
|
|
||||||
|
|
||||||
|
class CreateHouseholdBody(ApiModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class HouseholdResponse(ApiModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(name: str) -> str:
|
||||||
|
s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||||
|
return s or "household"
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
):
|
||||||
|
results: list[HouseholdResponse] = []
|
||||||
|
async with conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT h.id, h.name, h.slug FROM Household h
|
||||||
|
JOIN HouseholdMember m ON m.household_id = h.id
|
||||||
|
WHERE m.user_id = ?
|
||||||
|
ORDER BY h.id
|
||||||
|
""",
|
||||||
|
(user.id,),
|
||||||
|
) as c:
|
||||||
|
async for row in c:
|
||||||
|
results.append(HouseholdResponse(id=int(row[0]), name=row[1], slug=row[2]))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/households", response_model=HouseholdResponse)
|
||||||
|
async def create_household(
|
||||||
|
request: Request,
|
||||||
|
body: CreateHouseholdBody,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
slug = slugify(body.name)
|
||||||
|
try:
|
||||||
|
async with conn.execute(
|
||||||
|
"INSERT INTO Household (name, slug) VALUES (?, ?)", (body.name, slug)
|
||||||
|
) as cur:
|
||||||
|
lrid = cur.lastrowid
|
||||||
|
if lrid is None:
|
||||||
|
return error_response(request, 400, "Unable to create household")
|
||||||
|
hid = int(lrid)
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO HouseholdMember (user_id, household_id, role) VALUES (?, ?, ?)",
|
||||||
|
(user.id, hid, "admin"),
|
||||||
|
)
|
||||||
|
return HouseholdResponse(id=hid, name=body.name, slug=slug)
|
||||||
|
except Exception:
|
||||||
|
return error_response(request, 400, "Unable to create household")
|
||||||
|
|
@ -173,39 +173,58 @@ Impact on existing routes (exact files to refactor):
|
||||||
|
|
||||||
## 4. Actionable Implementation Steps
|
## 4. Actionable Implementation Steps
|
||||||
|
|
||||||
1. **[ ] Database Schema and Migration**:
|
1. **[~] Database Schema and Migration**:
|
||||||
- **Schema Definition**: Define the new tables (`users`, `households`, etc.) in new repository files (e.g., `users/repository.py`, `households/repository.py`). The `create` function in each will contain the `CREATE TABLE` SQL.
|
- ✅ **Schema Definition**: Added new packages and tables:
|
||||||
- **Migration Logic**: Create a new migration script (e.g., in `scripts/migration_to_households.py`). This script will:
|
- `users` with tables `User`, `LocalCredentials`, `OAuthCredentials` (see `users/repository.py`).
|
||||||
|
- `households` with tables `Household`, `HouseholdMember`, `HouseholdInvitation` (see `households/repository.py`).
|
||||||
|
- `db.create()` now initializes these tables alongside existing v1 tables.
|
||||||
|
- ✅ **Migration Logic**: Implemented `scripts/migration_to_households.py` which:
|
||||||
- Connect to the database (reusing logic from `db.py`).
|
- Connect to the database (reusing logic from `db.py`).
|
||||||
- Call the `create()` function for each new repository to create the tables.
|
- Calls `create()` for new repos to ensure tables exist.
|
||||||
- Add a `household_id` column to all existing tenant-specific tables (`recipes`, `meals`, etc.).
|
- Adds a `household_id` column to tenant tables: `Recipe`, `Ingredient`, `Meal`, `MealParticipant`, `MealRecipe`, `ShoppingList`, `ShoppingListItem` (idempotent).
|
||||||
- **Data Porting**:
|
- Creates indices `idx_<table>_household_id` for all above tables.
|
||||||
- Create a single default "My Household".
|
- Creates a default household `{ name: "My Household", slug: "default" }` and backfills `household_id` with its ID for existing rows.
|
||||||
- Read all records from the `persons` table.
|
- Ports `Person` rows to `User` (email derived as `<name>@example.com`) and creates `HouseholdMember` links (role `admin`).
|
||||||
- For each person, create a corresponding record in `users` and `household_members`.
|
|
||||||
- Backfill the `household_id` in all existing resources with the ID of the default household.
|
|
||||||
- **Bootstrap Update**: Modify `db.py` so that a fresh database bootstrap (`db.create_schema`) calls the `create()` functions for the new repositories and *not* the old `persons` repository.
|
- **Bootstrap Update**: Modify `db.py` so that a fresh database bootstrap (`db.create_schema`) calls the `create()` functions for the new repositories and *not* the old `persons` repository.
|
||||||
- **Indices/Constraints**: Add unique `households.slug`; composite indexes on `(household_id, id)` per table; foreign keys with `ON DELETE CASCADE` where appropriate.
|
- ✅ **Indices/Constraints**: Enforced unique `Household.slug`; added `idx_*_household_id` indices; foreign keys added with `ON DELETE CASCADE` where applicable in new tables.
|
||||||
- **Acceptance**: Fresh bootstrap creates all tables; migration script idempotently adds columns and backfills; existing tests still pass against default household.
|
- ✅ **Acceptance (initial)**: Added `tests/test_migration_households.py` covering: new tables exist, `household_id` columns exist, default household created, and data porting from `Person` to `User` and `HouseholdMember`. Full test suite passes.
|
||||||
|
|
||||||
2. **[ ] Implement New Authentication System**:
|
- Pending follow-ups for this step:
|
||||||
- **Refactor `api/auth.py`**: Gut the existing cookie-based logic. Implement the new `/register`, `/login`, and `/google` endpoints.
|
- Add composite indices `(household_id, id)` where high-cardinality pagination will benefit.
|
||||||
- **Create `users/` package**: Add `models.py` and `repository.py` for the new `User` entity.
|
- Extend migration to add FK constraints from tenant tables to `Household(id)` where safe.
|
||||||
- **Update `api/deps.py`**: Replace `get_current_person` with a new `get_current_user` dependency that validates the JWT and returns the `User` model.
|
- Plan and implement data backfill for cross-table references once `users` replace `persons` in code.
|
||||||
|
|
||||||
|
2. **[~] Implement New Authentication System**:
|
||||||
|
- ✅ Minimal v2 endpoints scaffolded alongside v1 cookie auth (no breakage):
|
||||||
|
- Added `api/auth_v2.py` with `/api/v1/auth/register` and `/api/v1/auth/login`. Currently returns a simple bearer token of the form `user-<id>`.
|
||||||
|
- Added `users/repository.py` helpers: `get_by_email`, `insert_user`, `set_local_credentials`, `get_local_password_hash`.
|
||||||
|
- Added `get_current_user` dependency in `api/deps.py` that reads `Authorization: Bearer user-<id>` and returns the `User`.
|
||||||
|
- Wired v2 router in `main.py` without removing v1 cookie routes.
|
||||||
|
- Added tests: `tests/test_auth_and_households_v2.py` registers/logs in a user and exercises bearer auth.
|
||||||
|
- Pending (to complete this step):
|
||||||
|
- Replace placeholder token with real JWT signing/verification and introduce refresh tokens via HttpOnly cookie.
|
||||||
|
- Update `api/openapi.py` security scheme from cookie to bearer JWT and mark protected operations.
|
||||||
|
- Remove `persons` dependency from protected endpoints once household scoping is in place.
|
||||||
- **Token plumbing**: Configure signing keys, token lifetimes, and `HttpOnly` refresh cookie. Consider `Authorization: Bearer` for access tokens.
|
- **Token plumbing**: Configure signing keys, token lifetimes, and `HttpOnly` refresh cookie. Consider `Authorization: Bearer` for access tokens.
|
||||||
- **OpenAPI**: Update `api/openapi.py` to replace `cookieAuth` with `bearerAuth` (JWT) and mark protected operations accordingly.
|
- **OpenAPI**: Update `api/openapi.py` to replace `cookieAuth` with `bearerAuth` (JWT) and mark protected operations accordingly.
|
||||||
- **Acceptance**: Protected endpoints reject unauthenticated with 401; membership failures yield 403; tests updated to generate JWTs.
|
- **Acceptance**: Protected endpoints reject unauthenticated with 401; membership failures yield 403; tests updated to generate JWTs.
|
||||||
|
|
||||||
3. **[ ] Implement Household Scoping**:
|
3. **[~] Implement Household Scoping**:
|
||||||
- **Create `households/` package**: Add `models.py` and `repository.py` for `Household`, `HouseholdMember`, and `HouseholdInvitation`.
|
- ✅ Created `households/` package with `models.py` and `repository.py`.
|
||||||
- **Implement `get_household_from_slug`** in `api/deps.py`.
|
- ✅ Added initial `api/households.py` router:
|
||||||
- **Refactor `main.py`**:
|
- `GET /api/v1/users/me/households` (requires bearer token) → lists memberships.
|
||||||
|
- `POST /api/v1/households` (requires bearer token) → creates household and adds current user as admin.
|
||||||
|
- ⏳ Implement `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}")`.
|
- Create a new `APIRouter` for household-scoped routes, e.g., `household_router = APIRouter(prefix="/api/v1/households/{householdSlug}")`.
|
||||||
- Mount the existing routers (`recipes_api`, `meals_api`, etc.) onto this new `household_router` instead of the main `app`.
|
- Mount the existing routers (`recipes_api`, `meals_api`, etc.) onto this `household_router`.
|
||||||
- **Update Repositories**: Modify all repository functions (e.g., `recipes.repository.get_all`, `meals.repository.create`) to accept a `household_id` and use it in the `WHERE` clause of every SQL query.
|
- ⏳ Update Repositories: modify all repository functions to accept `household_id` and filter by it.
|
||||||
- **Update Routers**: Add the `get_household_from_slug` dependency to all household-scoped routes and pass the resulting `household_id` to the repository functions.
|
- ⏳ Update Routers: add `get_household_from_slug` dependency to all scoped routes and plumb `household_id`.
|
||||||
- **Acceptance**: The same queries as v1, when run under different household slugs and users, return isolated data sets; cross-household access yields 403.
|
- **Acceptance**: The same queries as v1, when run under different household slugs and users, return isolated data sets; cross-household access yields 403.
|
||||||
|
|
||||||
|
- Notes:
|
||||||
|
- Migration added `household_id` columns and indices, enabling next step to filter by household without additional schema changes.
|
||||||
|
|
||||||
4. **[ ] Implement Household & Invitation Logic**:
|
4. **[ ] Implement Household & Invitation Logic**:
|
||||||
- Create the `api/households.py` router and implement the endpoints for creating households, listing the user's households, and managing invitations.
|
- Create the `api/households.py` router and implement the endpoints for creating households, listing the user's households, and managing invitations.
|
||||||
- Add the logic for sending invitation emails (this may require a new utility/service for sending emails).
|
- Add the logic for sending invitation emails (this may require a new utility/service for sending emails).
|
||||||
|
|
|
||||||
14
db.py
14
db.py
|
|
@ -20,6 +20,20 @@ async def create(conn: aiosqlite.Connection):
|
||||||
|
|
||||||
await recipe_db.create(conn)
|
await recipe_db.create(conn)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
import households.repository as households_db
|
||||||
|
await households_db.create(conn)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
import persons.repository as person_db
|
import persons.repository as person_db
|
||||||
|
|
||||||
await person_db.create(conn)
|
await person_db.create(conn)
|
||||||
|
|
|
||||||
2
households/__init__.py
Normal file
2
households/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
from households.models import Household as Household, HouseholdInvitation as HouseholdInvitation
|
||||||
|
from households.repository import create as create
|
||||||
39
households/models.py
Normal file
39
households/models.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
from typing import ClassVar, Optional
|
||||||
|
|
||||||
|
from common import ApiModel
|
||||||
|
|
||||||
|
|
||||||
|
class Household(ApiModel):
|
||||||
|
KEYS: ClassVar[list[str]] = ["id", "name", "slug"]
|
||||||
|
|
||||||
|
id: int = -1
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
|
||||||
|
|
||||||
|
class HouseholdMember(ApiModel):
|
||||||
|
KEYS: ClassVar[list[str]] = ["user_id", "household_id", "role"]
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
household_id: int
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
class HouseholdInvitation(ApiModel):
|
||||||
|
KEYS: ClassVar[list[str]] = [
|
||||||
|
"id",
|
||||||
|
"household_id",
|
||||||
|
"email",
|
||||||
|
"invited_by_user_id",
|
||||||
|
"token",
|
||||||
|
"expires_at",
|
||||||
|
"status",
|
||||||
|
]
|
||||||
|
|
||||||
|
id: int = -1
|
||||||
|
household_id: int
|
||||||
|
email: str
|
||||||
|
invited_by_user_id: int
|
||||||
|
token: str
|
||||||
|
expires_at: str
|
||||||
|
status: str
|
||||||
50
households/repository.py
Normal file
50
households/repository.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
async def create(conn):
|
||||||
|
# Households table
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS Household (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Membership table
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS HouseholdMember (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
household_id INTEGER NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, household_id),
|
||||||
|
FOREIGN KEY(user_id) REFERENCES User(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(household_id) REFERENCES Household(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Invitations table
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS HouseholdInvitation (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
household_id INTEGER NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
invited_by_user_id INTEGER NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(household_id) REFERENCES Household(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(invited_by_user_id) REFERENCES User(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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_member_household ON HouseholdMember(household_id);"
|
||||||
|
)
|
||||||
5
main.py
5
main.py
|
|
@ -10,11 +10,13 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
|
|
||||||
from api import (
|
from api import (
|
||||||
auth as auth_router,
|
auth as auth_router,
|
||||||
|
auth_v2 as auth_v2_router,
|
||||||
meals as meals_router,
|
meals as meals_router,
|
||||||
persons as persons_router,
|
persons as persons_router,
|
||||||
products as products_router,
|
products as products_router,
|
||||||
recipes as recipes_router,
|
recipes as recipes_router,
|
||||||
shopping as shopping_router,
|
shopping as shopping_router,
|
||||||
|
households as households_router,
|
||||||
)
|
)
|
||||||
from api.deps import (
|
from api.deps import (
|
||||||
cookie_person as cookie_person, # noqa: F401 - re-exported for tests
|
cookie_person as cookie_person, # noqa: F401 - re-exported for tests
|
||||||
|
|
@ -159,6 +161,9 @@ def create_app() -> FastAPI:
|
||||||
app.include_router(shopping_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
app.include_router(shopping_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||||
app.include_router(persons_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
app.include_router(persons_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||||
app.include_router(auth_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
app.include_router(auth_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||||
|
# Experimental v2 auth endpoints (JWT to be implemented). Kept alongside v1 during transition.
|
||||||
|
app.include_router(auth_v2_router.router, prefix="/api/v1", tags=["v2"])
|
||||||
|
app.include_router(households_router.router, prefix="/api/v1", tags=["v2"]) # new
|
||||||
|
|
||||||
# Routes
|
# Routes
|
||||||
app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus)
|
app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus)
|
||||||
|
|
|
||||||
136
scripts/migration_to_households.py
Normal file
136
scripts/migration_to_households.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
import db
|
||||||
|
from settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
async def column_exists(conn: aiosqlite.Connection, table: str, column: str) -> bool:
|
||||||
|
async with conn.execute(f"PRAGMA table_info({table});") as cursor:
|
||||||
|
async for row in cursor:
|
||||||
|
if row[1] == column:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
if not await column_exists(conn, table, col_name):
|
||||||
|
await conn.execute(f"ALTER TABLE {table} ADD COLUMN {column_def};")
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_default_household(conn: aiosqlite.Connection) -> int:
|
||||||
|
# Create a default household and return its id, idempotently
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO Household (name, slug)
|
||||||
|
VALUES ('My Household', 'default')
|
||||||
|
ON CONFLICT(slug) DO NOTHING
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
async with conn.execute("SELECT id FROM Household WHERE slug = 'default' LIMIT 1;") as c:
|
||||||
|
row = await c.fetchone()
|
||||||
|
assert row is not None
|
||||||
|
return int(row[0])
|
||||||
|
|
||||||
|
|
||||||
|
async def backfill_table_household_id(
|
||||||
|
conn: aiosqlite.Connection, table: str, default_household_id: int
|
||||||
|
) -> None:
|
||||||
|
# If any NULL household_id rows exist, backfill to default
|
||||||
|
await conn.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE {table}
|
||||||
|
SET household_id = ?
|
||||||
|
WHERE household_id IS NULL
|
||||||
|
""",
|
||||||
|
(default_household_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migration(conn: Optional[aiosqlite.Connection] = None):
|
||||||
|
owned = False
|
||||||
|
if conn is None:
|
||||||
|
conn = await db.connect(settings.database_path)
|
||||||
|
owned = True
|
||||||
|
try:
|
||||||
|
# Ensure new domain tables exist
|
||||||
|
from users import repository as users_db
|
||||||
|
from households import repository as households_db
|
||||||
|
|
||||||
|
await users_db.create(conn)
|
||||||
|
await households_db.create(conn)
|
||||||
|
|
||||||
|
# Add household_id columns to tenant tables
|
||||||
|
for table in [
|
||||||
|
"Recipe",
|
||||||
|
"Ingredient",
|
||||||
|
"Meal",
|
||||||
|
"MealParticipant",
|
||||||
|
"MealRecipe",
|
||||||
|
"ShoppingList",
|
||||||
|
"ShoppingListItem",
|
||||||
|
]:
|
||||||
|
await add_column_if_missing(conn, table, "household_id INTEGER")
|
||||||
|
|
||||||
|
# Backfill default household
|
||||||
|
default_hid = await ensure_default_household(conn)
|
||||||
|
for table in [
|
||||||
|
"Recipe",
|
||||||
|
"Ingredient",
|
||||||
|
"Meal",
|
||||||
|
"MealParticipant",
|
||||||
|
"MealRecipe",
|
||||||
|
"ShoppingList",
|
||||||
|
"ShoppingListItem",
|
||||||
|
]:
|
||||||
|
await backfill_table_household_id(conn, table, default_hid)
|
||||||
|
|
||||||
|
# Create indices on household_id for efficient scoping
|
||||||
|
for table in [
|
||||||
|
"Recipe",
|
||||||
|
"Ingredient",
|
||||||
|
"Meal",
|
||||||
|
"MealParticipant",
|
||||||
|
"MealRecipe",
|
||||||
|
"ShoppingList",
|
||||||
|
"ShoppingListItem",
|
||||||
|
]:
|
||||||
|
await conn.execute(
|
||||||
|
f"CREATE INDEX IF NOT EXISTS idx_{table.lower()}_household_id ON {table}(household_id);"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Port persons -> users and create memberships in default household
|
||||||
|
# Only perform if users table currently empty
|
||||||
|
async with conn.execute("SELECT COUNT(1) FROM User;") as c:
|
||||||
|
row = await c.fetchone()
|
||||||
|
user_count = int(row[0]) if row else 0
|
||||||
|
if user_count == 0:
|
||||||
|
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
|
||||||
|
# Insert user
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO User (id, email, display_name) VALUES (?, ?, ?)\n ON CONFLICT(id) DO NOTHING;",
|
||||||
|
(pid, email, display_name),
|
||||||
|
)
|
||||||
|
# Create membership
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO HouseholdMember (user_id, household_id, role) VALUES (?, ?, ?);",
|
||||||
|
(pid, default_hid, "admin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
|
finally:
|
||||||
|
if owned:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(run_migration())
|
||||||
47
tests/test_auth_and_households_v2.py
Normal file
47
tests/test_auth_and_households_v2.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import unittest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import main
|
||||||
|
from db import connect, create
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthAndHouseholdsV2(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def asyncSetUp(self):
|
||||||
|
self.conn = await connect(":memory:")
|
||||||
|
await create(self.conn)
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
try:
|
||||||
|
yield self.conn
|
||||||
|
finally:
|
||||||
|
pass
|
||||||
|
|
||||||
|
main.app.dependency_overrides[main.get_db] = override_get_db
|
||||||
|
self.client = TestClient(main.app)
|
||||||
|
|
||||||
|
async def asyncTearDown(self):
|
||||||
|
await self.conn.close()
|
||||||
|
main.app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
def test_register_and_login_and_households(self):
|
||||||
|
# Register a user
|
||||||
|
r = self.client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": "test@example.com", "password": "pw", "displayName": "Test"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
token = body["accessToken"]
|
||||||
|
assert token.startswith("user-")
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
# List households (migration created default household 'default' and membership set to admin)
|
||||||
|
r = self.client.get("/api/v1/users/me/households", headers=headers)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
households = r.json()
|
||||||
|
|
||||||
|
# Create a new household
|
||||||
|
r = self.client.post("/api/v1/households", headers=headers, json={"name": "Family"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
created = r.json()
|
||||||
|
assert created["slug"].startswith("family")
|
||||||
77
tests/test_migration_households.py
Normal file
77
tests/test_migration_households.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
import db
|
||||||
|
from tests.test_data import create_test_data
|
||||||
|
from scripts.migration_to_households import run_migration
|
||||||
|
|
||||||
|
|
||||||
|
async def table_has_column(conn: aiosqlite.Connection, table: str, col: str) -> bool:
|
||||||
|
async with conn.execute(f"PRAGMA table_info({table});") as c:
|
||||||
|
async for row in c:
|
||||||
|
if row[1] == col:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_adds_tables_and_columns_and_ports_data(tmp_path):
|
||||||
|
async def _run():
|
||||||
|
db_path = tmp_path / "test.sqlite"
|
||||||
|
conn = await db.connect(str(db_path))
|
||||||
|
# Bootstrap v1 schema
|
||||||
|
await db.create(conn)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
# Seed some v1 data (persons, products, recipes, meals)
|
||||||
|
await create_test_data(conn)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
# Run migration
|
||||||
|
await run_migration(conn)
|
||||||
|
|
||||||
|
# Verify new tables exist
|
||||||
|
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:
|
||||||
|
assert await c.fetchone() is not None, f"Missing table {tbl}"
|
||||||
|
|
||||||
|
# Verify household_id column exists on tenant tables
|
||||||
|
tenant_tables = [
|
||||||
|
"Recipe",
|
||||||
|
"Ingredient",
|
||||||
|
"Meal",
|
||||||
|
"MealParticipant",
|
||||||
|
"MealRecipe",
|
||||||
|
"ShoppingList",
|
||||||
|
"ShoppingListItem",
|
||||||
|
]
|
||||||
|
for tbl in tenant_tables:
|
||||||
|
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:
|
||||||
|
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])
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
2
users/__init__.py
Normal file
2
users/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
from users.models import User as User
|
||||||
|
from users.repository import create as create
|
||||||
17
users/models.py
Normal file
17
users/models.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
from typing import ClassVar, Optional
|
||||||
|
|
||||||
|
from common import ApiModel
|
||||||
|
|
||||||
|
|
||||||
|
class User(ApiModel):
|
||||||
|
KEYS: ClassVar[list[str]] = [
|
||||||
|
"id",
|
||||||
|
"email",
|
||||||
|
"display_name",
|
||||||
|
"profile_photo_url",
|
||||||
|
]
|
||||||
|
|
||||||
|
id: int = -1
|
||||||
|
email: str
|
||||||
|
display_name: str
|
||||||
|
profile_photo_url: Optional[str] = None
|
||||||
79
users/repository.py
Normal file
79
users/repository.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
async def create(conn):
|
||||||
|
# Users core table
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS User (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
profile_photo_url TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Local credential storage for password auth
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS LocalCredentials (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
hashed_password TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES User(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# OAuth provider links (e.g., Google)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS OAuthCredentials (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
provider_user_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (provider, provider_user_id),
|
||||||
|
FOREIGN KEY(user_id) REFERENCES User(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Helpful indices
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_user_email ON User(email);"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_by_email(conn, email: str):
|
||||||
|
async with conn.execute(
|
||||||
|
"SELECT id, email, display_name, profile_photo_url FROM User WHERE email = ? LIMIT 1",
|
||||||
|
(email,),
|
||||||
|
) as c:
|
||||||
|
row = await c.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
from users.models import User
|
||||||
|
|
||||||
|
return User(id=int(row[0]), email=row[1], display_name=row[2], profile_photo_url=row[3])
|
||||||
|
|
||||||
|
|
||||||
|
async def insert_user(conn, email: str, display_name: str, profile_photo_url: str | None = None):
|
||||||
|
async with conn.execute(
|
||||||
|
"INSERT INTO User (email, display_name, profile_photo_url) VALUES (?, ?, ?)",
|
||||||
|
(email, display_name, profile_photo_url),
|
||||||
|
) as cur:
|
||||||
|
user_id = cur.lastrowid
|
||||||
|
return user_id
|
||||||
|
|
||||||
|
|
||||||
|
async def set_local_credentials(conn, user_id: int, hashed_password: str):
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO LocalCredentials (user_id, hashed_password) VALUES (?, ?)",
|
||||||
|
(user_id, hashed_password),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_local_password_hash(conn, user_id: int) -> str | None:
|
||||||
|
async with conn.execute(
|
||||||
|
"SELECT hashed_password FROM LocalCredentials WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
) as c:
|
||||||
|
row = await c.fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
Loading…
Reference in a new issue