"""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 trading_models import TradingValidationError 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 TradingValidationError as error: return JSONResponse({'detail': str(error)}, 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()