libra/views_api/admin.py
Padreug a7d7740a3a refactor(api): split views_api.py into a package of domain modules
Pure move — no logic changes. The 4,100-line single file becomes
views_api/ with one module per domain (accounts, entries, payments,
settings_reports, reconciliation, permissions, admin), each
registering full literal paths on its own APIRouter; __init__ builds
the combined libra_api_router so libra/__init__.py is untouched.
Shared imports/helpers live in views_api/_shared.py;
_extract_entry_id and _SYSTEM_LINK_PREFIXES move to
beancount_format.py (they are pure entry-dict parsing).

Route behavior is pinned by tests/test_route_table.py: the 73-route
set is unchanged, and the two order-sensitive families (the shadowed
/accounts/hierarchy wart — deliberately preserved here, fixed in the
next commit — and the admin sync literal/param pair) keep their
relative order inside a single module each.

Addresses CODE-REVIEW-2026-06 structure findings (views_api monolith).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 16:09:58 +02:00

535 lines
17 KiB
Python

"""Libra API — admin endpoints (moved verbatim from views_api.py)."""
from fastapi import APIRouter
from ._shared import * # noqa: F401,F403
from ._shared import _VALID_ACCOUNT_PREFIXES, _validate_account_name # noqa: F401
router = APIRouter()
@router.post("/api/v1/admin/accounts", status_code=HTTPStatus.CREATED)
async def api_admin_add_chart_account(
payload: CreateChartAccount,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Add a chart-of-accounts entry (super-user only).
Writes an Open directive to accounts/chart.beancount via Fava's /api/source,
then syncs the account into Libra's DB so permissions can be granted on it.
Per-user accounts (matching :User-xxxxxxxx) take a different code path via
crud.get_or_create_user_account and are not created through this endpoint.
"""
from ..fava_client import get_fava_client
if not payload.name.startswith(_VALID_ACCOUNT_PREFIXES):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"Account name must start with one of "
f"{', '.join(_VALID_ACCOUNT_PREFIXES)} (got {payload.name!r})"
),
)
_validate_account_name(payload.name)
logger.info(
f"Admin {auth.user_id[:8]} adding chart account {payload.name} "
f"with currencies {payload.currencies}"
)
fava = get_fava_client()
metadata: dict = {"added_by": auth.user_id[:8], "source": "admin-ui"}
if payload.description:
metadata["description"] = payload.description
result = await fava.add_account(
account_name=payload.name,
currencies=payload.currencies,
target_file="accounts/chart.beancount",
metadata=metadata,
)
from ..account_sync import sync_single_account_from_beancount
if result.get("already_existed"):
# The Open directive is already in the ledger. If it's also already
# mirrored into libra's DB, it's a true duplicate → 409. If not (a prior
# sync failed — there's no cross-DB atomicity — or it was opened out of
# band), mirror it now so it becomes grantable instead of being stranded
# with no recovery path.
from ..crud import get_account_by_name
if await get_account_by_name(payload.name) is not None:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail=f"Account {payload.name} already exists",
)
synced = await sync_single_account_from_beancount(payload.name)
return {
"success": True,
"account_name": payload.name,
"synced_to_libra_db": synced,
"already_existed": True,
}
# Mirror into libra DB so permissions / metadata layer sees it. We just
# wrote the Open directive ourselves, so skip the verification
# round-trip through Fava (libra-#53).
synced = await sync_single_account_from_beancount(
payload.name,
description=payload.description,
assume_exists=True,
)
return {
"success": True,
"account_name": payload.name,
"synced_to_libra_db": synced,
}
@router.post("/api/v1/admin/accounts/sync")
async def api_sync_all_accounts(
force_full_sync: bool = False,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Sync all accounts from Beancount to Libra DB (admin only).
This ensures Libra DB has metadata entries for all accounts that exist
in Beancount, enabling permissions and user associations to work properly.
Args:
force_full_sync: If True, re-check all accounts. If False, only add new ones.
Returns:
Sync statistics: {total_beancount_accounts, accounts_added, accounts_skipped, errors}
"""
from ..account_sync import sync_accounts_from_beancount
logger.info(f"Admin {auth.user_id[:8]} triggered account sync (force={force_full_sync})")
try:
stats = await sync_accounts_from_beancount(force_full_sync=force_full_sync)
logger.info(f"Account sync complete: {stats['accounts_added']} added, {stats['accounts_skipped']} skipped")
return stats
except Exception as e:
logger.error(f"Account sync failed: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Account sync failed: {str(e)}"
)
@router.post("/api/v1/admin/accounts/sync/{account_name:path}")
async def api_sync_single_account(
account_name: str,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Sync a single account from Beancount to Libra DB (admin only).
Useful for ensuring a specific account exists in Libra DB before
granting permissions on it.
Args:
account_name: Hierarchical account name (e.g., "Expenses:Food:Groceries")
Returns:
{success: bool, account_name: str, message: str}
"""
from ..account_sync import sync_single_account_from_beancount
logger.info(f"Admin {auth.user_id[:8]} triggered sync for account: {account_name}")
try:
created = await sync_single_account_from_beancount(account_name)
if created:
return {
"success": True,
"account_name": account_name,
"message": f"Account '{account_name}' synced successfully"
}
else:
return {
"success": False,
"account_name": account_name,
"message": f"Account '{account_name}' already exists or not found in Beancount"
}
except Exception as e:
logger.error(f"Single account sync failed for {account_name}: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Account sync failed: {str(e)}"
)
# ===== RBAC (ROLE-BASED ACCESS CONTROL) ENDPOINTS =====
@router.get("/api/v1/admin/roles")
async def api_get_all_roles(
auth: AuthContext = Depends(require_super_user),
) -> list:
"""Get all roles (admin only)"""
from .. import crud
roles = await crud.get_all_roles()
# Enrich each role with user count and permission count
enriched_roles = []
for role in roles:
user_count = await crud.get_user_count_for_role(role.id)
permissions = await crud.get_role_permissions(role.id)
enriched_roles.append({
"id": role.id,
"name": role.name,
"description": role.description,
"is_default": role.is_default,
"created_by": role.created_by,
"created_at": role.created_at.isoformat(),
"user_count": user_count,
"permission_count": len(permissions),
})
return enriched_roles
@router.post("/api/v1/admin/roles", status_code=HTTPStatus.CREATED)
async def api_create_role(
data: CreateRole,
auth: AuthContext = Depends(require_super_user),
):
"""Create a new role (admin only)"""
from .. import crud
try:
role = await crud.create_role(data, created_by=auth.user_id)
return {
"id": role.id,
"name": role.name,
"description": role.description,
"is_default": role.is_default,
"created_by": role.created_by,
"created_at": role.created_at.isoformat(),
}
except Exception as e:
logger.error(f"Failed to create role: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Failed to create role: {str(e)}"
)
@router.get("/api/v1/admin/roles/{role_id}")
async def api_get_role(
role_id: str,
auth: AuthContext = Depends(require_super_user),
):
"""Get a specific role with its permissions and users (admin only)"""
from .. import crud
role = await crud.get_role(role_id)
if not role:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Role {role_id} not found"
)
permissions = await crud.get_role_permissions(role.id)
user_roles = await crud.get_role_users(role.id)
return {
"id": role.id,
"name": role.name,
"description": role.description,
"is_default": role.is_default,
"created_by": role.created_by,
"created_at": role.created_at.isoformat(),
"permissions": [
{
"id": p.id,
"account_id": p.account_id,
"permission_type": p.permission_type.value,
"notes": p.notes,
"created_at": p.created_at.isoformat(),
}
for p in permissions
],
"users": [
{
"id": ur.id,
"user_id": ur.user_id,
"granted_by": ur.granted_by,
"granted_at": ur.granted_at.isoformat(),
"expires_at": ur.expires_at.isoformat() if ur.expires_at else None,
"notes": ur.notes,
}
for ur in user_roles
],
}
@router.put("/api/v1/admin/roles/{role_id}")
async def api_update_role(
role_id: str,
data: UpdateRole,
auth: AuthContext = Depends(require_super_user),
):
"""Update a role (admin only)"""
from .. import crud
role = await crud.update_role(role_id, data)
if not role:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Role {role_id} not found"
)
return {
"id": role.id,
"name": role.name,
"description": role.description,
"is_default": role.is_default,
"created_by": role.created_by,
"created_at": role.created_at.isoformat(),
}
@router.delete("/api/v1/admin/roles/{role_id}")
async def api_delete_role(
role_id: str,
auth: AuthContext = Depends(require_super_user),
):
"""Delete a role (admin only) - cascades to role_permissions and user_roles"""
from .. import crud
role = await crud.get_role(role_id)
if not role:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Role {role_id} not found"
)
await crud.delete_role(role_id)
return {"success": True, "message": f"Role '{role.name}' deleted successfully"}
# ===== ROLE PERMISSION ENDPOINTS =====
@router.post("/api/v1/admin/roles/{role_id}/permissions", status_code=HTTPStatus.CREATED)
async def api_add_role_permission(
role_id: str,
data: CreateRolePermission,
auth: AuthContext = Depends(require_super_user),
):
"""Add a permission to a role (admin only)"""
from .. import crud
# Verify role exists
role = await crud.get_role(role_id)
if not role:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Role {role_id} not found"
)
# Ensure data has correct role_id
data.role_id = role_id
try:
permission = await crud.create_role_permission(data)
return {
"id": permission.id,
"role_id": permission.role_id,
"account_id": permission.account_id,
"permission_type": permission.permission_type.value,
"notes": permission.notes,
"created_at": permission.created_at.isoformat(),
}
except Exception as e:
logger.error(f"Failed to add role permission: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Failed to add permission: {str(e)}"
)
@router.delete("/api/v1/admin/roles/{role_id}/permissions/{permission_id}")
async def api_delete_role_permission(
role_id: str,
permission_id: str,
auth: AuthContext = Depends(require_super_user),
):
"""Remove a permission from a role (admin only)"""
from .. import crud
await crud.delete_role_permission(permission_id)
return {"success": True, "message": "Permission removed from role"}
# ===== USER ROLE ASSIGNMENT ENDPOINTS =====
@router.post("/api/v1/admin/user-roles", status_code=HTTPStatus.CREATED)
async def api_assign_user_role(
data: AssignUserRole,
auth: AuthContext = Depends(require_super_user),
):
"""Assign a user to a role (admin only)"""
from .. import crud
# Verify role exists
role = await crud.get_role(data.role_id)
if not role:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Role {data.role_id} not found"
)
try:
user_role = await crud.assign_user_role(data, granted_by=auth.user_id)
return {
"id": user_role.id,
"user_id": user_role.user_id,
"role_id": user_role.role_id,
"granted_by": user_role.granted_by,
"granted_at": user_role.granted_at.isoformat(),
"expires_at": user_role.expires_at.isoformat() if user_role.expires_at else None,
"notes": user_role.notes,
}
except Exception as e:
logger.error(f"Failed to assign user role: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Failed to assign role: {str(e)}"
)
@router.get("/api/v1/admin/user-roles/{user_id}")
async def api_get_user_roles(
user_id: str,
auth: AuthContext = Depends(require_super_user),
):
"""Get all roles assigned to a user (admin only)"""
from .. import crud
user_roles = await crud.get_user_roles(user_id)
# Enrich with role details
enriched = []
for ur in user_roles:
role = await crud.get_role(ur.role_id)
if role:
enriched.append({
"user_role_id": ur.id,
"user_id": ur.user_id,
"role": {
"id": role.id,
"name": role.name,
"description": role.description,
"is_default": role.is_default,
},
"granted_by": ur.granted_by,
"granted_at": ur.granted_at.isoformat(),
"expires_at": ur.expires_at.isoformat() if ur.expires_at else None,
"notes": ur.notes,
})
return enriched
@router.delete("/api/v1/admin/user-roles/{user_role_id}")
async def api_revoke_user_role(
user_role_id: str,
auth: AuthContext = Depends(require_super_user),
):
"""Revoke a user's role assignment (admin only)"""
from .. import crud
await crud.revoke_user_role(user_role_id)
return {"success": True, "message": "Role assignment revoked"}
@router.get("/api/v1/admin/users/roles")
async def api_get_all_user_roles(
auth: AuthContext = Depends(require_super_user),
):
"""Get all user role assignments (admin only)"""
from .. import crud
user_roles = await crud.get_all_user_roles()
return [
{
"id": ur.id,
"user_id": ur.user_id,
"role_id": ur.role_id,
"granted_by": ur.granted_by,
"granted_at": ur.granted_at.isoformat(),
"expires_at": ur.expires_at.isoformat() if ur.expires_at else None,
"notes": ur.notes,
}
for ur in user_roles
]
@router.get("/api/v1/users/me/roles")
async def api_get_my_roles(
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
"""Get current user's roles and effective permissions"""
from .. import crud
user_id = wallet.wallet.user
# Get user's roles
user_roles = await crud.get_user_roles(user_id)
# Get permissions from roles
role_permissions_list = await crud.get_user_permissions_from_roles(user_id)
# Get direct permissions
direct_permissions = await crud.get_user_permissions(user_id)
# Build response
roles_data = []
for ur in user_roles:
role = await crud.get_role(ur.role_id)
if role:
permissions = await crud.get_role_permissions(role.id)
roles_data.append({
"role": {
"id": role.id,
"name": role.name,
"description": role.description,
},
"permissions": [
{
"account_id": p.account_id,
"permission_type": p.permission_type.value,
}
for p in permissions
],
"granted_at": ur.granted_at.isoformat(),
"expires_at": ur.expires_at.isoformat() if ur.expires_at else None,
})
return {
"roles": roles_data,
"direct_permissions": [
{
"id": p.id,
"account_id": p.account_id,
"permission_type": p.permission_type.value,
"granted_at": p.granted_at.isoformat(),
"expires_at": p.expires_at.isoformat() if p.expires_at else None,
"notes": p.notes,
}
for p in direct_permissions
],
}