Minimal v2 endpoints scaffolded alongside v1 cookie auth

This commit is contained in:
jableader 2025-11-01 13:43:26 +11:00
parent aa74972fdf
commit f1dac94396
15 changed files with 672 additions and 23 deletions

62
api/auth_v2.py Normal file
View 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)

View file

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

View file

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

@ -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
View 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
View 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
View 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);"
)

View file

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

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

View 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")

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