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:
parent
9e06fa0b2a
commit
a7d7740a3a
12 changed files with 4233 additions and 4130 deletions
328
views_api/accounts.py
Normal file
328
views_api/accounts.py
Normal 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 =====
|
||||
Loading…
Add table
Add a link
Reference in a new issue