Add authenticated finance REST API
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user