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>
535 lines
17 KiB
Python
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
|
|
],
|
|
}
|