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>
This commit is contained in:
Padreug 2026-07-12 16:09:58 +02:00
commit a7d7740a3a
12 changed files with 4233 additions and 4130 deletions

328
views_api/accounts.py Normal file
View file

@ -0,0 +1,328 @@
"""Libra API — accounts endpoints (moved verbatim from views_api.py)."""
from fastapi import APIRouter
from ._shared import * # noqa: F401,F403
router = APIRouter()
@router.get("/api/v1/currencies")
async def api_get_currencies() -> list[str]:
"""Get list of allowed currencies for fiat conversion"""
return allowed_currencies()
# ===== ACCOUNT ENDPOINTS =====
@router.get("/api/v1/accounts")
async def api_get_accounts(
filter_by_user: bool = False,
exclude_virtual: bool = True,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> list[Account] | list[AccountWithPermissions]:
"""
Get all accounts in the chart of accounts.
- filter_by_user: If true, only return accounts the user has permissions for
- exclude_virtual: If true, exclude virtual parent accounts (default True)
- Returns AccountWithPermissions objects when filter_by_user=true, otherwise Account objects
"""
from lnbits.settings import settings as lnbits_settings
from .. import crud
all_accounts = await get_all_accounts()
user_id = wallet.wallet.user
is_super_user = user_id == lnbits_settings.super_user
# Auto-assign default role if user has no roles (only for non-super users)
if not is_super_user:
assigned_role = await crud.auto_assign_default_role(user_id, "system")
if assigned_role:
logger.info(f"[ACCOUNTS] Auto-assigned role to user {user_id}")
# Super users bypass permission filtering - they see everything
if not filter_by_user or is_super_user:
# Filter out virtual accounts if requested (default behavior for user views)
if exclude_virtual:
all_accounts = [acc for acc in all_accounts if not acc.is_virtual]
# Return all accounts without filtering by permissions
return all_accounts
# Filter by user permissions
# NOTE: Do NOT filter out virtual accounts yet - they're needed for inheritance logic
# Get direct user permissions
user_permissions = await get_user_permissions(user_id)
# Get role-based permissions
role_permissions_list = await crud.get_user_permissions_from_roles(user_id)
# Flatten role permissions into a single list
role_perms = []
for role, perms in role_permissions_list:
role_perms.extend(perms)
# Combine direct and role-based permissions
all_permissions = list(user_permissions) + role_perms
logger.info(f"[ACCOUNTS] User {user_id} has {len(user_permissions)} direct permissions and {len(role_perms)} role permissions (total: {len(all_permissions)})")
if role_perms:
logger.info(f"[ACCOUNTS] Role permissions: {[(p.account_id, p.permission_type) for p in role_perms]}")
logger.info(f"[ACCOUNTS] Total accounts in system: {len(all_accounts)}")
if len(all_accounts) > 0:
logger.info(f"[ACCOUNTS] Sample account IDs: {[acc.id for acc in all_accounts[:5]]}")
# Get set of account IDs the user has any permission on
permitted_account_ids = {perm.account_id for perm in all_permissions}
# Build list of accounts with permission metadata
accounts_with_permissions = []
for account in all_accounts:
# Check if user has permission on this account (direct or from role)
account_perms = [
perm for perm in all_permissions if perm.account_id == account.id
]
# Check if user has inherited permission from parent account (using combined permissions)
# Check both direct and role-based permissions for parent accounts
inherited_perms = []
for perm in all_permissions:
# Get the account for this permission
perm_account = await get_account(perm.account_id)
if not perm_account:
continue
# Check if this permission's account is a parent of the current account
# e.g., "Expenses:Supplies" is parent of "Expenses:Supplies:Food"
if account.name.startswith(perm_account.name + ":"):
# Inherited permission from parent account
inherited_perms.append((perm, perm_account.name))
# Determine if account should be included
has_access = bool(account_perms) or bool(inherited_perms)
if has_access:
# Parse hierarchical account name to get parent and level
parts = account.name.split(":")
level = len(parts) - 1
parent_account = ":".join(parts[:-1]) if level > 0 else None
# Determine inherited_from (which parent account gave access)
inherited_from = None
if inherited_perms and not account_perms:
# Permission is inherited, use the parent account name
_, parent_name = inherited_perms[0]
inherited_from = parent_name
# Collect permission types for this account
permission_types = [perm.permission_type for perm in account_perms]
# Check if account has children
has_children = any(
a.name.startswith(account.name + ":") for a in all_accounts
)
accounts_with_permissions.append(
AccountWithPermissions(
id=account.id,
name=account.name,
account_type=account.account_type,
description=account.description,
user_id=account.user_id,
created_at=account.created_at,
is_active=account.is_active,
is_virtual=account.is_virtual,
user_permissions=permission_types if permission_types else None,
inherited_from=inherited_from,
parent_account=parent_account,
level=level,
has_children=has_children,
)
)
# Filter out virtual accounts if requested (after permission inheritance logic)
if exclude_virtual:
accounts_with_permissions = [
acc for acc in accounts_with_permissions if not acc.is_virtual
]
logger.info(f"[ACCOUNTS] Returning {len(accounts_with_permissions)} accounts for user {user_id}")
return accounts_with_permissions
@router.post("/api/v1/accounts", status_code=HTTPStatus.CREATED)
async def api_create_account(
data: CreateAccount,
auth: AuthContext = Depends(require_super_user),
) -> Account:
"""Create a new account (super user only)"""
from ..crud import AccountExistsError
try:
return await create_account(data)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(e)
)
except AccountExistsError:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail=f"Account {data.name} already exists",
)
@router.get("/api/v1/accounts/{account_id}")
async def api_get_account(
account_id: str,
auth: AuthContext = Depends(require_authenticated),
) -> Account:
"""Get a specific account (requires authentication and account access)"""
account = await get_account(account_id)
if not account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Account not found"
)
# Check access permission
await require_account_access(auth, account_id, PermissionType.READ)
return account
@router.get("/api/v1/accounts/{account_id}/balance")
async def api_get_account_balance(
account_id: str,
auth: AuthContext = Depends(require_authenticated),
) -> dict:
"""Get account balance from Fava/Beancount (requires authentication and account access)"""
from ..fava_client import get_fava_client
# Get account to retrieve its name
account = await get_account(account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
# Check access permission
await require_account_access(auth, account_id, PermissionType.READ)
# Query Fava for balance
fava = get_fava_client()
balance_data = await fava.get_account_balance(account.name)
return {
"account_id": account_id,
"balance": balance_data["sats"], # Balance in satoshis
"fiat": float(balance_data.get("fiat", 0)), # Fiat amount
"fiat_currency": balance_data.get("fiat_currency", "EUR")
}
@router.get("/api/v1/accounts/{account_id}/transactions")
async def api_get_account_transactions(
account_id: str,
limit: int = 100,
auth: AuthContext = Depends(require_authenticated),
) -> list[dict]:
"""
Get all transactions for an account from Fava/Beancount.
Returns transactions affecting this account in reverse chronological order.
Requires authentication and account access.
"""
from ..fava_client import get_fava_client
# Get account details
account = await get_account(account_id)
if not account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Account {account_id} not found"
)
# Check access permission
await require_account_access(auth, account_id, PermissionType.READ)
# Query Fava for transactions
fava = get_fava_client()
transactions = await fava.get_account_transactions(account.name, limit)
return transactions
# ===== JOURNAL ENTRY ENDPOINTS =====
@router.get("/api/v1/accounts/hierarchy")
async def api_get_account_hierarchy(
root_account: str | None = None,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> list[AccountWithPermissions]:
"""
Get hierarchical account structure with user permissions.
Optionally filter by root account (e.g., "Expenses" to get all expense sub-accounts).
"""
all_accounts = await get_all_accounts()
user_id = wallet.wallet.user
user_permissions = await get_user_permissions(user_id)
# Filter by root account if specified
if root_account:
all_accounts = [
acc for acc in all_accounts
if acc.name == root_account or acc.name.startswith(root_account + ":")
]
# Build hierarchy with permission metadata
accounts_with_hierarchy = []
for account in all_accounts:
# Check if user has direct permission on this account
account_perms = [
perm for perm in user_permissions if perm.account_id == account.id
]
# Check if user has inherited permission from parent account
inherited_perms = await get_user_permissions_with_inheritance(
user_id, account.name, PermissionType.READ
)
# Parse hierarchical account name to get parent and level
parts = account.name.split(":")
level = len(parts) - 1
parent_account = ":".join(parts[:-1]) if level > 0 else None
# Determine inherited_from (which parent account gave access)
inherited_from = None
if inherited_perms and not account_perms:
# Permission is inherited, use the parent account name
_, parent_name = inherited_perms[0]
inherited_from = parent_name
# Collect permission types for this account
permission_types = [perm.permission_type for perm in account_perms]
# Check if account has children
has_children = any(
a.name.startswith(account.name + ":") for a in all_accounts
)
accounts_with_hierarchy.append(
AccountWithPermissions(
id=account.id,
name=account.name,
account_type=account.account_type,
description=account.description,
user_id=account.user_id,
created_at=account.created_at,
user_permissions=permission_types if permission_types else None,
inherited_from=inherited_from,
parent_account=parent_account,
level=level,
has_children=has_children,
)
)
# Sort by hierarchical name for natural ordering
accounts_with_hierarchy.sort(key=lambda a: a.name)
return accounts_with_hierarchy
# ===== ACCOUNT SYNC ENDPOINTS =====