Add authenticated finance REST API
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
bearer = HTTPBearer(auto_error=False, scheme_name='FinanceAPIToken',
|
||||
description='Bearer-Token aus FINANCE_API_TOKEN. Kein Standard-Token.')
|
||||
|
||||
|
||||
def require_token(credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]):
|
||||
token = os.environ.get('FINANCE_API_TOKEN', '')
|
||||
if not token.strip() or token == 'change-me':
|
||||
raise HTTPException(503, 'API nicht konfiguriert. FINANCE_API_TOKEN muss gesetzt werden.')
|
||||
if credentials is None or not secrets.compare_digest(credentials.credentials.encode(), token.encode()):
|
||||
raise HTTPException(401, 'Fehlendes oder ungültiges API-Token.', headers={'WWW-Authenticate': 'Bearer'})
|
||||
@@ -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()
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Transport schemas only; business validation lives in the shared services."""
|
||||
from decimal import Decimal
|
||||
from typing import Annotated, Literal
|
||||
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, StrictBool, model_validator
|
||||
from models import cents, valid_date
|
||||
|
||||
AssetType = Literal['stock', 'etf', 'bond', 'crypto', 'interest', 'other']
|
||||
Category = Literal['dividend', 'interest', 'distribution', 'other']
|
||||
Identifier = Annotated[int, Field(strict=True, ge=1, le=9223372036854775807)]
|
||||
MoneyString = Annotated[str, Field(pattern=r'^-?\d+\.\d{2}$', examples=['0.04'])]
|
||||
|
||||
|
||||
def parse_amount(value):
|
||||
if not isinstance(value, (str, Decimal)):
|
||||
raise ValueError('Betrag als Dezimalstring senden, zum Beispiel "0.04".')
|
||||
return Decimal(cents(value)) / 100
|
||||
|
||||
|
||||
Amount = Annotated[Decimal, BeforeValidator(parse_amount, json_schema_input_type=str)]
|
||||
Day = Annotated[str, BeforeValidator(valid_date)]
|
||||
|
||||
|
||||
class RequestModel(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
|
||||
class PatchModel(RequestModel):
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def reject_required_nulls(cls, data):
|
||||
if isinstance(data, dict):
|
||||
for name, value in data.items():
|
||||
if value is None and name not in {'note', 'ticker'}:
|
||||
raise ValueError('Nur Notiz und Ticker dürfen null sein.')
|
||||
return data
|
||||
|
||||
|
||||
class AssetCreate(RequestModel):
|
||||
name: str = Field(min_length=1, max_length=150)
|
||||
ticker: str | None = Field(default=None, max_length=30)
|
||||
asset_type: AssetType
|
||||
active: StrictBool = True
|
||||
|
||||
|
||||
class AssetPatch(PatchModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=150)
|
||||
ticker: str | None = Field(default=None, max_length=30)
|
||||
asset_type: AssetType | None = None
|
||||
active: StrictBool | None = None
|
||||
|
||||
|
||||
class AssetResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
ticker: str | None
|
||||
asset_type: AssetType
|
||||
active: bool
|
||||
created_at: str
|
||||
|
||||
|
||||
class IncomeCreate(RequestModel):
|
||||
date: Day
|
||||
asset_id: Identifier
|
||||
category: Category
|
||||
amount: Amount
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
expected: StrictBool = False
|
||||
received: StrictBool = True
|
||||
|
||||
|
||||
class IncomePatch(PatchModel):
|
||||
date: Day | None = None
|
||||
asset_id: Identifier | None = None
|
||||
category: Category | None = None
|
||||
amount: Amount | None = None
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
expected: StrictBool | None = None
|
||||
received: StrictBool | None = None
|
||||
|
||||
|
||||
class IncomeResponse(BaseModel):
|
||||
id: int
|
||||
date: str
|
||||
asset: str = Field(description='Normalisierter Positionsname')
|
||||
asset_id: int
|
||||
category: Category
|
||||
amount: MoneyString
|
||||
note: str | None
|
||||
expected: bool
|
||||
received: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class SummaryResponse(BaseModel):
|
||||
current_month: MoneyString
|
||||
current_month_previous_year: MoneyString
|
||||
current_month_yoy_percent: MoneyString | None
|
||||
current_year: MoneyString
|
||||
previous_year: MoneyString
|
||||
current_year_yoy_percent: MoneyString | None
|
||||
all_time: MoneyString
|
||||
current_year_payment_count: int
|
||||
|
||||
|
||||
class MonthResponse(BaseModel):
|
||||
month: int
|
||||
amount: MoneyString
|
||||
|
||||
|
||||
class MonthlyResponse(BaseModel):
|
||||
year: int
|
||||
months: list[MonthResponse]
|
||||
total: MoneyString
|
||||
|
||||
|
||||
class AssetShareResponse(BaseModel):
|
||||
asset_id: int
|
||||
asset: str
|
||||
amount: MoneyString
|
||||
percentage: MoneyString | None
|
||||
|
||||
|
||||
class CategoryShareResponse(BaseModel):
|
||||
category: Category
|
||||
amount: MoneyString
|
||||
percentage: MoneyString | None
|
||||
|
||||
|
||||
class MetaResponse(BaseModel):
|
||||
name: str = 'Finance Dashboard'
|
||||
api_version: str = 'v1'
|
||||
database: Literal['ok'] = 'ok'
|
||||
+12
-5
@@ -23,13 +23,18 @@ def connect(path=None):
|
||||
db.close()
|
||||
|
||||
|
||||
def ensure_asset(db, name, asset_type='other', ticker=None):
|
||||
def validate_asset(name, asset_type='other', ticker=None):
|
||||
name = canonical_name(name)
|
||||
if asset_type not in ASSET_TYPES:
|
||||
raise ValueError('Ungültiger Positionstyp.')
|
||||
ticker = (ticker or '').strip()
|
||||
if len(ticker) > 30:
|
||||
raise ValueError('Ticker darf maximal 30 Zeichen enthalten.')
|
||||
return name, ticker or None
|
||||
|
||||
|
||||
def ensure_asset(db, name, asset_type='other', ticker=None):
|
||||
name, ticker = validate_asset(name, asset_type, ticker)
|
||||
db.execute('INSERT INTO assets (name, normalized_name, ticker, asset_type) VALUES (?, ?, ?, ?) ON CONFLICT(normalized_name) DO NOTHING',
|
||||
(name, name_key(name), ticker or None, asset_type))
|
||||
return db.execute('SELECT id FROM assets WHERE normalized_name = ?', (name_key(name),)).fetchone()['id']
|
||||
@@ -39,6 +44,7 @@ def initialize(path=None):
|
||||
target = Path(path or db_path())
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with connect(target) as db:
|
||||
first_setup = db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='assets'").fetchone() is None
|
||||
db.execute('PRAGMA journal_mode = WAL')
|
||||
db.executescript('''
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
@@ -67,7 +73,8 @@ def initialize(path=None):
|
||||
);
|
||||
PRAGMA user_version = 1;
|
||||
''')
|
||||
for name, kind in [('AGNC','stock'), ('Main Street Capital','stock'), ('Capital Southwest','stock'),
|
||||
('Ares Capital','stock'), ('Realty Income','stock'), ('Enbridge','stock'),
|
||||
('Bayer','stock'), ('STOXX Global Select Dividend 100','etf'), ('airBaltic','bond')]:
|
||||
ensure_asset(db, name, kind)
|
||||
if first_setup:
|
||||
for name, kind in [('AGNC','stock'), ('Main Street Capital','stock'), ('Capital Southwest','stock'),
|
||||
('Ares Capital','stock'), ('Realty Income','stock'), ('Enbridge','stock'),
|
||||
('Bayer','stock'), ('STOXX Global Select Dividend 100','etf'), ('airBaltic','bond')]:
|
||||
ensure_asset(db, name, kind)
|
||||
|
||||
+3
-1
@@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from database import initialize
|
||||
from routes import dashboard, income, export
|
||||
from api.routes import router as api_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -20,11 +21,12 @@ app.mount('/static', StaticFiles(directory=Path(__file__).parent / 'static'), na
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(income.router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(api_router)
|
||||
|
||||
|
||||
@app.middleware('http')
|
||||
async def protect_forms(request: Request, call_next):
|
||||
if request.method == 'POST':
|
||||
if request.method == 'POST' and not request.url.path.startswith('/api/v1/'):
|
||||
origin = request.headers.get('origin')
|
||||
if request.headers.get('sec-fetch-site') == 'cross-site' or (origin and urlsplit(origin).netloc != request.headers.get('host')):
|
||||
return HTMLResponse('Fremder Formularursprung ist nicht erlaubt.', status_code=403)
|
||||
|
||||
+6
-1
@@ -64,6 +64,11 @@ ALIASES = {
|
||||
def canonical_name(value):
|
||||
name = ' '.join(str(value).split())
|
||||
name = re.sub(r'^(dividende[n]?|ausschüttung)\s+', '', name, flags=re.I)
|
||||
if not name or len(name) > 150:
|
||||
if not name or not name_key(name) or len(name) > 150:
|
||||
raise ValueError('Positionsname muss zwischen 1 und 150 Zeichen lang sein.')
|
||||
return ALIASES.get(name_key(name), name)
|
||||
|
||||
|
||||
def decimal_string(amount):
|
||||
"""Represent integer cents in JSON without a binary float conversion."""
|
||||
return f'{Decimal(amount) / 100:.2f}'
|
||||
|
||||
@@ -3,9 +3,9 @@ from decimal import Decimal
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Form, HTTPException, Query, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from database import connect, ensure_asset
|
||||
from services.asset_service import create_asset as create_asset_record
|
||||
from models import CATEGORIES
|
||||
from services.income_service import assets, available_years, get_entry, list_entries, save_entry
|
||||
from services.income_service import assets, available_years, get_entry, list_entries, save_entry, delete_entry
|
||||
from views import render
|
||||
|
||||
router = APIRouter()
|
||||
@@ -68,11 +68,7 @@ def save(request: Request, date: Annotated[str, Form()] = '', asset_id: Annotate
|
||||
|
||||
@router.post('/income/{entry_id}/delete')
|
||||
def delete(entry_id: int):
|
||||
if not 1 <= entry_id <= 9223372036854775807:
|
||||
raise HTTPException(404, 'Zahlung nicht gefunden.')
|
||||
with connect() as db:
|
||||
if db.execute('DELETE FROM income_entries WHERE id = ?', (entry_id,)).rowcount == 0:
|
||||
raise HTTPException(404, 'Zahlung nicht gefunden.')
|
||||
delete_entry(entry_id)
|
||||
return RedirectResponse('/?message=deleted', status_code=303)
|
||||
|
||||
|
||||
@@ -84,8 +80,7 @@ def new_asset(request: Request):
|
||||
@router.post('/assets/new')
|
||||
def create_asset(request: Request, name: Annotated[str, Form()], asset_type: Annotated[str, Form()], ticker: Annotated[str, Form()] = ''):
|
||||
try:
|
||||
with connect() as db:
|
||||
asset_id = ensure_asset(db, name, asset_type, ticker)
|
||||
asset_id = create_asset_record(name, asset_type, ticker, reuse=True)['id']
|
||||
except ValueError as error:
|
||||
return render(request, 'asset_form.html', {'data': dict(name=name, asset_type=asset_type, ticker=ticker), 'error': str(error)}, 422)
|
||||
return RedirectResponse(f'/income/new?asset_id={asset_id}', status_code=303)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Asset operations shared by web forms and the REST API."""
|
||||
from fastapi import HTTPException
|
||||
from database import connect, ensure_asset, validate_asset
|
||||
from models import name_key
|
||||
|
||||
|
||||
def list_assets(active=None):
|
||||
with connect() as db:
|
||||
if active is None:
|
||||
rows = db.execute('SELECT * FROM assets ORDER BY name COLLATE NOCASE').fetchall()
|
||||
else:
|
||||
rows = db.execute('SELECT * FROM assets WHERE active=? ORDER BY name COLLATE NOCASE', (active,)).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def _get_asset(db, asset_id):
|
||||
if not 1 <= asset_id <= 9223372036854775807:
|
||||
raise HTTPException(404, 'Position nicht gefunden.')
|
||||
row = db.execute('SELECT * FROM assets WHERE id=?', (asset_id,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, 'Position nicht gefunden.')
|
||||
return dict(row)
|
||||
|
||||
|
||||
def get_asset(asset_id):
|
||||
with connect() as db:
|
||||
return _get_asset(db, asset_id)
|
||||
|
||||
|
||||
def create_asset(name, asset_type='other', ticker=None, active=True, reuse=False):
|
||||
name, ticker = validate_asset(name, asset_type, ticker)
|
||||
with connect() as db:
|
||||
db.execute('BEGIN IMMEDIATE')
|
||||
existing = db.execute('SELECT * FROM assets WHERE normalized_name=?', (name_key(name),)).fetchone()
|
||||
if existing:
|
||||
if reuse:
|
||||
return dict(existing)
|
||||
raise HTTPException(409, 'Diese Position existiert bereits.')
|
||||
asset_id = ensure_asset(db, name, asset_type, ticker)
|
||||
db.execute('UPDATE assets SET active=? WHERE id=?', (bool(active), asset_id))
|
||||
return _get_asset(db, asset_id)
|
||||
|
||||
|
||||
def update_asset(asset_id, changes):
|
||||
with connect() as db:
|
||||
db.execute('BEGIN IMMEDIATE')
|
||||
data = _get_asset(db, asset_id)
|
||||
data.update(changes)
|
||||
name, ticker = validate_asset(data['name'], data['asset_type'], data['ticker'])
|
||||
if db.execute('SELECT 1 FROM assets WHERE normalized_name=? AND id<>?', (name_key(name), asset_id)).fetchone():
|
||||
raise HTTPException(409, 'Diese Position existiert bereits.')
|
||||
db.execute('UPDATE assets SET name=?, normalized_name=?, ticker=?, asset_type=?, active=? WHERE id=?',
|
||||
(name, name_key(name), ticker, data['asset_type'], bool(data['active']), asset_id))
|
||||
return _get_asset(db, asset_id)
|
||||
|
||||
|
||||
def deactivate_asset(asset_id):
|
||||
# Preserve references and history even when an asset has no payments yet.
|
||||
return update_asset(asset_id, {'active': False})
|
||||
@@ -1,27 +1,25 @@
|
||||
from datetime import date
|
||||
from fastapi import HTTPException
|
||||
from database import connect
|
||||
from models import CATEGORIES, MONTHS, cents, valid_date, percent
|
||||
from models import CATEGORIES, MONTHS, cents, valid_date, percent, decimal_string
|
||||
from services.asset_service import list_assets as assets
|
||||
|
||||
|
||||
def assets():
|
||||
with connect() as db:
|
||||
return db.execute('SELECT * FROM assets ORDER BY name COLLATE NOCASE').fetchall()
|
||||
|
||||
|
||||
def get_entry(entry_id):
|
||||
def _get_entry(db, entry_id):
|
||||
if not 1 <= entry_id <= 9223372036854775807:
|
||||
raise HTTPException(404, 'Zahlung nicht gefunden.')
|
||||
with connect() as db:
|
||||
row = db.execute('SELECT * FROM income_entries WHERE id = ?', (entry_id,)).fetchone()
|
||||
row = db.execute('SELECT i.*, a.name FROM income_entries i JOIN assets a ON a.id=i.asset_id WHERE i.id=?', (entry_id,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, 'Zahlung nicht gefunden.')
|
||||
return dict(row)
|
||||
|
||||
|
||||
def save_entry(data, entry_id=None):
|
||||
if entry_id is not None and not 1 <= entry_id <= 9223372036854775807:
|
||||
raise HTTPException(404, 'Zahlung nicht gefunden.')
|
||||
def get_entry(entry_id):
|
||||
with connect() as db:
|
||||
return _get_entry(db, entry_id)
|
||||
|
||||
|
||||
def _validated_values(data, db, existing):
|
||||
day = valid_date(data.get('date', ''))
|
||||
amount = cents(data.get('amount', ''))
|
||||
category = data.get('category', '')
|
||||
@@ -33,32 +31,54 @@ def save_entry(data, entry_id=None):
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError('Bitte eine Position auswählen.') from None
|
||||
note = data.get('note', '').strip()
|
||||
note = (data.get('note') or '').strip()
|
||||
if len(note) > 2000:
|
||||
raise ValueError('Notiz darf maximal 2000 Zeichen enthalten.')
|
||||
expected, received = int(data.get('expected') == '1'), int(data.get('received') == '1')
|
||||
expected = int(data.get('expected') in (True, '1'))
|
||||
received = int(data.get('received') in (True, '1'))
|
||||
asset = db.execute('SELECT * FROM assets WHERE id=?', (asset_id,)).fetchone()
|
||||
if asset is None or (not asset['active'] and (existing is None or existing['asset_id'] != asset_id)):
|
||||
raise ValueError('Diese Position ist nicht mehr verfügbar.')
|
||||
return day, asset_id, category, amount, note or None, expected, received
|
||||
|
||||
|
||||
def write_entry(data, entry_id=None, partial=False):
|
||||
"""Validate and write atomically; PATCH merges inside the write transaction."""
|
||||
with connect() as db:
|
||||
db.execute('BEGIN IMMEDIATE')
|
||||
existing = db.execute('SELECT * FROM income_entries WHERE id = ?', (entry_id,)).fetchone() if entry_id else None
|
||||
if entry_id and existing is None:
|
||||
raise HTTPException(404, 'Zahlung nicht gefunden.')
|
||||
asset = db.execute('SELECT * FROM assets WHERE id = ?', (asset_id,)).fetchone()
|
||||
if asset is None or (not asset['active'] and (existing is None or existing['asset_id'] != asset_id)):
|
||||
raise ValueError('Diese Position ist nicht mehr verfügbar.')
|
||||
values = (day, asset_id, category, amount, note or None, expected, received)
|
||||
if entry_id:
|
||||
existing = _get_entry(db, entry_id) if entry_id is not None else None
|
||||
if partial:
|
||||
merged = dict(existing)
|
||||
merged['amount'] = decimal_string(existing['amount'])
|
||||
merged.update(data)
|
||||
data = merged
|
||||
values = _validated_values(data, db, existing)
|
||||
if entry_id is not None:
|
||||
db.execute("UPDATE income_entries SET date=?, asset_id=?, category=?, amount=?, note=?, expected=?, received=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?", (*values, entry_id))
|
||||
else:
|
||||
entry_id = db.execute('INSERT INTO income_entries (date,asset_id,category,amount,note,expected,received) VALUES (?,?,?,?,?,?,?)', values).lastrowid
|
||||
return entry_id
|
||||
return _get_entry(db, entry_id)
|
||||
|
||||
|
||||
def list_entries(year=None, month=None, asset_id=None, category=None, limit=None, offset=0):
|
||||
def save_entry(data, entry_id=None):
|
||||
"""Existing web/import-facing return contract."""
|
||||
return write_entry(data, entry_id)['id']
|
||||
|
||||
|
||||
def delete_entry(entry_id):
|
||||
with connect() as db:
|
||||
db.execute('BEGIN IMMEDIATE')
|
||||
_get_entry(db, entry_id)
|
||||
db.execute('DELETE FROM income_entries WHERE id=?', (entry_id,))
|
||||
|
||||
|
||||
def list_entries(year=None, month=None, asset_id=None, category=None, limit=None, offset=0, received=None, expected=None):
|
||||
# SQL fragments are constants. All filter values remain bound parameters.
|
||||
clauses, args = [], []
|
||||
for sql, value in [("substr(i.date,1,4) = ?", str(year) if year else None),
|
||||
("substr(i.date,6,2) = ?", f'{month:02}' if month else None),
|
||||
('i.asset_id = ?', asset_id), ('i.category = ?', category)]:
|
||||
('i.asset_id = ?', asset_id), ('i.category = ?', category),
|
||||
('i.received = ?', received), ('i.expected = ?', expected)]:
|
||||
if value is not None:
|
||||
clauses.append(sql)
|
||||
args.append(value)
|
||||
@@ -81,11 +101,12 @@ def available_years():
|
||||
def dashboard(today=None):
|
||||
today = today or date.today()
|
||||
with connect() as db:
|
||||
db.execute('BEGIN') # One read snapshot for all dashboard/statistics aggregates.
|
||||
years_found = [int(r[0]) for r in db.execute('SELECT DISTINCT substr(date,1,4) FROM income_entries')]
|
||||
grouped = db.execute("SELECT substr(date,1,4) year, substr(date,6,2) month, SUM(amount) amount, COUNT(*) count FROM income_entries WHERE received=1 GROUP BY year, month").fetchall()
|
||||
shares = [dict(r) for r in db.execute('SELECT a.name, SUM(i.amount) amount FROM income_entries i JOIN assets a ON a.id=i.asset_id WHERE received=1 GROUP BY a.id ORDER BY amount DESC')]
|
||||
shares = [dict(r) for r in db.execute('SELECT a.id asset_id, a.name, SUM(i.amount) amount FROM income_entries i JOIN assets a ON a.id=i.asset_id WHERE received=1 GROUP BY a.id ORDER BY amount DESC')]
|
||||
kinds = {r['category']: r['amount'] for r in db.execute('SELECT category, SUM(amount) amount FROM income_entries WHERE received=1 GROUP BY category')}
|
||||
pending = db.execute('SELECT COUNT(*) count, COALESCE(SUM(amount),0) amount FROM income_entries WHERE expected=1 AND received=0').fetchone()
|
||||
years_found = available_years()
|
||||
years = list(range(min(years_found + [today.year]), max(years_found + [today.year + 1]) + 1))
|
||||
monthly = {year: [0] * 12 for year in years}
|
||||
count = 0
|
||||
@@ -101,7 +122,7 @@ def dashboard(today=None):
|
||||
return dict(years=years, monthly=monthly, totals=totals, month=current_month, prior_month=prior_month,
|
||||
month_change=percent(current_month, prior_month), year=totals[today.year], prior_year=prior_year,
|
||||
year_change=percent(totals[today.year], prior_year), all_time=sum(totals.values()), count=count,
|
||||
pending=dict(pending), today=today, shares=shares,
|
||||
pending=dict(pending), today=today, shares=shares, categories=kinds,
|
||||
chart={'months': MONTHS, 'years': [{'label': str(y), 'data': monthly[y]} for y in years],
|
||||
'shares': shares, 'kinds': [{'name': 'Dividenden / Ausschüttungen', 'amount': kinds.get('dividend',0)+kinds.get('distribution',0)},
|
||||
{'name': 'Zinsen', 'amount': kinds.get('interest',0)}, {'name': 'Sonstiges', 'amount': kinds.get('other',0)}]})
|
||||
|
||||
Reference in New Issue
Block a user