Add authenticated finance REST API

This commit is contained in:
kai
2026-09-09 09:47:56 +02:00
parent afc6f74f36
commit ad452045be
13 changed files with 760 additions and 44 deletions
+16
View File
@@ -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'})
+155
View File
@@ -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()
+133
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}'
+4 -9
View File
@@ -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)
+59
View File
@@ -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})
+49 -28
View File
@@ -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)}]})