feat(api): add admin household management endpoints

This commit is contained in:
Piotr Oleszczyk 2026-03-12 16:02:11 +01:00
parent 4bfa4ea02d
commit 1d5630ed8c
3 changed files with 566 additions and 4 deletions

View file

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

View file

@ -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",

View file

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