munch-ease-backend/api/households.py

208 lines
6 KiB
Python
Raw Normal View History

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, get_household_from_slug
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(
2025-11-01 04:34:01 +00:00
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")
# Household-scoped router and endpoint to validate scoping mechanics
scoped = APIRouter(prefix="/households/{householdSlug}")
class WhoAmI(ApiModel):
household_id: int
household_slug: str
@scoped.get("/whoami", response_model=WhoAmI)
async def whoami(household=Depends(get_household_from_slug)):
return WhoAmI(household_id=household["id"], household_slug=household["slug"])
# Members listing to unblock frontend
class HouseholdMember(ApiModel):
id: int
display_name: str
role: str
@scoped.get("/members", response_model=list[HouseholdMember])
async def list_members(
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
):
members: list[HouseholdMember] = []
async with conn.execute(
"""
SELECT u.id, u.display_name, m.role
FROM HouseholdMember m
JOIN User u ON u.id = m.user_id
WHERE m.household_id = ?
ORDER BY lower(u.display_name), u.id
""",
(household["id"],),
) as c:
async for row in c:
members.append(HouseholdMember(id=int(row[0]), display_name=row[1], role=row[2]))
return members
# Invitations
class CreateInvitationBody(ApiModel):
email: str
class InvitationResponse(ApiModel):
token: str
status: str = "pending"
@scoped.post("/invitations", response_model=InvitationResponse)
async def create_invitation(
request: Request,
body: CreateInvitationBody,
user: User = Depends(get_current_user),
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
):
import secrets
from datetime import datetime, timedelta
token = secrets.token_urlsafe(24)
expires_at = (datetime.utcnow() + timedelta(days=14)).isoformat() + "Z"
try:
await conn.execute(
"""
INSERT INTO HouseholdInvitation (household_id, email, invited_by_user_id, token, expires_at, status)
VALUES (?, ?, ?, ?, ?, ?)
""",
(household["id"], body.email, user.id, token, expires_at, "pending"),
)
return InvitationResponse(token=token)
except Exception:
return error_response(request, 400, "Unable to create invitation")
class AcceptInvitationBody(ApiModel):
token: str
class AcceptInvitationResponse(ApiModel):
status: str = "accepted"
household: HouseholdResponse
# Accept invitation (mounted on root router via main.py)
@router.post("/invitations/accept", response_model=AcceptInvitationResponse)
async def accept_invitation(
request: Request,
body: AcceptInvitationBody,
user: User = Depends(get_current_user),
conn: aiosqlite.Connection = Depends(get_db),
):
token = body.token
# Lookup invitation
async with conn.execute(
"SELECT id, household_id, status FROM HouseholdInvitation WHERE token = ?",
(token,),
) as c:
row = await c.fetchone()
if not row:
return error_response(request, 404, "Invitation not found")
inv_id = int(row[0])
hid = int(row[1])
status = row[2]
if status != "pending":
return error_response(request, 400, "Invitation not pending")
# Add membership if not exists
await conn.execute(
"INSERT OR IGNORE INTO HouseholdMember (user_id, household_id, role) VALUES (?, ?, ?)",
(user.id, hid, "member"),
)
# Mark invitation accepted
await conn.execute(
"UPDATE HouseholdInvitation SET status = 'accepted' WHERE id = ?",
(inv_id,),
)
# Load household details for response
async with conn.execute(
"SELECT id, name, slug FROM Household WHERE id = ?",
(hid,),
) as c:
hrow = await c.fetchone()
if not hrow:
return error_response(request, 404, "Household not found")
return AcceptInvitationResponse(
status="accepted",
household=HouseholdResponse(id=int(hrow[0]), name=hrow[1], slug=hrow[2]),
)