Files
finance-dashboard/app/api/routes.py
T

156 lines
7.2 KiB
Python

"""Authenticated REST adapters around the same services used by Jinja pages."""
from decimal import Decimal, ROUND_HALF_UP
import sqlite3
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from database import connect
from models import CATEGORIES, decimal_string
from services import asset_service, income_service
from api.auth import require_token
from api.schemas import (
AssetCreate, AssetPatch, AssetResponse, AssetShareResponse, Category,
CategoryShareResponse, IncomeCreate, IncomePatch, IncomeResponse,
MetaResponse, MonthlyResponse, SummaryResponse,
)
class SafeAPIRoute(APIRoute):
def get_route_handler(self):
original = super().get_route_handler()
async def safe_handler(request):
try:
return await original(request)
except HTTPException:
raise
except RequestValidationError as error:
# Never echo raw request bodies, credentials or internal exception context.
details = [{'loc': e['loc'], 'msg': e['msg'], 'type': e['type']} for e in error.errors()]
return JSONResponse({'detail': details}, status_code=422)
except ValueError:
return JSONResponse({'detail': 'Ungültige Werte. Bitte Felder und Position prüfen.'}, status_code=422)
except sqlite3.IntegrityError:
return JSONResponse({'detail': 'Änderung steht im Konflikt mit vorhandenen Daten.'}, status_code=409)
except sqlite3.OperationalError:
return JSONResponse({'detail': 'Datenbank vorübergehend nicht verfügbar.'}, status_code=503, headers={'Retry-After': '5'})
except Exception:
return JSONResponse({'detail': 'Interner Fehler. Anfrage konnte nicht verarbeitet werden.'}, status_code=500)
return safe_handler
router = APIRouter(prefix='/api/v1', dependencies=[Depends(require_token)], route_class=SafeAPIRoute,
responses={401: {'description': 'Token fehlt oder ist ungültig'},
503: {'description': 'API nicht konfiguriert oder Datenbank nicht verfügbar'}})
def income_response(row):
return IncomeResponse(**{**dict(row), 'asset': row['name'], 'amount': decimal_string(row['amount'])})
def percentage(amount, total):
if total == 0:
return None
return format((Decimal(amount) * 100 / Decimal(total)).quantize(Decimal('.01'), rounding=ROUND_HALF_UP), '.2f')
@router.get('/assets', response_model=list[AssetResponse], tags=['Assets'])
def assets(active: bool | None = None):
return [AssetResponse(**row) for row in asset_service.list_assets(active)]
@router.get('/assets/{asset_id}', response_model=AssetResponse, tags=['Assets'])
def asset(asset_id: int):
return AssetResponse(**asset_service.get_asset(asset_id))
@router.post('/assets', response_model=AssetResponse, status_code=201, tags=['Assets'])
def create_asset(data: AssetCreate):
return AssetResponse(**asset_service.create_asset(**data.model_dump()))
@router.patch('/assets/{asset_id}', response_model=AssetResponse, tags=['Assets'])
def update_asset(asset_id: int, data: AssetPatch):
return AssetResponse(**asset_service.update_asset(asset_id, data.model_dump(exclude_unset=True)))
@router.delete('/assets/{asset_id}', status_code=204, tags=['Assets'])
def delete_asset(asset_id: int):
asset_service.deactivate_asset(asset_id)
return Response(status_code=204)
@router.get('/income', response_model=list[IncomeResponse], tags=['Income'])
def income(year: Annotated[int | None, Query(ge=1, le=9999)] = None,
month: Annotated[int | None, Query(ge=1, le=12)] = None,
asset_id: Annotated[int | None, Query(ge=1, le=9223372036854775807)] = None,
category: Category | None = None, received: bool | None = None, expected: bool | None = None,
limit: Annotated[int, Query(ge=1, le=1000)] = 100,
offset: Annotated[int, Query(ge=0, le=9223372036854775807)] = 0):
return [income_response(row) for row in income_service.list_entries(
year, month, asset_id, category, limit, offset, received, expected)]
@router.get('/income/{entry_id}', response_model=IncomeResponse, tags=['Income'])
def income_entry(entry_id: int):
return income_response(income_service.get_entry(entry_id))
@router.post('/income', response_model=IncomeResponse, status_code=201, tags=['Income'])
def create_income(data: IncomeCreate):
return income_response(income_service.write_entry(data.model_dump()))
@router.patch('/income/{entry_id}', response_model=IncomeResponse, tags=['Income'])
def update_income(entry_id: int, data: IncomePatch):
return income_response(income_service.write_entry(data.model_dump(exclude_unset=True), entry_id, partial=True))
@router.delete('/income/{entry_id}', status_code=204, tags=['Income'])
def delete_income(entry_id: int):
income_service.delete_entry(entry_id)
return Response(status_code=204)
@router.get('/stats/summary', response_model=SummaryResponse, tags=['Stats'])
def summary():
stats = income_service.dashboard()
return SummaryResponse(
current_month=decimal_string(stats['month']), current_month_previous_year=decimal_string(stats['prior_month']),
current_month_yoy_percent=None if stats['month_change'] is None else format(stats['month_change'], '.2f'),
current_year=decimal_string(stats['year']), previous_year=decimal_string(stats['prior_year']),
current_year_yoy_percent=None if stats['year_change'] is None else format(stats['year_change'], '.2f'),
all_time=decimal_string(stats['all_time']), current_year_payment_count=stats['count'])
@router.get('/stats/monthly', response_model=list[MonthlyResponse], tags=['Stats'])
def monthly(year: Annotated[int | None, Query(ge=1, le=9999)] = None):
stats = income_service.dashboard()
years = [year] if year is not None else stats['years']
return [MonthlyResponse(year=y, months=[{'month': m+1, 'amount': decimal_string(amount)}
for m, amount in enumerate(stats['monthly'].get(y, [0]*12))],
total=decimal_string(stats['totals'].get(y, 0))) for y in years]
@router.get('/stats/by-asset', response_model=list[AssetShareResponse], tags=['Stats'])
def by_asset():
stats = income_service.dashboard()
return [AssetShareResponse(asset_id=row['asset_id'], asset=row['name'], amount=decimal_string(row['amount']),
percentage=percentage(row['amount'], stats['all_time'])) for row in stats['shares']]
@router.get('/stats/by-category', response_model=list[CategoryShareResponse], tags=['Stats'])
def by_category():
stats = income_service.dashboard()
return [CategoryShareResponse(category=category, amount=decimal_string(stats['categories'].get(category, 0)),
percentage=percentage(stats['categories'].get(category, 0), stats['all_time'])) for category in CATEGORIES]
@router.get('/meta', response_model=MetaResponse, tags=['Stats'])
def meta():
with connect() as db:
db.execute('SELECT COUNT(*) FROM assets').fetchone()
return MetaResponse()