feat(api): add admin household management endpoints
This commit is contained in:
parent
4bfa4ea02d
commit
1d5630ed8c
3 changed files with 566 additions and 4 deletions
206
backend/innercontext/api/admin.py
Normal file
206
backend/innercontext/api/admin.py
Normal 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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
354
backend/tests/test_admin_households.py
Normal file
354
backend/tests/test_admin_households.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue