Add authenticated finance REST API
This commit is contained in:
@@ -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