munch-ease-backend/api/households.py

167 lines
4.9 KiB
Python

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 pydantic import Field
from common import ApiModel
from households import repository as households_repo
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 = await households_repo.list_for_user(conn, user.id)
return [HouseholdResponse.model_validate(h) for h in 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)
household = await households_repo.create_for_user(conn, body.name, slug, user.id)
if household is None:
return error_response(request, 400, "Unable to create household")
return HouseholdResponse.model_validate(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),
):
member_data = await households_repo.list_members(conn, household["id"])
return [HouseholdMember.model_validate(m) for m in member_data]
# Invitations
class InvitationResponse(ApiModel):
token: str
status: str = "pending"
class InviteLinkResponse(ApiModel):
# Force snake_case in JSON output to match spec and tests
invite_link: str = Field(serialization_alias="invite_link")
@scoped.post("/invitations", response_model=InviteLinkResponse)
async def create_invitation(
request: Request,
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
from urllib.parse import urljoin, urlencode
from settings import settings
token = secrets.token_urlsafe(24)
expires_at = (datetime.utcnow() + timedelta(days=14)).isoformat() + "Z"
success = await households_repo.create_invitation(
conn, household["id"], user.id, token, expires_at
)
if not success:
return error_response(request, 400, "Unable to create invitation")
base = settings.frontend_dev_url
# Ensure base ends with a slash for urljoin
if not base.endswith("/"):
base = base + "/"
path_with_query = f"invitations/accept?{urlencode({'token': token})}"
invite_link = urljoin(base, path_with_query)
return InviteLinkResponse(invite_link=invite_link)
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),
):
# Lookup invitation
invitation = await households_repo.get_invitation_by_token(conn, body.token)
if not invitation:
return error_response(request, 404, "Invitation not found")
if invitation.status != "pending":
return error_response(request, 400, "Invitation not pending")
# Add membership and mark invitation accepted
await households_repo.accept_invitation(
conn, invitation.id, user.id, invitation.household_id
)
# Load household details for response
household = await households_repo.get_household_by_id(conn, invitation.household_id)
if not household:
return error_response(request, 404, "Household not found")
return AcceptInvitationResponse(
status="accepted",
household=HouseholdResponse.model_validate(household),
)