From 1d5630ed8c715bba0b6ef2e819f70a87ca59f4e8 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Thu, 12 Mar 2026 16:02:11 +0100 Subject: [PATCH] feat(api): add admin household management endpoints --- backend/innercontext/api/admin.py | 206 ++++++++++++++ backend/main.py | 10 +- backend/tests/test_admin_households.py | 354 +++++++++++++++++++++++++ 3 files changed, 566 insertions(+), 4 deletions(-) create mode 100644 backend/innercontext/api/admin.py create mode 100644 backend/tests/test_admin_households.py diff --git a/backend/innercontext/api/admin.py b/backend/innercontext/api/admin.py new file mode 100644 index 0000000..0a5c1fd --- /dev/null +++ b/backend/innercontext/api/admin.py @@ -0,0 +1,206 @@ +from datetime import datetime +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlmodel import Session, SQLModel, select + +from db import get_session +from innercontext.api.auth_deps import require_admin +from innercontext.api.utils import get_or_404 +from innercontext.models import ( + Household, + HouseholdMembership, + HouseholdRole, + Role, + User, +) + +router = APIRouter(dependencies=[Depends(require_admin)]) +SessionDep = Annotated[Session, Depends(get_session)] + + +class AdminHouseholdPublic(SQLModel): + id: UUID + created_at: datetime + updated_at: datetime + + +class AdminHouseholdMembershipPublic(SQLModel): + id: UUID + user_id: UUID + household_id: UUID + role: HouseholdRole + created_at: datetime + updated_at: datetime + + +class AdminUserPublic(SQLModel): + id: UUID + oidc_issuer: str + oidc_subject: str + role: Role + created_at: datetime + updated_at: datetime + household_membership: AdminHouseholdMembershipPublic | None = None + + +class AdminHouseholdMembershipCreate(SQLModel): + user_id: UUID + role: HouseholdRole = HouseholdRole.MEMBER + + +def _membership_public( + membership: HouseholdMembership, +) -> AdminHouseholdMembershipPublic: + return AdminHouseholdMembershipPublic( + id=membership.id, + user_id=membership.user_id, + household_id=membership.household_id, + role=membership.role, + created_at=membership.created_at, + updated_at=membership.updated_at, + ) + + +def _household_public(household: Household) -> AdminHouseholdPublic: + return AdminHouseholdPublic( + id=household.id, + created_at=household.created_at, + updated_at=household.updated_at, + ) + + +def _user_public( + user: User, + membership: HouseholdMembership | None, +) -> AdminUserPublic: + return AdminUserPublic( + id=user.id, + oidc_issuer=user.oidc_issuer, + oidc_subject=user.oidc_subject, + role=user.role, + created_at=user.created_at, + updated_at=user.updated_at, + household_membership=( + _membership_public(membership) if membership is not None else None + ), + ) + + +def _get_membership_for_user( + session: Session, + user_id: UUID, +) -> HouseholdMembership | None: + return session.exec( + select(HouseholdMembership).where(HouseholdMembership.user_id == user_id) + ).first() + + +@router.get("/users", response_model=list[AdminUserPublic]) +def list_users(session: SessionDep): + users = sorted( + session.exec(select(User)).all(), + key=lambda user: (user.created_at, str(user.id)), + ) + memberships = session.exec(select(HouseholdMembership)).all() + memberships_by_user_id = { + membership.user_id: membership for membership in memberships + } + return [_user_public(user, memberships_by_user_id.get(user.id)) for user in users] + + +@router.post( + "/households", + response_model=AdminHouseholdPublic, + status_code=status.HTTP_201_CREATED, +) +def create_household(session: SessionDep): + household = Household() + session.add(household) + session.commit() + session.refresh(household) + return _household_public(household) + + +@router.post( + "/households/{household_id}/members", + response_model=AdminHouseholdMembershipPublic, + status_code=status.HTTP_201_CREATED, +) +def assign_household_member( + household_id: UUID, + payload: AdminHouseholdMembershipCreate, + session: SessionDep, +): + _ = get_or_404(session, Household, household_id) + _ = get_or_404(session, User, payload.user_id) + existing_membership = _get_membership_for_user(session, payload.user_id) + if existing_membership is not None: + detail = "User already belongs to a household" + if existing_membership.household_id == household_id: + detail = "User already belongs to this household" + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail) + + membership = HouseholdMembership( + user_id=payload.user_id, + household_id=household_id, + role=payload.role, + ) + session.add(membership) + session.commit() + session.refresh(membership) + return _membership_public(membership) + + +@router.patch( + "/households/{household_id}/members/{user_id}", + response_model=AdminHouseholdMembershipPublic, +) +def move_household_member( + household_id: UUID, + user_id: UUID, + session: SessionDep, +): + _ = get_or_404(session, Household, household_id) + _ = get_or_404(session, User, user_id) + membership = _get_membership_for_user(session, user_id) + if membership is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="HouseholdMembership not found", + ) + if membership.household_id == household_id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User already belongs to this household", + ) + + membership.household_id = household_id + session.add(membership) + session.commit() + session.refresh(membership) + return _membership_public(membership) + + +@router.delete( + "/households/{household_id}/members/{user_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def remove_household_member( + household_id: UUID, + user_id: UUID, + session: SessionDep, +): + _ = get_or_404(session, Household, household_id) + _ = get_or_404(session, User, user_id) + membership = _get_membership_for_user(session, user_id) + if membership is None or membership.household_id != household_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="HouseholdMembership not found", + ) + + session.delete(membership) + session.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/main.py b/backend/main.py index 55280aa..3d74443 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,9 +1,9 @@ +from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from typing import AsyncIterator from dotenv import load_dotenv -load_dotenv() # load .env before db.py reads DATABASE_URL +_ = load_dotenv() # load .env before db.py reads DATABASE_URL from fastapi import Depends, FastAPI # noqa: E402 from fastapi.middleware.cors import CORSMiddleware # noqa: E402 @@ -11,6 +11,7 @@ from sqlmodel import Session # noqa: E402 from db import create_db_and_tables, engine # noqa: E402 from innercontext.api import ( # noqa: E402 + admin, ai_logs, auth, health, @@ -25,11 +26,11 @@ from innercontext.services.pricing_jobs import enqueue_pricing_recalc # noqa: E @asynccontextmanager -async def lifespan(app: FastAPI) -> AsyncIterator[None]: +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: create_db_and_tables() try: with Session(engine) as session: - enqueue_pricing_recalc(session) + _ = enqueue_pricing_recalc(session) session.commit() except Exception as exc: # pragma: no cover print(f"[startup] failed to enqueue pricing recalculation job: {exc}") @@ -52,6 +53,7 @@ app.add_middleware( protected = [Depends(get_current_user)] app.include_router(auth.router, prefix="/auth", tags=["auth"]) +app.include_router(admin.router, prefix="/admin", tags=["admin"]) app.include_router( products.router, prefix="/products", diff --git a/backend/tests/test_admin_households.py b/backend/tests/test_admin_households.py new file mode 100644 index 0000000..31b1f16 --- /dev/null +++ b/backend/tests/test_admin_households.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +from collections.abc import Generator +from datetime import UTC, datetime, timedelta +from typing import cast +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session + +from db import get_session +from innercontext.api.auth_deps import get_current_user +from innercontext.auth import ( + CurrentHouseholdMembership, + CurrentUser, + IdentityData, + TokenClaims, +) +from innercontext.models import ( + Household, + HouseholdMembership, + HouseholdRole, + Role, + User, +) +from main import app + + +def _current_user( + user_id: UUID, + *, + role: Role = Role.ADMIN, + household_id: UUID | None = None, +) -> CurrentUser: + claims = TokenClaims( + issuer="https://auth.test", + subject=str(user_id), + audience=("innercontext-web",), + expires_at=datetime.now(UTC) + timedelta(hours=1), + groups=( + ("innercontext-admin",) if role is Role.ADMIN else ("innercontext-member",) + ), + raw_claims={"iss": "https://auth.test", "sub": str(user_id)}, + ) + membership = None + if household_id is not None: + membership = CurrentHouseholdMembership( + household_id=household_id, + role=HouseholdRole.MEMBER, + ) + return CurrentUser( + user_id=user_id, + role=role, + identity=IdentityData.from_claims(claims), + claims=claims, + household_membership=membership, + ) + + +def _create_user( + session: Session, + *, + role: Role = Role.MEMBER, + subject: str | None = None, +) -> User: + user = User( + oidc_issuer="https://auth.test", + oidc_subject=subject or str(uuid4()), + role=role, + ) + session.add(user) + session.commit() + session.refresh(user) + return user + + +def _create_household(session: Session) -> Household: + household = Household() + session.add(household) + session.commit() + session.refresh(household) + return household + + +def _create_membership( + session: Session, + *, + user_id: UUID, + household_id: UUID, + role: HouseholdRole = HouseholdRole.MEMBER, +) -> HouseholdMembership: + membership = HouseholdMembership( + user_id=user_id, + household_id=household_id, + role=role, + ) + session.add(membership) + session.commit() + session.refresh(membership) + return membership + + +@pytest.fixture() +def auth_client( + session: Session, +) -> Generator[tuple[TestClient, dict[str, CurrentUser]], None, None]: + auth_state = {"current_user": _current_user(uuid4(), role=Role.ADMIN)} + + def _session_override(): + yield session + + def _current_user_override(): + return auth_state["current_user"] + + app.dependency_overrides[get_session] = _session_override + app.dependency_overrides[get_current_user] = _current_user_override + with TestClient(app) as client: + yield client, auth_state + app.dependency_overrides.clear() + + +def test_list_users_returns_local_users_with_memberships( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + + unassigned_user = _create_user(session, subject="member-a") + assigned_user = _create_user(session, subject="member-b") + household = _create_household(session) + membership = _create_membership( + session, + user_id=assigned_user.id, + household_id=household.id, + role=HouseholdRole.OWNER, + ) + + response = client.get("/admin/users") + + assert response.status_code == 200 + response_users = cast(list[dict[str, object]], response.json()) + users = {item["id"]: item for item in response_users} + assert users[str(unassigned_user.id)]["household_membership"] is None + assert users[str(assigned_user.id)]["household_membership"] == { + "id": str(membership.id), + "user_id": str(assigned_user.id), + "household_id": str(household.id), + "role": "owner", + "created_at": membership.created_at.isoformat(), + "updated_at": membership.updated_at.isoformat(), + } + + +def test_create_household_returns_new_household( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + + response = client.post("/admin/households") + + assert response.status_code == 201 + payload = cast(dict[str, object], response.json()) + household_id = UUID(cast(str, payload["id"])) + created = session.get(Household, household_id) + assert created is not None + + +def test_assign_member_creates_membership( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + + user = _create_user(session) + household = _create_household(session) + + response = client.post( + f"/admin/households/{household.id}/members", + json={"user_id": str(user.id), "role": "owner"}, + ) + + assert response.status_code == 201 + payload = cast(dict[str, object], response.json()) + assert payload["user_id"] == str(user.id) + assert payload["household_id"] == str(household.id) + assert payload["role"] == "owner" + + membership = session.get(HouseholdMembership, UUID(cast(str, payload["id"]))) + assert membership is not None + assert membership.user_id == user.id + assert membership.household_id == household.id + assert membership.role is HouseholdRole.OWNER + + +def test_assign_member_rejects_already_assigned_user( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + + user = _create_user(session) + current_household = _create_household(session) + target_household = _create_household(session) + _ = _create_membership(session, user_id=user.id, household_id=current_household.id) + + response = client.post( + f"/admin/households/{target_household.id}/members", + json={"user_id": str(user.id)}, + ) + + assert response.status_code == 409 + assert response.json()["detail"] == "User already belongs to a household" + + +def test_assign_member_rejects_unsynced_user( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + household = _create_household(session) + user_id = uuid4() + + response = client.post( + f"/admin/households/{household.id}/members", + json={"user_id": str(user_id)}, + ) + + assert response.status_code == 404 + assert response.json()["detail"] == "User not found" + + +def test_move_member_moves_user_between_households( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + + user = _create_user(session) + source_household = _create_household(session) + target_household = _create_household(session) + membership = _create_membership( + session, + user_id=user.id, + household_id=source_household.id, + role=HouseholdRole.OWNER, + ) + + response = client.patch( + f"/admin/households/{target_household.id}/members/{user.id}" + ) + + assert response.status_code == 200 + payload = cast(dict[str, object], response.json()) + assert payload["id"] == str(membership.id) + assert payload["household_id"] == str(target_household.id) + assert payload["role"] == "owner" + + session.refresh(membership) + assert membership.household_id == target_household.id + + +def test_move_member_rejects_user_without_membership( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + + user = _create_user(session) + target_household = _create_household(session) + + response = client.patch( + f"/admin/households/{target_household.id}/members/{user.id}" + ) + + assert response.status_code == 404 + assert response.json()["detail"] == "HouseholdMembership not found" + + +def test_move_member_rejects_same_household_target( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + + user = _create_user(session) + household = _create_household(session) + _ = _create_membership(session, user_id=user.id, household_id=household.id) + + response = client.patch(f"/admin/households/{household.id}/members/{user.id}") + + assert response.status_code == 409 + assert response.json()["detail"] == "User already belongs to this household" + + +def test_remove_membership_deletes_membership( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + + user = _create_user(session) + household = _create_household(session) + membership = _create_membership(session, user_id=user.id, household_id=household.id) + + response = client.delete(f"/admin/households/{household.id}/members/{user.id}") + + assert response.status_code == 204 + assert session.get(HouseholdMembership, membership.id) is None + + +def test_remove_membership_requires_matching_household( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + session: Session, +): + client, _ = auth_client + + user = _create_user(session) + household = _create_household(session) + other_household = _create_household(session) + _ = _create_membership(session, user_id=user.id, household_id=household.id) + + response = client.delete( + f"/admin/households/{other_household.id}/members/{user.id}" + ) + + assert response.status_code == 404 + assert response.json()["detail"] == "HouseholdMembership not found" + + +@pytest.mark.parametrize( + ("method", "path", "json_body"), + [ + ("get", "/admin/users", None), + ("post", "/admin/households", None), + ("post", f"/admin/households/{uuid4()}/members", {"user_id": str(uuid4())}), + ("patch", f"/admin/households/{uuid4()}/members/{uuid4()}", None), + ("delete", f"/admin/households/{uuid4()}/members/{uuid4()}", None), + ], +) +def test_admin_household_routes_forbidden_for_member( + auth_client: tuple[TestClient, dict[str, CurrentUser]], + method: str, + path: str, + json_body: dict[str, str] | None, +): + client, auth_state = auth_client + auth_state["current_user"] = _current_user(uuid4(), role=Role.MEMBER) + + response = client.request(method, path, json=json_body) + + assert response.status_code == 403 + assert response.json()["detail"] == "Admin role required"