Add SQLite passive income dashboard with Excel import and deployment tools
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from services.income_service import dashboard, list_entries
|
||||
from views import render
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/')
|
||||
def index(request: Request):
|
||||
return render(request, 'index.html', {'stats': dashboard(), 'entries': list_entries(limit=20)})
|
||||
@@ -0,0 +1,35 @@
|
||||
import csv
|
||||
import io
|
||||
from decimal import Decimal
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import StreamingResponse
|
||||
from database import connect
|
||||
from models import CATEGORIES
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def safe_cell(value):
|
||||
text = str(value or '')
|
||||
return "'" + text if text.lstrip().startswith(('=', '+', '-', '@')) or text.startswith(('\t', '\r', '\n')) else text
|
||||
|
||||
|
||||
def csv_rows():
|
||||
stream = io.StringIO(newline='')
|
||||
writer = csv.writer(stream, delimiter=';', lineterminator='\r\n')
|
||||
yield '\ufeff'
|
||||
writer.writerow(['Datum', 'Position', 'Kategorie', 'Betrag', 'Notiz', 'Erwartet', 'Erhalten'])
|
||||
yield stream.getvalue()
|
||||
stream.seek(0); stream.truncate(0)
|
||||
with connect() as db:
|
||||
for row in db.execute('SELECT i.*, a.name FROM income_entries i JOIN assets a ON a.id=i.asset_id ORDER BY date DESC, i.id DESC'):
|
||||
writer.writerow([row['date'], safe_cell(row['name']), CATEGORIES[row['category']],
|
||||
f"{Decimal(row['amount'])/100:.2f}".replace('.', ','), safe_cell(row['note']),
|
||||
'Ja' if row['expected'] else 'Nein', 'Ja' if row['received'] else 'Nein'])
|
||||
yield stream.getvalue()
|
||||
stream.seek(0); stream.truncate(0)
|
||||
|
||||
|
||||
@router.get('/export/income.csv')
|
||||
def export():
|
||||
return StreamingResponse(csv_rows(), media_type='text/csv; charset=utf-8', headers={'Content-Disposition': 'attachment; filename="income.csv"'})
|
||||
@@ -0,0 +1,91 @@
|
||||
from datetime import date
|
||||
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 models import CATEGORIES
|
||||
from services.income_service import assets, available_years, get_entry, list_entries, save_entry
|
||||
from views import render
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def form_page(request, data, error=None, status=200, entry_id=None):
|
||||
return render(request, 'income_form.html', {'data': data, 'assets': assets(), 'error': error, 'entry_id': entry_id}, status)
|
||||
|
||||
|
||||
@router.get('/income')
|
||||
def history(request: Request, year: str | None = None,
|
||||
month: str | None = None,
|
||||
asset_id: str | None = None, category: str | None = None,
|
||||
page: Annotated[int, Query(ge=1, le=1000000)] = 1):
|
||||
def number(value, maximum):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = int(value)
|
||||
if not 1 <= parsed <= maximum:
|
||||
raise ValueError
|
||||
return parsed
|
||||
except ValueError:
|
||||
raise HTTPException(422, 'Ungültiger Filter.') from None
|
||||
year, month, asset_id = number(year, 9999), number(month, 12), number(asset_id, 9223372036854775807)
|
||||
category = category or None
|
||||
if category is not None and category not in CATEGORIES:
|
||||
raise HTTPException(422, 'Ungültige Kategorie.')
|
||||
rows = list_entries(year, month, asset_id, category, limit=101, offset=(page-1)*100)
|
||||
return render(request, 'income.html', dict(entries=rows[:100], more=len(rows)>100, page=page,
|
||||
assets=assets(), years=available_years(), filters=dict(year=year, month=month, asset_id=asset_id, category=category)))
|
||||
|
||||
|
||||
@router.get('/income/new')
|
||||
def new(request: Request):
|
||||
return form_page(request, {'date': date.today().isoformat(), 'received': True, 'expected': False,
|
||||
'asset_id': request.query_params.get('asset_id', ''), 'category': 'dividend'})
|
||||
|
||||
|
||||
@router.get('/income/{entry_id}/edit')
|
||||
def edit(request: Request, entry_id: int):
|
||||
data = get_entry(entry_id)
|
||||
data['amount'] = str(Decimal(data['amount']) / 100).replace('.', ',')
|
||||
return form_page(request, data, entry_id=entry_id)
|
||||
|
||||
|
||||
@router.post('/income/new')
|
||||
@router.post('/income/{entry_id}/edit')
|
||||
def save(request: Request, date: Annotated[str, Form()] = '', asset_id: Annotated[str, Form()] = '',
|
||||
category: Annotated[str, Form()] = '', amount: Annotated[str, Form()] = '',
|
||||
note: Annotated[str, Form()] = '', expected: Annotated[str, Form()] = '',
|
||||
received: Annotated[str, Form()] = '', entry_id: int | None = None):
|
||||
data = dict(date=date, asset_id=asset_id, category=category, amount=amount, note=note, expected=expected, received=received)
|
||||
try:
|
||||
save_entry(data, entry_id)
|
||||
except ValueError as error:
|
||||
return form_page(request, data, str(error), 422, entry_id)
|
||||
return RedirectResponse('/?message=saved', status_code=303)
|
||||
|
||||
|
||||
@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.')
|
||||
return RedirectResponse('/?message=deleted', status_code=303)
|
||||
|
||||
|
||||
@router.get('/assets/new')
|
||||
def new_asset(request: Request):
|
||||
return render(request, 'asset_form.html', {'data': {}})
|
||||
|
||||
|
||||
@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)
|
||||
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)
|
||||
Reference in New Issue
Block a user