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

View file

@ -0,0 +1,399 @@
"""Libra API — settings reports endpoints (moved verbatim from views_api.py)."""
from fastapi import APIRouter
from ._shared import * # noqa: F401,F403
router = APIRouter()
@router.get("/api/v1/settings")
async def api_get_settings(
user: User = Depends(check_user_exists),
) -> LibraSettings:
"""Get Libra settings"""
user_id = "admin"
settings = await get_settings(user_id)
# Return empty settings if not configured (so UI can show setup screen)
if not settings:
return LibraSettings()
return settings
@router.put("/api/v1/settings")
async def api_update_settings(
data: LibraSettings,
user: User = Depends(check_super_user),
) -> LibraSettings:
"""Update Libra settings (super user only)"""
if not data.libra_wallet_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Libra wallet ID is required",
)
user_id = "admin"
return await update_settings(user_id, data)
# ===== USER WALLET ENDPOINTS =====
@router.get("/api/v1/user-wallet/{user_id}")
async def api_get_user_wallet(
user_id: str,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Get user's wallet settings (super user only)
Supports both full UUIDs and truncated 8-char IDs (from Beancount accounts).
"""
from ..crud import get_user_wallet_settings_by_prefix
# First try exact match
user_wallet = await get_user_wallet(user_id)
# If not found and user_id looks like a truncated ID (8 chars), try prefix match
if not user_wallet or not user_wallet.user_wallet_id:
if len(user_id) <= 8:
stored_wallet = await get_user_wallet_settings_by_prefix(user_id)
if stored_wallet and stored_wallet.user_wallet_id:
user_wallet = stored_wallet
user_id = stored_wallet.id # Use the full ID
if not user_wallet or not user_wallet.user_wallet_id:
return {"user_id": user_id, "user_wallet_id": None}
# Get invoice key for the user's wallet (needed to generate invoices)
from lnbits.core.crud import get_wallet
wallet_obj = await get_wallet(user_wallet.user_wallet_id)
if not wallet_obj:
return {"user_id": user_id, "user_wallet_id": user_wallet.user_wallet_id}
return {
"user_id": user_id,
"user_wallet_id": user_wallet.user_wallet_id,
"user_wallet_id_invoice_key": wallet_obj.inkey,
}
@router.get("/api/v1/users")
async def api_get_all_users(
auth: AuthContext = Depends(require_super_user),
) -> list[dict]:
"""Get all users who have configured their wallet (super user only)"""
from lnbits.core.crud.users import get_user
user_settings = await get_all_user_wallet_settings()
users = []
for setting in user_settings:
# Get user details from core
user = await get_user(setting.id)
# Use username if available, otherwise truncate user_id
username = user.username if user and user.username else setting.id[:16] + "..."
users.append({
"user_id": setting.id,
"user_wallet_id": setting.user_wallet_id,
"username": username,
})
return users
@router.get("/api/v1/admin/libra-users")
async def api_get_libra_users(
auth: AuthContext = Depends(require_super_user),
) -> list[dict]:
"""
Get all users who have configured their wallet in Libra.
These are users who can interact with Libra (submit expenses, receive permissions, etc.).
Super user only.
"""
from lnbits.core.crud.users import get_user
# Get all users who have configured their wallet
user_settings = await get_all_user_wallet_settings()
users = []
for setting in user_settings:
# Get user details from core
user = await get_user(setting.id)
# Use username if available, otherwise use user_id
username = user.username if user and user.username else None
users.append({
"id": setting.id,
"user_id": setting.id, # Compatibility with existing code
"username": username,
"user_wallet_id": setting.user_wallet_id,
})
# Sort by username (None values last)
users.sort(key=lambda x: (x["username"] is None, x["username"] or "", x["user_id"]))
return users
@router.get("/api/v1/reports/expenses")
async def api_expense_report(
start_date: Optional[str] = None,
end_date: Optional[str] = None,
group_by: str = "account",
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Get expense summary report using BQL. Super user only.
Args:
start_date: Filter from this date (YYYY-MM-DD), optional
end_date: Filter to this date (YYYY-MM-DD), optional
group_by: "account" (by expense category) or "month" (by month)
Returns:
{
"summary": [
{"account": "Expenses:Supplies:Food", "fiat": 500.00, "sats": 550000},
...
],
"total_fiat": 1500.00,
"total_sats": 1650000,
"fiat_currency": "EUR",
"group_by": "account",
"start_date": "2025-01-01",
"end_date": "2025-12-31"
}
Admin only.
"""
from ..fava_client import get_fava_client
if group_by not in ["account", "month"]:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="group_by must be 'account' or 'month'"
)
fava = get_fava_client()
summaries = await fava.get_expense_summary_bql(
start_date=start_date,
end_date=end_date,
group_by=group_by
)
# Calculate totals
total_fiat = sum(s.get("fiat", 0) for s in summaries)
total_sats = sum(s.get("sats", 0) for s in summaries)
return {
"summary": summaries,
"total_fiat": total_fiat,
"total_sats": total_sats,
"fiat_currency": "EUR",
"group_by": group_by,
"start_date": start_date,
"end_date": end_date,
"count": len(summaries)
}
@router.get("/api/v1/reports/contributions")
async def api_contributions_report(
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Get user contribution report using BQL.
Shows total expenses submitted by each user (creating payables).
Returns:
{
"contributions": [
{
"user_id": "cfe378b3",
"username": "alice",
"total_fiat": 1500.00,
"total_sats": 1650000,
"entry_count": 25
},
...
],
"total_fiat": 5000.00,
"total_sats": 5500000,
"fiat_currency": "EUR",
"user_count": 5
}
Admin only.
"""
from lnbits.core.crud.users import get_user
from ..fava_client import get_fava_client
fava = get_fava_client()
contributions = await fava.get_user_contributions_bql()
# Enrich with usernames
for contrib in contributions:
user_id = contrib["user_id"]
# Try to find full user_id from wallet settings
settings = await get_all_user_wallet_settings()
full_user_id = None
for s in settings:
if s.id.startswith(user_id):
full_user_id = s.id
break
if full_user_id:
user = await get_user(full_user_id)
contrib["username"] = user.username if user and user.username else None
contrib["full_user_id"] = full_user_id
else:
contrib["username"] = None
contrib["full_user_id"] = None
# Calculate totals
total_fiat = sum(c.get("total_fiat", 0) for c in contributions)
total_sats = sum(c.get("total_sats", 0) for c in contributions)
return {
"contributions": contributions,
"total_fiat": total_fiat,
"total_sats": total_sats,
"fiat_currency": "EUR",
"user_count": len(contributions)
}
@router.get("/api/v1/users/{user_id}/unsettled-entries")
async def api_get_unsettled_entries(
user_id: str,
entry_type: str = "expense",
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Get unsettled expense or receivable entries for a user.
Returns entries that have unique links (exp-xxx or rcv-xxx) which
have not yet appeared in a settlement transaction.
Args:
user_id: The user's ID
entry_type: "expense" (payables - libra owes user) or
"receivable" (user owes libra)
Returns:
{
"user_id": "abc123...",
"entry_type": "expense",
"unsettled_entries": [
{
"link": "exp-abc123",
"date": "2025-12-01",
"narration": "Groceries at Biocoop",
"fiat_amount": 50.00,
"fiat_currency": "EUR",
"sats_amount": 47000,
"flag": "!" # pending or "*" cleared
},
...
],
"total_fiat": 150.00,
"total_fiat_currency": "EUR",
"total_sats": 141000,
"count": 3
}
Admin only - used when settling user balances.
"""
from ..fava_client import get_fava_client
if entry_type not in ["expense", "receivable"]:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="entry_type must be 'expense' or 'receivable'"
)
fava = get_fava_client()
unsettled = await fava.get_unsettled_entries_bql(user_id, entry_type)
# Calculate totals
total_fiat = sum(e.get("fiat_amount", 0) for e in unsettled)
total_sats = sum(e.get("sats_amount", 0) for e in unsettled)
# Get currency (assume all same currency for a user)
fiat_currency = unsettled[0].get("fiat_currency", "EUR") if unsettled else "EUR"
return {
"user_id": user_id,
"entry_type": entry_type,
"unsettled_entries": unsettled,
"total_fiat": total_fiat,
"total_fiat_currency": fiat_currency,
"total_sats": total_sats,
"count": len(unsettled)
}
@router.get("/api/v1/user/wallet")
async def api_get_user_wallet(
user: User = Depends(check_user_exists),
) -> UserWalletSettings:
"""Get current user's wallet settings"""
from lnbits.settings import settings as lnbits_settings
# If user is super user, return the libra wallet
if user.id == lnbits_settings.super_user:
libra_settings = await get_settings("admin")
if libra_settings and libra_settings.libra_wallet_id:
return UserWalletSettings(user_wallet_id=libra_settings.libra_wallet_id)
return UserWalletSettings()
# For regular users, get their personal wallet
settings = await get_user_wallet(user.id)
# Return empty settings if not configured (so UI can show setup screen)
if not settings:
return UserWalletSettings()
return settings
@router.put("/api/v1/user/wallet")
async def api_update_user_wallet(
data: UserWalletSettings,
user: User = Depends(check_user_exists),
) -> UserWalletSettings:
"""Update current user's wallet settings"""
from lnbits.settings import settings as lnbits_settings
# Super user cannot set their wallet separately - it's always the libra wallet
if user.id == lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Super user wallet is automatically set to the Libra wallet. Update Libra settings instead.",
)
if not data.user_wallet_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="User wallet ID is required",
)
return await update_user_wallet(user.id, data)
# ===== MANUAL PAYMENT REQUESTS =====
@router.get("/api/v1/user/info")
async def api_get_user_info(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> UserInfo:
"""Get current user's information including equity eligibility"""
from ..crud import get_user_equity_status
from ..models import UserInfo
equity_status = await get_user_equity_status(wallet.wallet.user)
return UserInfo(
user_id=wallet.wallet.user,
is_equity_eligible=equity_status.is_equity_eligible if equity_status else False,
equity_account_name=equity_status.equity_account_name if equity_status else None,
)