feat: Projektfundament mit Datenmodell, Migrationen und Seed
- Repo-Struktur für Backend, Frontend, Dokumentation und Gitea-Workflows - FastAPI-Skeleton mit pydantic-settings und RFC-7807-artigem Fehlerformat - Vollständiges Datenmodell (15 Tabellen) als SQLAlchemy-2.0-Modelle - Alembic gegen die Async-Engine inklusive Erstmigration - Idempotenter Seed für den deutschen Kategoriebaum und Standardregeln - Endpunkte /api/health und /api/version - pytest-Infrastruktur mit transaktionsisolierten Fixtures Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
@@ -0,0 +1,72 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# moneyfy – Beispielkonfiguration
|
||||||
|
# Kopieren nach `.env` und anpassen. Niemals echte Secrets committen.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- Allgemein ---------------------------------------------------------------
|
||||||
|
ENVIRONMENT=production # development | production | test
|
||||||
|
DEBUG=false
|
||||||
|
TIMEZONE=Europe/Berlin # Durchgängige Zeitzone der Anwendung
|
||||||
|
HOLIDAY_REGION=DE-NW # Feiertagsregion für Werktagsverschiebungen
|
||||||
|
DEFAULT_CURRENCY=EUR
|
||||||
|
# Öffentliche Basis-URL, u. a. für Links in Benachrichtigungen
|
||||||
|
PUBLIC_BASE_URL=https://moneyfy.menzel.center
|
||||||
|
# Image-Tag, den docker-compose zieht (main, 0.1.0, latest, sha-abc1234)
|
||||||
|
TAG=latest
|
||||||
|
|
||||||
|
# --- Datenbank ---------------------------------------------------------------
|
||||||
|
POSTGRES_DB=moneyfy
|
||||||
|
POSTGRES_USER=moneyfy
|
||||||
|
POSTGRES_PASSWORD=bitte-aendern
|
||||||
|
# Vom Backend genutzte DSN. `postgresql://` wird automatisch auf asyncpg umgestellt.
|
||||||
|
DATABASE_URL=postgresql+asyncpg://moneyfy:bitte-aendern@moneyfy-db:5432/moneyfy
|
||||||
|
|
||||||
|
# --- Sicherheit --------------------------------------------------------------
|
||||||
|
# Signaturschlüssel für JWTs. Erzeugen mit: openssl rand -hex 32
|
||||||
|
SECRET_KEY=bitte-aendern
|
||||||
|
ACCESS_TOKEN_TTL_MINUTES=30
|
||||||
|
REFRESH_TOKEN_TTL_DAYS=14
|
||||||
|
COOKIE_SECURE=true # Hinter HTTPS-Proxy true, für lokales HTTP false
|
||||||
|
COOKIE_DOMAIN=
|
||||||
|
|
||||||
|
# Wird beim ersten Start angelegt (danach mit erzwungenem Passwortwechsel).
|
||||||
|
MONEYFY_ADMIN_USER=admin
|
||||||
|
MONEYFY_ADMIN_PASSWORD=bitte-aendern
|
||||||
|
|
||||||
|
# --- OIDC (vorbereitet, noch nicht implementiert) ----------------------------
|
||||||
|
OIDC_ENABLED=false
|
||||||
|
OIDC_ISSUER=
|
||||||
|
OIDC_CLIENT_ID=
|
||||||
|
OIDC_CLIENT_SECRET=
|
||||||
|
OIDC_SCOPES=openid profile email
|
||||||
|
|
||||||
|
# --- Logo-Service ------------------------------------------------------------
|
||||||
|
# Verzeichnis für den lokalen Logo-Cache (im Container auf das Volume gemountet).
|
||||||
|
LOGO_STORAGE_DIR=/data/logos
|
||||||
|
# Optionale Provider. Ohne Key wird der jeweilige Adapter übersprungen.
|
||||||
|
LOGODEV_API_KEY=
|
||||||
|
BRANDFETCH_API_KEY=
|
||||||
|
LOGO_HTTP_TIMEOUT_SECONDS=5
|
||||||
|
LOGO_HTTP_RETRIES=2
|
||||||
|
LOGO_MAX_UPLOAD_BYTES=1048576
|
||||||
|
|
||||||
|
# --- Benachrichtigungen ------------------------------------------------------
|
||||||
|
NOTIFICATIONS_ENABLED=true
|
||||||
|
SCHEDULER_ENABLED=true
|
||||||
|
NOTIFICATION_HOUR=7 # Täglicher Lauf um 07:00 Europe/Berlin
|
||||||
|
NOTIFICATION_MINUTE=0
|
||||||
|
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM=moneyfy@example.com
|
||||||
|
SMTP_USE_TLS=true # STARTTLS
|
||||||
|
SMTP_USE_SSL=false # Implizites TLS (Port 465)
|
||||||
|
|
||||||
|
# Komma-separierte Apprise-URLs, z. B. ntfy://host/topic,discord://...
|
||||||
|
APPRISE_URLS=
|
||||||
|
|
||||||
|
# --- CORS --------------------------------------------------------------------
|
||||||
|
# Nur für getrennte Frontend-Entwicklung nötig, z. B. http://localhost:5173
|
||||||
|
CORS_ORIGINS=
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# Lokale Konfiguration und Daten
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
docker-compose.override.yml
|
||||||
|
backend/.data/
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Werkzeuge
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert.
|
||||||
|
|
||||||
|
Das Format orientiert sich an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
|
||||||
|
die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- Projektgerüst mit getrennten Bereichen für Backend, Frontend, Dokumentation und CI.
|
||||||
|
- FastAPI-Backend mit Konfiguration über `pydantic-settings` und einheitlichem
|
||||||
|
Fehlerformat (`{"detail": ..., "code": ...}`).
|
||||||
|
- Vollständiges Datenmodell als SQLAlchemy-2.0-Modelle: Konten, Kategorien, Firmen,
|
||||||
|
Logo-Assets, Recurrences, Preishistorie, Occurrences, Buchungen, Budgets samt
|
||||||
|
Vorlagen, Sparziele, Rücklagen, Benachrichtigungsregeln und -protokoll, Benutzer.
|
||||||
|
- Alembic-Setup gegen die Async-Engine samt Erstmigration des kompletten Schemas.
|
||||||
|
- Idempotenter Seed für den deutschen Standard-Kategoriebaum (35 Kategorien) und
|
||||||
|
die Standard-Benachrichtigungsregeln.
|
||||||
|
- Endpunkte `GET /api/health` und `GET /api/version`.
|
||||||
|
- Testinfrastruktur mit pytest, httpx und transaktionsisolierten Fixtures.
|
||||||
|
- `.env.example`, `docker-compose.override.yml.example` und Makefile für die
|
||||||
|
lokale Entwicklung.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Jonas Menzel
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Entwicklungs-Helfer für moneyfy.
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
BACKEND := backend
|
||||||
|
PY := $(BACKEND)/.venv/bin/python
|
||||||
|
UV := uv
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## Verfügbare Ziele anzeigen
|
||||||
|
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \
|
||||||
|
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
.PHONY: install
|
||||||
|
install: ## Backend-Abhängigkeiten in .venv installieren
|
||||||
|
$(UV) venv --python 3.12 $(BACKEND)/.venv
|
||||||
|
cd $(BACKEND) && ../$(BACKEND)/.venv/bin/python -m ensurepip --version >/dev/null 2>&1 || true
|
||||||
|
cd $(BACKEND) && $(UV) pip install -e ".[dev]"
|
||||||
|
|
||||||
|
.PHONY: migrate
|
||||||
|
migrate: ## Alembic-Migrationen auf den aktuellen Stand bringen
|
||||||
|
cd $(BACKEND) && .venv/bin/alembic upgrade head
|
||||||
|
|
||||||
|
.PHONY: revision
|
||||||
|
revision: ## Neue Migration erzeugen: make revision m="beschreibung"
|
||||||
|
cd $(BACKEND) && .venv/bin/alembic revision --autogenerate -m "$(m)"
|
||||||
|
|
||||||
|
.PHONY: seed
|
||||||
|
seed: ## Stammdaten (Kategoriebaum, Benachrichtigungsregeln) anlegen
|
||||||
|
cd $(BACKEND) && .venv/bin/python -m app.scripts.seed
|
||||||
|
|
||||||
|
.PHONY: dev
|
||||||
|
dev: ## Backend mit Hot Reload starten
|
||||||
|
cd $(BACKEND) && .venv/bin/uvicorn app.main:app --reload --port 8000
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test: ## Backend-Tests ausführen
|
||||||
|
cd $(BACKEND) && .venv/bin/python -m pytest
|
||||||
|
|
||||||
|
.PHONY: lint
|
||||||
|
lint: ## ruff check und Formatprüfung
|
||||||
|
cd $(BACKEND) && .venv/bin/ruff check .
|
||||||
|
cd $(BACKEND) && .venv/bin/ruff format --check .
|
||||||
|
|
||||||
|
.PHONY: format
|
||||||
|
format: ## Code formatieren und automatisch behebbare Regelverstöße korrigieren
|
||||||
|
cd $(BACKEND) && .venv/bin/ruff check --fix .
|
||||||
|
cd $(BACKEND) && .venv/bin/ruff format .
|
||||||
|
|
||||||
|
.PHONY: check
|
||||||
|
check: lint test ## Lint und Tests zusammen
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# moneyfy
|
||||||
|
|
||||||
|
Selbst gehostete Web-Anwendung zur Planung monatlicher Kosten und Einkünfte.
|
||||||
|
Single-User, deutschsprachige Oberfläche, ausgelegt für den Betrieb im Homelab
|
||||||
|
hinter einem Reverse Proxy.
|
||||||
|
|
||||||
|
> **Status:** in Entwicklung. Siehe [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
_Platzhalter – werden ergänzt, sobald die Oberfläche steht._
|
||||||
|
|
||||||
|
| Dashboard | Cashflow-Kalender | Firmen |
|
||||||
|
|---|---|---|
|
||||||
|
| _folgt_ | _folgt_ | _folgt_ |
|
||||||
|
|
||||||
|
## Funktionsumfang
|
||||||
|
|
||||||
|
- Wiederkehrende Zahlungen und Einkünfte über vollständige RFC-5545-RRULEs,
|
||||||
|
inklusive Werktagsverschiebung nach NRW-Feiertagen.
|
||||||
|
- Preishistorie je Vertrag, sodass vergangene Monate betragstreu bleiben.
|
||||||
|
- Verträge mit Mindestlaufzeit, Kündigungsfrist und automatischer Verlängerung.
|
||||||
|
- Ratenzahlungen mit Restschuld- und Restratenberechnung.
|
||||||
|
- Rücklagenbildung für nicht-monatliche Posten.
|
||||||
|
- Firmenlogos und Markenfarben, lokal zwischengespeichert.
|
||||||
|
- Auswertungen: Monatsübersicht, Cashflow-Kalender, 12-Monats-Forecast,
|
||||||
|
Kategorien, Abo-Übersicht, Jahresvergleich, Budgets, Sparziele.
|
||||||
|
- Benachrichtigungen per SMTP und Apprise.
|
||||||
|
|
||||||
|
## Technischer Stack
|
||||||
|
|
||||||
|
| Bereich | Technologie |
|
||||||
|
|---|---|
|
||||||
|
| Backend | Python 3.12, FastAPI, SQLAlchemy 2 (async), Alembic, Pydantic v2 |
|
||||||
|
| Datenbank | PostgreSQL 17 |
|
||||||
|
| Scheduler | APScheduler (in-process) |
|
||||||
|
| Frontend | React 18, TypeScript, Vite, TailwindCSS |
|
||||||
|
| Charts | Recharts |
|
||||||
|
| Tests | pytest + httpx, vitest + testing-library |
|
||||||
|
|
||||||
|
## Schnellstart (lokale Entwicklung)
|
||||||
|
|
||||||
|
Voraussetzungen: Python 3.12, [uv](https://docs.astral.sh/uv/), Node 20+,
|
||||||
|
eine erreichbare PostgreSQL-17-Instanz.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.menzel.center/menzeljonas/moneyfy.git
|
||||||
|
cd moneyfy
|
||||||
|
cp .env.example backend/.env # DATABASE_URL und SECRET_KEY anpassen
|
||||||
|
|
||||||
|
make install # Backend-Abhängigkeiten in backend/.venv
|
||||||
|
make migrate # Schema anlegen
|
||||||
|
make seed # Kategoriebaum und Standardregeln
|
||||||
|
make dev # http://localhost:8000/api/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
Nützliche Ziele: `make test`, `make lint`, `make format`, `make check`.
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
Alle Einstellungen kommen aus Umgebungsvariablen; `.env.example` enthält die
|
||||||
|
vollständige, kommentierte Liste. Die wichtigsten:
|
||||||
|
|
||||||
|
| Variable | Standard | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `DATABASE_URL` | – | PostgreSQL-DSN, `postgresql://` wird auf asyncpg umgestellt |
|
||||||
|
| `SECRET_KEY` | – | Signaturschlüssel für JWTs (`openssl rand -hex 32`) |
|
||||||
|
| `MONEYFY_ADMIN_USER` / `MONEYFY_ADMIN_PASSWORD` | `admin` / – | Beim Erststart angelegter Benutzer |
|
||||||
|
| `TIMEZONE` | `Europe/Berlin` | Zeitzone der gesamten Anwendung |
|
||||||
|
| `HOLIDAY_REGION` | `DE-NW` | Feiertagsregion für Werktagsverschiebungen |
|
||||||
|
| `LOGO_STORAGE_DIR` | `/data/logos` | Verzeichnis des Logo-Caches |
|
||||||
|
| `LOGODEV_API_KEY` / `BRANDFETCH_API_KEY` | leer | Optionale Logo-Provider |
|
||||||
|
| `SMTP_*` | leer | Mailversand für Benachrichtigungen |
|
||||||
|
| `APPRISE_URLS` | leer | Komma-separierte Apprise-Ziele |
|
||||||
|
| `COOKIE_SECURE` | `true` | Hinter HTTPS `true`, für lokales HTTP `false` |
|
||||||
|
| `SCHEDULER_ENABLED` | `true` | Täglicher Benachrichtigungslauf um 07:00 |
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/ FastAPI-Anwendung, Modelle, Services, Alembic-Migrationen, Tests
|
||||||
|
frontend/ React-Oberfläche (Vite)
|
||||||
|
docs/ Betriebs- und Runner-Dokumentation
|
||||||
|
.gitea/ Gitea-Actions-Workflows
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Alembic-Konfiguration. Die DSN kommt aus der Umgebung (siehe alembic/env.py).
|
||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
timezone = Europe/Berlin
|
||||||
|
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(slug)s
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Alembic-Umgebung – nutzt die Async-Engine der Anwendung."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models import Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
config.set_main_option("sqlalchemy.url", str(settings.database_url))
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Migrationen ohne DB-Verbindung als SQL ausgeben."""
|
||||||
|
context.configure(
|
||||||
|
url=str(settings.database_url),
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
compare_type=True,
|
||||||
|
compare_server_default=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
compare_type=True,
|
||||||
|
compare_server_default=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
${imports if imports else ""}
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: str | None = ${repr(down_revision)}
|
||||||
|
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||||
|
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"""initial schema
|
||||||
|
|
||||||
|
Revision ID: 2bc4ab55dbe9
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-09-09 13:07:31.239925+02:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
revision: str = '2bc4ab55dbe9'
|
||||||
|
down_revision: str | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('account',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('type', sa.Enum('checking', 'credit_card', 'savings', 'cash', name='account_type'), nullable=False),
|
||||||
|
sa.Column('iban_last4', sa.String(length=4), nullable=True),
|
||||||
|
sa.Column('opening_balance', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||||
|
sa.Column('opening_balance_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('color', sa.String(length=9), nullable=False),
|
||||||
|
sa.Column('icon', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('sort_order', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_account')),
|
||||||
|
sa.UniqueConstraint('name', name='uq_account_name')
|
||||||
|
)
|
||||||
|
op.create_index('ix_account_is_active', 'account', ['is_active'], unique=False)
|
||||||
|
op.create_table('app_user',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('password_hash', sa.Text(), nullable=True),
|
||||||
|
sa.Column('must_change_password', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('external_subject', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.CheckConstraint('password_hash IS NOT NULL OR external_subject IS NOT NULL', name=op.f('ck_app_user_local_or_external_login')),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_app_user')),
|
||||||
|
sa.UniqueConstraint('external_subject', name=op.f('uq_app_user_external_subject')),
|
||||||
|
sa.UniqueConstraint('username', name=op.f('uq_app_user_username'))
|
||||||
|
)
|
||||||
|
op.create_table('category',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('parent_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('kind', sa.Enum('expense', 'income', name='entry_kind'), nullable=False),
|
||||||
|
sa.Column('color', sa.String(length=9), nullable=False),
|
||||||
|
sa.Column('icon', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('is_fixed_cost', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('sort_order', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('is_archived', sa.Boolean(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['parent_id'], ['category.id'], name=op.f('fk_category_parent_id_category'), ondelete='RESTRICT'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_category')),
|
||||||
|
sa.UniqueConstraint('parent_id', 'name', name='uq_category_parent_id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_category_kind', 'category', ['kind'], unique=False)
|
||||||
|
op.create_table('logo_asset',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('sha256', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('mime', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('width', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('height', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('file_path', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('source_url', sa.Text(), nullable=True),
|
||||||
|
sa.Column('fetched_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_logo_asset')),
|
||||||
|
sa.UniqueConstraint('sha256', name=op.f('uq_logo_asset_sha256'))
|
||||||
|
)
|
||||||
|
op.create_table('notification_rule',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('type', sa.Enum('due_soon', 'notice_deadline', 'budget_exceeded', 'contract_renewal', name='notification_type'), nullable=False),
|
||||||
|
sa.Column('lead_days', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('channel', sa.Enum('smtp', 'apprise', name='notification_channel'), nullable=False),
|
||||||
|
sa.Column('target', sa.Text(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_notification_rule'))
|
||||||
|
)
|
||||||
|
op.create_table('budget',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('category_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('period_month', sa.Date(), nullable=False),
|
||||||
|
sa.Column('limit_amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||||
|
sa.Column('rollover', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.CheckConstraint("date_trunc('month', period_month) = period_month", name=op.f('ck_budget_month_start')),
|
||||||
|
sa.ForeignKeyConstraint(['category_id'], ['category.id'], name=op.f('fk_budget_category_id_category'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_budget')),
|
||||||
|
sa.UniqueConstraint('category_id', 'period_month', name='uq_budget_category_id')
|
||||||
|
)
|
||||||
|
op.create_table('budget_template',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('category_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('valid_from', sa.Date(), nullable=False),
|
||||||
|
sa.Column('valid_until', sa.Date(), nullable=True),
|
||||||
|
sa.Column('limit_amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||||
|
sa.Column('rollover', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.CheckConstraint("date_trunc('month', valid_from) = valid_from", name=op.f('ck_budget_template_month_start')),
|
||||||
|
sa.ForeignKeyConstraint(['category_id'], ['category.id'], name=op.f('fk_budget_template_category_id_category'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_budget_template')),
|
||||||
|
sa.UniqueConstraint('category_id', 'valid_from', name='uq_budget_template_category_id')
|
||||||
|
)
|
||||||
|
op.create_table('merchant',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=160), nullable=False),
|
||||||
|
sa.Column('normalized_name', sa.String(length=160), nullable=False),
|
||||||
|
sa.Column('domain', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('aliases', postgresql.ARRAY(sa.Text()), server_default='{}', nullable=False),
|
||||||
|
sa.Column('logo_asset_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('brand_color', sa.String(length=9), nullable=True),
|
||||||
|
sa.Column('brand_color_dark', sa.String(length=9), nullable=True),
|
||||||
|
sa.Column('logo_source', sa.Enum('simple-icons', 'logodev', 'brandfetch', 'favicon', 'upload', 'generated', name='logo_source'), nullable=True),
|
||||||
|
sa.Column('logo_status', sa.Enum('pending', 'resolved', 'failed', 'manual', name='logo_status'), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['logo_asset_id'], ['logo_asset.id'], name=op.f('fk_merchant_logo_asset_id_logo_asset'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_merchant')),
|
||||||
|
sa.UniqueConstraint('normalized_name', name=op.f('uq_merchant_normalized_name'))
|
||||||
|
)
|
||||||
|
op.create_index('ix_merchant_normalized_name', 'merchant', ['normalized_name'], unique=False)
|
||||||
|
op.create_table('notification_log',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('rule_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('ref_type', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('ref_id', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('dedupe_day', sa.Date(), nullable=False),
|
||||||
|
sa.Column('sent_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('sent', 'failed', name='notification_status'), nullable=False),
|
||||||
|
sa.Column('error', sa.Text(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['rule_id'], ['notification_rule.id'], name=op.f('fk_notification_log_rule_id_notification_rule'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_notification_log')),
|
||||||
|
sa.UniqueConstraint('rule_id', 'ref_type', 'ref_id', 'dedupe_day', name='uq_notification_log_rule_id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_notification_log_sent_at', 'notification_log', ['sent_at'], unique=False)
|
||||||
|
op.create_table('savings_goal',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=160), nullable=False),
|
||||||
|
sa.Column('target_amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||||
|
sa.Column('target_date', sa.Date(), nullable=True),
|
||||||
|
sa.Column('current_amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||||
|
sa.Column('account_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('monthly_contribution', sa.Numeric(precision=12, scale=2), nullable=True),
|
||||||
|
sa.Column('color', sa.String(length=9), nullable=False),
|
||||||
|
sa.Column('icon', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('is_archived', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['account_id'], ['account.id'], name=op.f('fk_savings_goal_account_id_account'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_savings_goal')),
|
||||||
|
sa.UniqueConstraint('name', name='uq_savings_goal_name')
|
||||||
|
)
|
||||||
|
op.create_table('recurrence',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('kind', sa.Enum('expense', 'income', name='entry_kind'), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=160), nullable=False),
|
||||||
|
sa.Column('merchant_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('category_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('account_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||||
|
sa.Column('is_variable', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('currency', sa.String(length=3), nullable=False),
|
||||||
|
sa.Column('rrule', sa.Text(), nullable=False),
|
||||||
|
sa.Column('dtstart', sa.Date(), nullable=False),
|
||||||
|
sa.Column('until', sa.Date(), nullable=True),
|
||||||
|
sa.Column('business_day_shift', sa.Enum('none', 'next', 'previous', name='business_day_shift'), server_default='next', nullable=False),
|
||||||
|
sa.Column('holiday_region', sa.String(length=8), server_default='DE-NW', nullable=False),
|
||||||
|
sa.Column('installments_total', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('principal_amount', sa.Numeric(precision=12, scale=2), nullable=True),
|
||||||
|
sa.Column('contract_start', sa.Date(), nullable=True),
|
||||||
|
sa.Column('contract_min_term_months', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('contract_notice_period_days', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('contract_auto_renew_months', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('contract_cancelled_at', sa.Date(), nullable=True),
|
||||||
|
sa.Column('reserve_enabled', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('tags', postgresql.ARRAY(sa.Text()), server_default='{}', nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.CheckConstraint('installments_total IS NULL OR installments_total > 0', name=op.f('ck_recurrence_installments')),
|
||||||
|
sa.ForeignKeyConstraint(['account_id'], ['account.id'], name=op.f('fk_recurrence_account_id_account'), ondelete='RESTRICT'),
|
||||||
|
sa.ForeignKeyConstraint(['category_id'], ['category.id'], name=op.f('fk_recurrence_category_id_category'), ondelete='RESTRICT'),
|
||||||
|
sa.ForeignKeyConstraint(['merchant_id'], ['merchant.id'], name=op.f('fk_recurrence_merchant_id_merchant'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_recurrence'))
|
||||||
|
)
|
||||||
|
op.create_index('ix_recurrence_category_id', 'recurrence', ['category_id'], unique=False)
|
||||||
|
op.create_index('ix_recurrence_kind_active', 'recurrence', ['kind', 'is_active'], unique=False)
|
||||||
|
op.create_index('ix_recurrence_merchant_id', 'recurrence', ['merchant_id'], unique=False)
|
||||||
|
op.create_table('transaction',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('kind', sa.Enum('expense', 'income', name='entry_kind'), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=160), nullable=False),
|
||||||
|
sa.Column('merchant_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('category_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('account_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||||
|
sa.Column('booking_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('note', sa.Text(), nullable=True),
|
||||||
|
sa.Column('tags', postgresql.ARRAY(sa.Text()), server_default='{}', nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['account_id'], ['account.id'], name=op.f('fk_transaction_account_id_account'), ondelete='RESTRICT'),
|
||||||
|
sa.ForeignKeyConstraint(['category_id'], ['category.id'], name=op.f('fk_transaction_category_id_category'), ondelete='RESTRICT'),
|
||||||
|
sa.ForeignKeyConstraint(['merchant_id'], ['merchant.id'], name=op.f('fk_transaction_merchant_id_merchant'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_transaction'))
|
||||||
|
)
|
||||||
|
op.create_index('ix_transaction_booking_date', 'transaction', ['booking_date'], unique=False)
|
||||||
|
op.create_index('ix_transaction_category_id', 'transaction', ['category_id'], unique=False)
|
||||||
|
op.create_table('amount_version',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('recurrence_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||||
|
sa.Column('valid_from', sa.Date(), nullable=False),
|
||||||
|
sa.Column('note', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['recurrence_id'], ['recurrence.id'], name=op.f('fk_amount_version_recurrence_id_recurrence'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_amount_version')),
|
||||||
|
sa.UniqueConstraint('recurrence_id', 'valid_from', name='uq_amount_version_recurrence_id')
|
||||||
|
)
|
||||||
|
op.create_table('occurrence',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('recurrence_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('occurrence_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('planned', 'confirmed', 'skipped', name='occurrence_status'), nullable=False),
|
||||||
|
sa.Column('planned_amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||||
|
sa.Column('actual_amount', sa.Numeric(precision=12, scale=2), nullable=True),
|
||||||
|
sa.Column('actual_date', sa.Date(), nullable=True),
|
||||||
|
sa.Column('account_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('note', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['account_id'], ['account.id'], name=op.f('fk_occurrence_account_id_account'), ondelete='RESTRICT'),
|
||||||
|
sa.ForeignKeyConstraint(['recurrence_id'], ['recurrence.id'], name=op.f('fk_occurrence_recurrence_id_recurrence'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_occurrence')),
|
||||||
|
sa.UniqueConstraint('recurrence_id', 'occurrence_date', name='uq_occurrence_recurrence_id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_occurrence_date', 'occurrence', ['occurrence_date'], unique=False)
|
||||||
|
op.create_table('reserve_ledger',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('recurrence_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('period_month', sa.Date(), nullable=False),
|
||||||
|
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||||
|
sa.Column('is_released', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.CheckConstraint("date_trunc('month', period_month) = period_month", name=op.f('ck_reserve_ledger_month_start')),
|
||||||
|
sa.ForeignKeyConstraint(['recurrence_id'], ['recurrence.id'], name=op.f('fk_reserve_ledger_recurrence_id_recurrence'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_reserve_ledger')),
|
||||||
|
sa.UniqueConstraint('recurrence_id', 'period_month', name='uq_reserve_ledger_recurrence_id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('reserve_ledger')
|
||||||
|
op.drop_index('ix_occurrence_date', table_name='occurrence')
|
||||||
|
op.drop_table('occurrence')
|
||||||
|
op.drop_table('amount_version')
|
||||||
|
op.drop_index('ix_transaction_category_id', table_name='transaction')
|
||||||
|
op.drop_index('ix_transaction_booking_date', table_name='transaction')
|
||||||
|
op.drop_table('transaction')
|
||||||
|
op.drop_index('ix_recurrence_merchant_id', table_name='recurrence')
|
||||||
|
op.drop_index('ix_recurrence_kind_active', table_name='recurrence')
|
||||||
|
op.drop_index('ix_recurrence_category_id', table_name='recurrence')
|
||||||
|
op.drop_table('recurrence')
|
||||||
|
op.drop_table('savings_goal')
|
||||||
|
op.drop_index('ix_notification_log_sent_at', table_name='notification_log')
|
||||||
|
op.drop_table('notification_log')
|
||||||
|
op.drop_index('ix_merchant_normalized_name', table_name='merchant')
|
||||||
|
op.drop_table('merchant')
|
||||||
|
op.drop_table('budget_template')
|
||||||
|
op.drop_table('budget')
|
||||||
|
op.drop_table('notification_rule')
|
||||||
|
op.drop_table('logo_asset')
|
||||||
|
op.drop_index('ix_category_kind', table_name='category')
|
||||||
|
op.drop_table('category')
|
||||||
|
op.drop_table('app_user')
|
||||||
|
op.drop_index('ix_account_is_active', table_name='account')
|
||||||
|
op.drop_table('account')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Sammelrouter für alle /api-Endpunkte."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.routes import system
|
||||||
|
|
||||||
|
api_router = APIRouter(prefix="/api")
|
||||||
|
api_router.include_router(system.router)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Systemendpunkte: Health-Check und Versionsinformation (ohne Authentifizierung)."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.session import get_session
|
||||||
|
|
||||||
|
router = APIRouter(tags=["system"])
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
status: str = Field(description="'ok' wenn Anwendung und Datenbank erreichbar sind.")
|
||||||
|
database: str = Field(description="'ok' oder 'unavailable'.")
|
||||||
|
time: datetime = Field(description="Aktuelle Serverzeit in Europe/Berlin.")
|
||||||
|
|
||||||
|
|
||||||
|
class VersionResponse(BaseModel):
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
environment: str
|
||||||
|
timezone: str
|
||||||
|
currency: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health", response_model=HealthResponse, summary="Health-Check")
|
||||||
|
async def health(session: AsyncSession = Depends(get_session)) -> HealthResponse:
|
||||||
|
"""Prüft die Datenbankverbindung. Wird vom Docker-Healthcheck verwendet."""
|
||||||
|
database = "ok"
|
||||||
|
try:
|
||||||
|
await session.execute(text("SELECT 1"))
|
||||||
|
except Exception:
|
||||||
|
database = "unavailable"
|
||||||
|
return HealthResponse(
|
||||||
|
status="ok" if database == "ok" else "degraded",
|
||||||
|
database=database,
|
||||||
|
time=datetime.now(ZoneInfo(settings.timezone)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/version", response_model=VersionResponse, summary="Versionsinformation")
|
||||||
|
async def version() -> VersionResponse:
|
||||||
|
return VersionResponse(
|
||||||
|
name=settings.app_name,
|
||||||
|
version=settings.app_version,
|
||||||
|
environment=settings.environment,
|
||||||
|
timezone=settings.timezone,
|
||||||
|
currency=settings.default_currency,
|
||||||
|
)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Zentrale Anwendungskonfiguration über Umgebungsvariablen."""
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import Field, PostgresDsn, field_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Alle Einstellungen stammen aus der Umgebung bzw. einer .env-Datei."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
case_sensitive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Allgemein -----------------------------------------------------------
|
||||||
|
app_name: str = "moneyfy"
|
||||||
|
app_version: str = "0.1.0"
|
||||||
|
environment: Literal["development", "production", "test"] = "development"
|
||||||
|
debug: bool = False
|
||||||
|
timezone: str = "Europe/Berlin"
|
||||||
|
holiday_region: str = "DE-NW"
|
||||||
|
default_currency: str = "EUR"
|
||||||
|
|
||||||
|
# --- Datenbank -----------------------------------------------------------
|
||||||
|
database_url: PostgresDsn = Field(
|
||||||
|
default="postgresql+asyncpg://moneyfy:moneyfy@localhost:5432/moneyfy",
|
||||||
|
description="Async-DSN (asyncpg) für SQLAlchemy.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Sicherheit ----------------------------------------------------------
|
||||||
|
secret_key: str = Field(default="change-me-in-production", min_length=8)
|
||||||
|
access_token_ttl_minutes: int = 30
|
||||||
|
refresh_token_ttl_days: int = 14
|
||||||
|
cookie_secure: bool = True
|
||||||
|
cookie_domain: str | None = None
|
||||||
|
|
||||||
|
# Erst-Start: Admin-Benutzer
|
||||||
|
moneyfy_admin_user: str = "admin"
|
||||||
|
moneyfy_admin_password: str | None = None
|
||||||
|
|
||||||
|
# --- OIDC (vorbereitet, nicht implementiert) -----------------------------
|
||||||
|
oidc_enabled: bool = False
|
||||||
|
oidc_issuer: str | None = None
|
||||||
|
oidc_client_id: str | None = None
|
||||||
|
oidc_client_secret: str | None = None
|
||||||
|
oidc_scopes: str = "openid profile email"
|
||||||
|
|
||||||
|
# --- Logo-Service --------------------------------------------------------
|
||||||
|
logo_storage_dir: Path = Path("/data/logos")
|
||||||
|
logodev_api_key: str | None = None
|
||||||
|
brandfetch_api_key: str | None = None
|
||||||
|
logo_http_timeout_seconds: float = 5.0
|
||||||
|
logo_http_retries: int = 2
|
||||||
|
logo_max_upload_bytes: int = 1_048_576
|
||||||
|
|
||||||
|
# --- Benachrichtigungen --------------------------------------------------
|
||||||
|
notifications_enabled: bool = True
|
||||||
|
scheduler_enabled: bool = True
|
||||||
|
notification_hour: int = 7
|
||||||
|
notification_minute: int = 0
|
||||||
|
|
||||||
|
smtp_host: str | None = None
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_user: str | None = None
|
||||||
|
smtp_password: str | None = None
|
||||||
|
smtp_from: str | None = None
|
||||||
|
smtp_use_tls: bool = True
|
||||||
|
smtp_use_ssl: bool = False
|
||||||
|
|
||||||
|
apprise_urls: str | None = None
|
||||||
|
|
||||||
|
public_base_url: str = "http://localhost:8087"
|
||||||
|
|
||||||
|
# --- CORS ----------------------------------------------------------------
|
||||||
|
cors_origins: str = ""
|
||||||
|
|
||||||
|
@field_validator("database_url", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _force_async_driver(cls, value: object) -> object:
|
||||||
|
"""Erlaubt sowohl `postgresql://` als auch `postgresql+asyncpg://` in der Umgebung."""
|
||||||
|
if isinstance(value, str) and value.startswith("postgresql://"):
|
||||||
|
return value.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||||
|
return value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sync_database_url(self) -> str:
|
||||||
|
"""Synchrone Variante der DSN – wird von Alembic benötigt."""
|
||||||
|
return str(self.database_url).replace("+asyncpg", "+psycopg")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origin_list(self) -> list[str]:
|
||||||
|
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def apprise_url_list(self) -> list[str]:
|
||||||
|
if not self.apprise_urls:
|
||||||
|
return []
|
||||||
|
return [url.strip() for url in self.apprise_urls.split(",") if url.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""Gecachte Settings-Instanz."""
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Einheitliche Fehlerdarstellung im RFC-7807-Stil: {"detail": ..., "code": ...}."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, status
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
|
|
||||||
|
# Starlette benennt die 422-Konstante gerade um – fester Wert vermeidet die Abhängigkeit.
|
||||||
|
HTTP_422 = 422
|
||||||
|
|
||||||
|
|
||||||
|
class AppError(Exception):
|
||||||
|
"""Basisklasse für fachliche Fehler mit stabilem Fehlercode."""
|
||||||
|
|
||||||
|
status_code: int = status.HTTP_400_BAD_REQUEST
|
||||||
|
code: str = "bad_request"
|
||||||
|
|
||||||
|
def __init__(self, detail: str, *, code: str | None = None, status_code: int | None = None):
|
||||||
|
super().__init__(detail)
|
||||||
|
self.detail = detail
|
||||||
|
if code is not None:
|
||||||
|
self.code = code
|
||||||
|
if status_code is not None:
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(AppError):
|
||||||
|
status_code = status.HTTP_404_NOT_FOUND
|
||||||
|
code = "not_found"
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictError(AppError):
|
||||||
|
status_code = status.HTTP_409_CONFLICT
|
||||||
|
code = "conflict"
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(AppError):
|
||||||
|
status_code = HTTP_422
|
||||||
|
code = "validation_error"
|
||||||
|
|
||||||
|
|
||||||
|
class AuthError(AppError):
|
||||||
|
status_code = status.HTTP_401_UNAUTHORIZED
|
||||||
|
code = "unauthorized"
|
||||||
|
|
||||||
|
|
||||||
|
def problem(
|
||||||
|
status_code: int, detail: str, code: str, extra: dict[str, Any] | None = None
|
||||||
|
) -> JSONResponse:
|
||||||
|
body: dict[str, Any] = {"detail": detail, "code": code}
|
||||||
|
if extra:
|
||||||
|
body.update(extra)
|
||||||
|
return JSONResponse(status_code=status_code, content=body)
|
||||||
|
|
||||||
|
|
||||||
|
# Zuordnung der Standard-HTTP-Statuscodes auf sprechende Fehlercodes.
|
||||||
|
_STATUS_CODES = {
|
||||||
|
400: "bad_request",
|
||||||
|
401: "unauthorized",
|
||||||
|
403: "forbidden",
|
||||||
|
404: "not_found",
|
||||||
|
405: "method_not_allowed",
|
||||||
|
409: "conflict",
|
||||||
|
413: "payload_too_large",
|
||||||
|
415: "unsupported_media_type",
|
||||||
|
422: "validation_error",
|
||||||
|
429: "too_many_requests",
|
||||||
|
500: "internal_error",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def register_exception_handlers(app: FastAPI) -> None:
|
||||||
|
"""Registriert alle Handler, damit jede Fehlerantwort dasselbe Format hat."""
|
||||||
|
|
||||||
|
@app.exception_handler(AppError)
|
||||||
|
async def _app_error(_: Request, exc: AppError) -> JSONResponse:
|
||||||
|
return problem(exc.status_code, exc.detail, exc.code)
|
||||||
|
|
||||||
|
@app.exception_handler(StarletteHTTPException)
|
||||||
|
async def _http_error(_: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||||
|
code = _STATUS_CODES.get(exc.status_code, "error")
|
||||||
|
detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
|
||||||
|
return problem(exc.status_code, detail, code)
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def _validation_error(_: Request, exc: RequestValidationError) -> JSONResponse:
|
||||||
|
return problem(
|
||||||
|
HTTP_422,
|
||||||
|
"Die Anfrage enthält ungültige Felder.",
|
||||||
|
"validation_error",
|
||||||
|
{"errors": _serialise_errors(exc.errors())},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialise_errors(errors: list[Any]) -> list[dict[str, Any]]:
|
||||||
|
"""Pydantic-Fehler auf JSON-serialisierbare Felder reduzieren."""
|
||||||
|
result = []
|
||||||
|
for error in errors:
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"loc": [str(part) for part in error.get("loc", [])],
|
||||||
|
"msg": error.get("msg", ""),
|
||||||
|
"type": error.get("type", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Deklarative Basisklasse und gemeinsame Spaltentypen."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, MetaData, Numeric
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
# Feste Namenskonventionen, damit Alembic stabile Constraint-Namen erzeugt.
|
||||||
|
NAMING_CONVENTION = {
|
||||||
|
"ix": "ix_%(column_0_label)s",
|
||||||
|
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||||
|
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||||
|
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||||
|
"pk": "pk_%(table_name)s",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Alle Geldbeträge einheitlich als NUMERIC(12,2) -> Decimal.
|
||||||
|
Money = Numeric(12, 2, asdecimal=True)
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
"""Zeitstempel in UTC – die Anzeige rechnet nach Europe/Berlin um."""
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||||
|
|
||||||
|
type_annotation_map = { # noqa: RUF012
|
||||||
|
Decimal: Money,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
"""created_at/updated_at für Tabellen mit Änderungsverfolgung."""
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=utcnow, nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CreatedAtMixin:
|
||||||
|
"""Nur created_at für rein additive Tabellen."""
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=utcnow, nullable=False
|
||||||
|
)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Deutscher Standard-Kategoriebaum für den Erst-Seed."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from app.models.enums import EntryKind
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SeedCategory:
|
||||||
|
"""Ein Knoten des Seed-Baums. Unterkategorien erben `kind` vom Elternknoten."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
icon: str = "circle"
|
||||||
|
color: str = "#64748b"
|
||||||
|
is_fixed_cost: bool = False
|
||||||
|
children: tuple["SeedCategory", ...] = field(default_factory=tuple)
|
||||||
|
|
||||||
|
|
||||||
|
# Ausgaben ----------------------------------------------------------------------
|
||||||
|
EXPENSE_TREE: tuple[SeedCategory, ...] = (
|
||||||
|
SeedCategory(
|
||||||
|
name="Wohnen",
|
||||||
|
icon="house",
|
||||||
|
color="#f97316",
|
||||||
|
is_fixed_cost=True,
|
||||||
|
children=(
|
||||||
|
SeedCategory("Miete", "key-round", "#f97316", True),
|
||||||
|
SeedCategory("Nebenkosten", "droplets", "#fb923c", True),
|
||||||
|
SeedCategory("Strom", "zap", "#facc15", True),
|
||||||
|
SeedCategory("Internet", "wifi", "#38bdf8", True),
|
||||||
|
SeedCategory("Rundfunkbeitrag", "radio", "#a78bfa", True),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SeedCategory(
|
||||||
|
name="Versicherungen",
|
||||||
|
icon="shield",
|
||||||
|
color="#0ea5e9",
|
||||||
|
is_fixed_cost=True,
|
||||||
|
children=(
|
||||||
|
SeedCategory("Haftpflicht", "shield-check", "#0ea5e9", True),
|
||||||
|
SeedCategory("Hausrat", "shield-half", "#22d3ee", True),
|
||||||
|
SeedCategory("Kfz", "car-front", "#38bdf8", True),
|
||||||
|
SeedCategory("Kranken", "heart-pulse", "#f472b6", True),
|
||||||
|
SeedCategory("BU", "briefcase-medical", "#818cf8", True),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SeedCategory(
|
||||||
|
name="Abos & Medien",
|
||||||
|
icon="repeat",
|
||||||
|
color="#a855f7",
|
||||||
|
is_fixed_cost=True,
|
||||||
|
children=(
|
||||||
|
SeedCategory("Streaming", "clapperboard", "#e11d48", True),
|
||||||
|
SeedCategory("Software", "app-window", "#8b5cf6", True),
|
||||||
|
SeedCategory("Cloud", "cloud", "#60a5fa", True),
|
||||||
|
SeedCategory("Zeitungen", "newspaper", "#94a3b8", True),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SeedCategory(
|
||||||
|
name="Mobilität",
|
||||||
|
icon="car",
|
||||||
|
color="#14b8a6",
|
||||||
|
children=(
|
||||||
|
SeedCategory("Kfz-Steuer", "landmark", "#14b8a6", True),
|
||||||
|
SeedCategory("Sprit", "fuel", "#f59e0b"),
|
||||||
|
SeedCategory("ÖPNV", "bus", "#10b981", True),
|
||||||
|
SeedCategory("Werkstatt", "wrench", "#64748b"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SeedCategory(
|
||||||
|
name="Lebenshaltung",
|
||||||
|
icon="shopping-basket",
|
||||||
|
color="#84cc16",
|
||||||
|
children=(
|
||||||
|
SeedCategory("Lebensmittel", "shopping-cart", "#84cc16"),
|
||||||
|
SeedCategory("Drogerie", "sparkles", "#22c55e"),
|
||||||
|
SeedCategory("Restaurant", "utensils", "#f97316"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SeedCategory(
|
||||||
|
name="Finanzen",
|
||||||
|
icon="banknote",
|
||||||
|
color="#6366f1",
|
||||||
|
children=(
|
||||||
|
SeedCategory("Kredite", "hand-coins", "#ef4444", True),
|
||||||
|
SeedCategory("Sparen", "piggy-bank", "#10b981", True),
|
||||||
|
SeedCategory("Gebühren", "receipt", "#94a3b8", True),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Einkünfte ---------------------------------------------------------------------
|
||||||
|
INCOME_TREE: tuple[SeedCategory, ...] = (
|
||||||
|
SeedCategory(
|
||||||
|
name="Einkünfte",
|
||||||
|
icon="wallet",
|
||||||
|
color="#22c55e",
|
||||||
|
children=(
|
||||||
|
SeedCategory("Gehalt", "briefcase", "#22c55e"),
|
||||||
|
SeedCategory("Nebeneinkünfte", "laptop", "#4ade80"),
|
||||||
|
SeedCategory("Erstattungen", "undo-2", "#34d399"),
|
||||||
|
SeedCategory("Zinsen", "percent", "#a3e635"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
SEED_TREE: tuple[tuple[EntryKind, tuple[SeedCategory, ...]], ...] = (
|
||||||
|
(EntryKind.EXPENSE, EXPENSE_TREE),
|
||||||
|
(EntryKind.INCOME, INCOME_TREE),
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Async-Engine und Session-Factory."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
str(settings.database_url),
|
||||||
|
echo=settings.debug,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = async_sessionmaker(
|
||||||
|
bind=engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
autoflush=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""FastAPI-Dependency: eine Session pro Request."""
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Einstiegspunkt der moneyfy-Anwendung."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.api.router import api_router
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.errors import register_exception_handlers
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG if settings.debug else logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("moneyfy")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
|
"""Start- und Stopplogik der Anwendung."""
|
||||||
|
settings.logo_storage_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger.info("moneyfy %s gestartet (%s)", settings.app_version, settings.environment)
|
||||||
|
yield
|
||||||
|
logger.info("moneyfy wird beendet")
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
app = FastAPI(
|
||||||
|
title="moneyfy",
|
||||||
|
version=settings.app_version,
|
||||||
|
description="Planung monatlicher Kosten und Einkünfte.",
|
||||||
|
openapi_url="/api/openapi.json",
|
||||||
|
docs_url="/api/docs",
|
||||||
|
redoc_url=None,
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.cors_origin_list:
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origin_list,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
register_exception_handlers(app)
|
||||||
|
app.include_router(api_router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""SQLAlchemy-Modelle. Import hier hält Alembics Autogenerate vollständig."""
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.models.core import Account, AppUser, Category, LogoAsset, Merchant
|
||||||
|
from app.models.enums import (
|
||||||
|
AccountType,
|
||||||
|
BusinessDayShift,
|
||||||
|
EntryKind,
|
||||||
|
LogoSource,
|
||||||
|
LogoStatus,
|
||||||
|
NotificationChannel,
|
||||||
|
NotificationStatus,
|
||||||
|
NotificationType,
|
||||||
|
OccurrenceStatus,
|
||||||
|
)
|
||||||
|
from app.models.finance import (
|
||||||
|
AmountVersion,
|
||||||
|
Budget,
|
||||||
|
BudgetTemplate,
|
||||||
|
Occurrence,
|
||||||
|
Recurrence,
|
||||||
|
ReserveLedger,
|
||||||
|
SavingsGoal,
|
||||||
|
Transaction,
|
||||||
|
)
|
||||||
|
from app.models.notification import NotificationLog, NotificationRule
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Account",
|
||||||
|
"AccountType",
|
||||||
|
"AmountVersion",
|
||||||
|
"AppUser",
|
||||||
|
"Base",
|
||||||
|
"Budget",
|
||||||
|
"BudgetTemplate",
|
||||||
|
"BusinessDayShift",
|
||||||
|
"Category",
|
||||||
|
"EntryKind",
|
||||||
|
"LogoAsset",
|
||||||
|
"LogoSource",
|
||||||
|
"LogoStatus",
|
||||||
|
"Merchant",
|
||||||
|
"NotificationChannel",
|
||||||
|
"NotificationLog",
|
||||||
|
"NotificationRule",
|
||||||
|
"NotificationStatus",
|
||||||
|
"NotificationType",
|
||||||
|
"Occurrence",
|
||||||
|
"OccurrenceStatus",
|
||||||
|
"Recurrence",
|
||||||
|
"ReserveLedger",
|
||||||
|
"SavingsGoal",
|
||||||
|
"Transaction",
|
||||||
|
]
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Stammdaten: Konten, Kategorien, Firmen, Logos, Benutzer."""
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, CreatedAtMixin, Money, TimestampMixin
|
||||||
|
from app.models.enums import (
|
||||||
|
AccountType,
|
||||||
|
EntryKind,
|
||||||
|
LogoSource,
|
||||||
|
LogoStatus,
|
||||||
|
pg_enum,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Account(Base, TimestampMixin):
|
||||||
|
"""Ein Konto.
|
||||||
|
|
||||||
|
Der Saldo wird aus Eröffnungssaldo plus allen bestätigten Buchungen fortgeschrieben.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "account"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
type: Mapped[AccountType] = mapped_column(
|
||||||
|
pg_enum(AccountType, "account_type"), nullable=False, default=AccountType.CHECKING
|
||||||
|
)
|
||||||
|
iban_last4: Mapped[str | None] = mapped_column(String(4), nullable=True)
|
||||||
|
opening_balance: Mapped[Decimal] = mapped_column(Money, nullable=False, default=Decimal("0.00"))
|
||||||
|
opening_balance_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3b82f6")
|
||||||
|
icon: Mapped[str] = mapped_column(String(64), nullable=False, default="wallet")
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("name", name="uq_account_name"),
|
||||||
|
Index("ix_account_is_active", "is_active"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Category(Base):
|
||||||
|
"""Zweistufiger Kategoriebaum – Unterkategorien haben genau einen Elternknoten."""
|
||||||
|
|
||||||
|
__tablename__ = "category"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
parent_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("category.id", ondelete="RESTRICT"), nullable=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
kind: Mapped[EntryKind] = mapped_column(pg_enum(EntryKind, "entry_kind"), nullable=False)
|
||||||
|
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#64748b")
|
||||||
|
icon: Mapped[str] = mapped_column(String(64), nullable=False, default="circle")
|
||||||
|
is_fixed_cost: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
parent: Mapped["Category | None"] = relationship(
|
||||||
|
remote_side="Category.id", back_populates="children"
|
||||||
|
)
|
||||||
|
children: Mapped[list["Category"]] = relationship(
|
||||||
|
back_populates="parent", cascade="save-update, merge", order_by="Category.sort_order"
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("parent_id", "name", name="uq_category_parent_id"),
|
||||||
|
Index("ix_category_kind", "kind"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LogoAsset(Base):
|
||||||
|
"""Im Volume abgelegtes Logo. Auslieferung erfolgt ausschließlich lokal."""
|
||||||
|
|
||||||
|
__tablename__ = "logo_asset"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
sha256: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||||
|
mime: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
height: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
file_path: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Merchant(Base, CreatedAtMixin):
|
||||||
|
"""Firma bzw. Zahlungsempfänger inklusive Markenlogo und -farbe."""
|
||||||
|
|
||||||
|
__tablename__ = "merchant"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||||
|
normalized_name: Mapped[str] = mapped_column(String(160), nullable=False, unique=True)
|
||||||
|
domain: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
aliases: Mapped[list[str]] = mapped_column(
|
||||||
|
ARRAY(Text), nullable=False, default=list, server_default="{}"
|
||||||
|
)
|
||||||
|
logo_asset_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("logo_asset.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
brand_color: Mapped[str | None] = mapped_column(String(9), nullable=True)
|
||||||
|
brand_color_dark: Mapped[str | None] = mapped_column(String(9), nullable=True)
|
||||||
|
logo_source: Mapped[LogoSource | None] = mapped_column(
|
||||||
|
pg_enum(LogoSource, "logo_source"), nullable=True
|
||||||
|
)
|
||||||
|
logo_status: Mapped[LogoStatus] = mapped_column(
|
||||||
|
pg_enum(LogoStatus, "logo_status"), nullable=False, default=LogoStatus.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
logo_asset: Mapped[LogoAsset | None] = relationship(lazy="joined")
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_merchant_normalized_name", "normalized_name"),)
|
||||||
|
|
||||||
|
|
||||||
|
class AppUser(Base, CreatedAtMixin):
|
||||||
|
"""Single-User-Betrieb; `external_subject` ist für eine spätere OIDC-Anbindung vorgesehen."""
|
||||||
|
|
||||||
|
__tablename__ = "app_user"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(120), nullable=False, unique=True)
|
||||||
|
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
external_subject: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
|
||||||
|
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"password_hash IS NOT NULL OR external_subject IS NOT NULL",
|
||||||
|
name="local_or_external_login",
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Aufzählungstypen des Datenmodells (werden als native PostgreSQL-Enums angelegt)."""
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from sqlalchemy import Enum as SAEnum
|
||||||
|
|
||||||
|
|
||||||
|
class AccountType(StrEnum):
|
||||||
|
CHECKING = "checking"
|
||||||
|
CREDIT_CARD = "credit_card"
|
||||||
|
SAVINGS = "savings"
|
||||||
|
CASH = "cash"
|
||||||
|
|
||||||
|
|
||||||
|
class EntryKind(StrEnum):
|
||||||
|
"""Richtung eines Postens: Ausgabe oder Einnahme."""
|
||||||
|
|
||||||
|
EXPENSE = "expense"
|
||||||
|
INCOME = "income"
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessDayShift(StrEnum):
|
||||||
|
"""Verschiebung, wenn ein Fälligkeitstag auf Wochenende/Feiertag fällt."""
|
||||||
|
|
||||||
|
NONE = "none"
|
||||||
|
NEXT = "next"
|
||||||
|
PREVIOUS = "previous"
|
||||||
|
|
||||||
|
|
||||||
|
class OccurrenceStatus(StrEnum):
|
||||||
|
PLANNED = "planned"
|
||||||
|
CONFIRMED = "confirmed"
|
||||||
|
SKIPPED = "skipped"
|
||||||
|
|
||||||
|
|
||||||
|
class LogoSource(StrEnum):
|
||||||
|
SIMPLE_ICONS = "simple-icons"
|
||||||
|
LOGODEV = "logodev"
|
||||||
|
BRANDFETCH = "brandfetch"
|
||||||
|
FAVICON = "favicon"
|
||||||
|
UPLOAD = "upload"
|
||||||
|
GENERATED = "generated"
|
||||||
|
|
||||||
|
|
||||||
|
class LogoStatus(StrEnum):
|
||||||
|
PENDING = "pending"
|
||||||
|
RESOLVED = "resolved"
|
||||||
|
FAILED = "failed"
|
||||||
|
MANUAL = "manual"
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationType(StrEnum):
|
||||||
|
DUE_SOON = "due_soon"
|
||||||
|
NOTICE_DEADLINE = "notice_deadline"
|
||||||
|
BUDGET_EXCEEDED = "budget_exceeded"
|
||||||
|
CONTRACT_RENEWAL = "contract_renewal"
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationChannel(StrEnum):
|
||||||
|
SMTP = "smtp"
|
||||||
|
APPRISE = "apprise"
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationStatus(StrEnum):
|
||||||
|
SENT = "sent"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def pg_enum(enum_cls: type[StrEnum], name: str) -> SAEnum:
|
||||||
|
"""Erzeugt einen nativen PostgreSQL-Enum-Typ mit den String-Werten der Enum-Klasse."""
|
||||||
|
return SAEnum(
|
||||||
|
enum_cls,
|
||||||
|
name=name,
|
||||||
|
native_enum=True,
|
||||||
|
values_callable=lambda cls: [member.value for member in cls],
|
||||||
|
validate_strings=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""Bewegungsdaten: Recurrences, Preishistorie, Occurrences, Buchungen, Budgets, Ziele."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
Date,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, CreatedAtMixin, Money, TimestampMixin
|
||||||
|
from app.models.core import Account, Category, Merchant
|
||||||
|
from app.models.enums import BusinessDayShift, EntryKind, OccurrenceStatus, pg_enum
|
||||||
|
|
||||||
|
|
||||||
|
class Recurrence(Base, TimestampMixin):
|
||||||
|
"""Wiederkehrende Zahlung oder Einkunft, beschrieben durch eine RFC-5545-RRULE."""
|
||||||
|
|
||||||
|
__tablename__ = "recurrence"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
kind: Mapped[EntryKind] = mapped_column(pg_enum(EntryKind, "entry_kind"), nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||||
|
merchant_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("merchant.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
category_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("category.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
account_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("account.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||||
|
is_variable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
|
||||||
|
|
||||||
|
# RRULE ohne DTSTART – der Startzeitpunkt steht separat in `dtstart`.
|
||||||
|
rrule: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
dtstart: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
until: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
|
||||||
|
business_day_shift: Mapped[BusinessDayShift] = mapped_column(
|
||||||
|
pg_enum(BusinessDayShift, "business_day_shift"),
|
||||||
|
nullable=False,
|
||||||
|
default=BusinessDayShift.NEXT,
|
||||||
|
server_default="next",
|
||||||
|
)
|
||||||
|
holiday_region: Mapped[str] = mapped_column(
|
||||||
|
String(8), nullable=False, default="DE-NW", server_default="DE-NW"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ratenzahlung / Kredit
|
||||||
|
installments_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
principal_amount: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||||
|
|
||||||
|
# Vertragsdaten
|
||||||
|
contract_start: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
contract_min_term_months: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
contract_notice_period_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
contract_auto_renew_months: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
contract_cancelled_at: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
|
||||||
|
reserve_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
tags: Mapped[list[str]] = mapped_column(
|
||||||
|
ARRAY(Text), nullable=False, default=list, server_default="{}"
|
||||||
|
)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
merchant: Mapped[Merchant | None] = relationship(lazy="selectin")
|
||||||
|
category: Mapped[Category] = relationship(lazy="selectin")
|
||||||
|
account: Mapped[Account] = relationship(lazy="selectin")
|
||||||
|
amount_versions: Mapped[list["AmountVersion"]] = relationship(
|
||||||
|
back_populates="recurrence",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="AmountVersion.valid_from",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
occurrences: Mapped[list["Occurrence"]] = relationship(
|
||||||
|
back_populates="recurrence",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="Occurrence.occurrence_date",
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"installments_total IS NULL OR installments_total > 0", name="installments"
|
||||||
|
),
|
||||||
|
Index("ix_recurrence_kind_active", "kind", "is_active"),
|
||||||
|
Index("ix_recurrence_category_id", "category_id"),
|
||||||
|
Index("ix_recurrence_merchant_id", "merchant_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AmountVersion(Base, CreatedAtMixin):
|
||||||
|
"""Preishistorie: gültig ist die Version mit dem größten `valid_from <= Fälligkeitsdatum`."""
|
||||||
|
|
||||||
|
__tablename__ = "amount_version"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
recurrence_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("recurrence.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||||
|
valid_from: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
recurrence: Mapped[Recurrence] = relationship(back_populates="amount_versions")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("recurrence_id", "valid_from", name="uq_amount_version_recurrence_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Occurrence(Base, TimestampMixin):
|
||||||
|
"""Materialisierte Einzelfälligkeit – nur bei Abweichung, Bestätigung oder Auslassung."""
|
||||||
|
|
||||||
|
__tablename__ = "occurrence"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
recurrence_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("recurrence.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
# Immer das nominale (unverschobene) Datum – dadurch bleibt die Zuordnung stabil.
|
||||||
|
occurrence_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
status: Mapped[OccurrenceStatus] = mapped_column(
|
||||||
|
pg_enum(OccurrenceStatus, "occurrence_status"),
|
||||||
|
nullable=False,
|
||||||
|
default=OccurrenceStatus.PLANNED,
|
||||||
|
)
|
||||||
|
planned_amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||||
|
actual_amount: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||||
|
actual_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
account_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("account.id", ondelete="RESTRICT"), nullable=True
|
||||||
|
)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
recurrence: Mapped[Recurrence] = relationship(back_populates="occurrences")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("recurrence_id", "occurrence_date", name="uq_occurrence_recurrence_id"),
|
||||||
|
Index("ix_occurrence_date", "occurrence_date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Transaction(Base, CreatedAtMixin):
|
||||||
|
"""Einmalige Buchung ohne Wiederholung."""
|
||||||
|
|
||||||
|
__tablename__ = "transaction"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
kind: Mapped[EntryKind] = mapped_column(pg_enum(EntryKind, "entry_kind"), nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||||
|
merchant_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("merchant.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
category_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("category.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
account_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("account.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||||
|
booking_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
tags: Mapped[list[str]] = mapped_column(
|
||||||
|
ARRAY(Text), nullable=False, default=list, server_default="{}"
|
||||||
|
)
|
||||||
|
|
||||||
|
merchant: Mapped[Merchant | None] = relationship(lazy="selectin")
|
||||||
|
category: Mapped[Category] = relationship(lazy="selectin")
|
||||||
|
account: Mapped[Account] = relationship(lazy="selectin")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_transaction_booking_date", "booking_date"),
|
||||||
|
Index("ix_transaction_category_id", "category_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Budget(Base, CreatedAtMixin):
|
||||||
|
"""Monatsbudget je Kategorie. `period_month` ist immer der Monatserste."""
|
||||||
|
|
||||||
|
__tablename__ = "budget"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
category_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("category.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
period_month: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
limit_amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||||
|
rollover: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
category: Mapped[Category] = relationship(lazy="selectin")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("category_id", "period_month", name="uq_budget_category_id"),
|
||||||
|
CheckConstraint("date_trunc('month', period_month) = period_month", name="month_start"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetTemplate(Base, TimestampMixin):
|
||||||
|
"""Dauerhaftes Budget ab einem Monat – erspart die Pflege jedes einzelnen Monats."""
|
||||||
|
|
||||||
|
__tablename__ = "budget_template"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
category_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("category.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
valid_from: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
valid_until: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
limit_amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||||
|
rollover: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
category: Mapped[Category] = relationship(lazy="selectin")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("category_id", "valid_from", name="uq_budget_template_category_id"),
|
||||||
|
CheckConstraint("date_trunc('month', valid_from) = valid_from", name="month_start"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SavingsGoal(Base, TimestampMixin):
|
||||||
|
"""Sparziel mit Fortschritt und optionaler Zielrate."""
|
||||||
|
|
||||||
|
__tablename__ = "savings_goal"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||||
|
target_amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||||
|
target_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
current_amount: Mapped[Decimal] = mapped_column(Money, nullable=False, default=Decimal("0.00"))
|
||||||
|
account_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("account.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
monthly_contribution: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||||
|
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#10b981")
|
||||||
|
icon: Mapped[str] = mapped_column(String(64), nullable=False, default="piggy-bank")
|
||||||
|
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("name", name="uq_savings_goal_name"),)
|
||||||
|
|
||||||
|
|
||||||
|
class ReserveLedger(Base, CreatedAtMixin):
|
||||||
|
"""Monatliche Rücklagenbildung für nicht-monatliche Posten."""
|
||||||
|
|
||||||
|
__tablename__ = "reserve_ledger"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
recurrence_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("recurrence.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
period_month: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||||
|
is_released: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("recurrence_id", "period_month", name="uq_reserve_ledger_recurrence_id"),
|
||||||
|
CheckConstraint("date_trunc('month', period_month) = period_month", name="month_start"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Benachrichtigungsregeln und Versandprotokoll."""
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, CreatedAtMixin
|
||||||
|
from app.models.enums import (
|
||||||
|
NotificationChannel,
|
||||||
|
NotificationStatus,
|
||||||
|
NotificationType,
|
||||||
|
pg_enum,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationRule(Base, CreatedAtMixin):
|
||||||
|
"""Eine Regel pro Ereignistyp und Kanal."""
|
||||||
|
|
||||||
|
__tablename__ = "notification_rule"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
type: Mapped[NotificationType] = mapped_column(
|
||||||
|
pg_enum(NotificationType, "notification_type"), nullable=False
|
||||||
|
)
|
||||||
|
lead_days: Mapped[int] = mapped_column(Integer, nullable=False, default=7)
|
||||||
|
channel: Mapped[NotificationChannel] = mapped_column(
|
||||||
|
pg_enum(NotificationChannel, "notification_channel"), nullable=False
|
||||||
|
)
|
||||||
|
target: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
logs: Mapped[list["NotificationLog"]] = relationship(
|
||||||
|
back_populates="rule", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationLog(Base):
|
||||||
|
"""Versandprotokoll. `dedupe_day` verhindert Doppelversand am selben Tag."""
|
||||||
|
|
||||||
|
__tablename__ = "notification_log"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
rule_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("notification_rule.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
ref_type: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
|
ref_id: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
|
dedupe_day: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
status: Mapped[NotificationStatus] = mapped_column(
|
||||||
|
pg_enum(NotificationStatus, "notification_status"), nullable=False
|
||||||
|
)
|
||||||
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
rule: Mapped[NotificationRule] = relationship(back_populates="logs")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"rule_id", "ref_type", "ref_id", "dedupe_day", name="uq_notification_log_rule_id"
|
||||||
|
),
|
||||||
|
Index("ix_notification_log_sent_at", "sent_at"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""CLI: Stammdaten seeden – `python -m app.scripts.seed`."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.db.session import SessionLocal, engine
|
||||||
|
from app.services.seed import seed_all
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
result = await seed_all(session)
|
||||||
|
await engine.dispose()
|
||||||
|
print(
|
||||||
|
f"Seed fertig: {result['categories']} Kategorien, "
|
||||||
|
f"{result['notification_rules']} Benachrichtigungsregeln neu angelegt."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Idempotenter Seed der Stammdaten (Kategoriebaum, Benachrichtigungsregeln)."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.seed_data import SEED_TREE, SeedCategory
|
||||||
|
from app.models import Category, NotificationRule
|
||||||
|
from app.models.enums import EntryKind, NotificationChannel, NotificationType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Standardregeln: Vorlaufzeiten gemäß Fachspezifikation, zunächst inaktiv per SMTP.
|
||||||
|
DEFAULT_RULES: tuple[tuple[NotificationType, int], ...] = (
|
||||||
|
(NotificationType.DUE_SOON, 3),
|
||||||
|
(NotificationType.NOTICE_DEADLINE, 30),
|
||||||
|
(NotificationType.BUDGET_EXCEEDED, 0),
|
||||||
|
(NotificationType.CONTRACT_RENEWAL, 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_categories(session: AsyncSession) -> int:
|
||||||
|
"""Legt den Standardbaum an. Bereits vorhandene Kategorien bleiben unverändert."""
|
||||||
|
created = 0
|
||||||
|
for kind, nodes in SEED_TREE:
|
||||||
|
for order, node in enumerate(nodes):
|
||||||
|
parent, was_created = await _upsert(session, node, kind, None, order)
|
||||||
|
created += int(was_created)
|
||||||
|
for child_order, child in enumerate(node.children):
|
||||||
|
_, child_created = await _upsert(session, child, kind, parent.id, child_order)
|
||||||
|
created += int(child_created)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
async def _upsert(
|
||||||
|
session: AsyncSession,
|
||||||
|
node: SeedCategory,
|
||||||
|
kind: EntryKind,
|
||||||
|
parent_id: int | None,
|
||||||
|
sort_order: int,
|
||||||
|
) -> tuple[Category, bool]:
|
||||||
|
"""Sucht die Kategorie über (parent_id, name) und legt sie bei Bedarf an."""
|
||||||
|
stmt = select(Category).where(Category.name == node.name)
|
||||||
|
stmt = stmt.where(
|
||||||
|
Category.parent_id.is_(None) if parent_id is None else Category.parent_id == parent_id
|
||||||
|
)
|
||||||
|
existing = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return existing, False
|
||||||
|
|
||||||
|
category = Category(
|
||||||
|
parent_id=parent_id,
|
||||||
|
name=node.name,
|
||||||
|
kind=kind,
|
||||||
|
color=node.color,
|
||||||
|
icon=node.icon,
|
||||||
|
is_fixed_cost=node.is_fixed_cost,
|
||||||
|
sort_order=sort_order,
|
||||||
|
)
|
||||||
|
session.add(category)
|
||||||
|
await session.flush()
|
||||||
|
return category, True
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_notification_rules(session: AsyncSession) -> int:
|
||||||
|
"""Legt je Ereignistyp eine inaktive SMTP-Standardregel an."""
|
||||||
|
created = 0
|
||||||
|
for rule_type, lead_days in DEFAULT_RULES:
|
||||||
|
stmt = select(NotificationRule).where(
|
||||||
|
NotificationRule.type == rule_type,
|
||||||
|
NotificationRule.channel == NotificationChannel.SMTP,
|
||||||
|
)
|
||||||
|
if (await session.execute(stmt)).scalar_one_or_none() is not None:
|
||||||
|
continue
|
||||||
|
session.add(
|
||||||
|
NotificationRule(
|
||||||
|
type=rule_type,
|
||||||
|
lead_days=lead_days,
|
||||||
|
channel=NotificationChannel.SMTP,
|
||||||
|
is_active=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
created += 1
|
||||||
|
await session.flush()
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_all(session: AsyncSession) -> dict[str, int]:
|
||||||
|
"""Führt alle Seeds aus und liefert die Anzahl neu angelegter Datensätze."""
|
||||||
|
result = {
|
||||||
|
"categories": await seed_categories(session),
|
||||||
|
"notification_rules": await seed_notification_rules(session),
|
||||||
|
}
|
||||||
|
await session.commit()
|
||||||
|
logger.info("Seed abgeschlossen: %s", result)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
[project]
|
||||||
|
name = "moneyfy-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "moneyfy – Planung monatlicher Kosten und Einkünfte"
|
||||||
|
requires-python = ">=3.12,<3.13"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115",
|
||||||
|
"uvicorn[standard]>=0.32",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.36",
|
||||||
|
"asyncpg>=0.30",
|
||||||
|
"alembic>=1.14",
|
||||||
|
"pydantic>=2.10",
|
||||||
|
"pydantic-settings>=2.7",
|
||||||
|
"python-dateutil>=2.9",
|
||||||
|
"holidays>=0.63",
|
||||||
|
"apscheduler>=3.11",
|
||||||
|
"argon2-cffi>=23.1",
|
||||||
|
"pyjwt>=2.10",
|
||||||
|
"httpx>=0.28",
|
||||||
|
"pillow>=11.0",
|
||||||
|
"openpyxl>=3.1",
|
||||||
|
"apprise>=1.9",
|
||||||
|
"python-multipart>=0.0.20",
|
||||||
|
"email-validator>=2.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.3",
|
||||||
|
"pytest-asyncio>=0.24",
|
||||||
|
"ruff>=0.8",
|
||||||
|
"psycopg[binary]>=3.2",
|
||||||
|
"freezegun>=1.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=75"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["app*"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
|
extend-exclude = ["alembic/versions"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B", "SIM", "C4", "N", "RUF"]
|
||||||
|
ignore = ["B008", "RUF001", "RUF002", "RUF003"]
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
asyncio_default_fixture_loop_scope = "session"
|
||||||
|
asyncio_default_test_loop_scope = "session"
|
||||||
|
filterwarnings = ["ignore::DeprecationWarning"]
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Gemeinsame Test-Fixtures. Nutzt eine separate Testdatenbank."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Muss vor dem ersten Import der Anwendung gesetzt sein, da die Settings gecacht werden.
|
||||||
|
os.environ.setdefault(
|
||||||
|
"DATABASE_URL",
|
||||||
|
os.environ.get(
|
||||||
|
"TEST_DATABASE_URL",
|
||||||
|
"postgresql+asyncpg://moneyfy:moneyfy@127.0.0.1:5432/moneyfy_test",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
os.environ.setdefault("SECRET_KEY", "test-secret-key")
|
||||||
|
os.environ.setdefault("ENVIRONMENT", "test")
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.main import app as fastapi_app
|
||||||
|
from app.models import Base
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def anyio_backend() -> str:
|
||||||
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def engine():
|
||||||
|
"""Legt das Schema einmal pro Testlauf frisch an."""
|
||||||
|
test_engine = create_async_engine(str(settings.database_url), poolclass=None)
|
||||||
|
async with test_engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield test_engine
|
||||||
|
await test_engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def session(engine) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Eine Session je Test, am Ende wird zurückgerollt."""
|
||||||
|
connection = await engine.connect()
|
||||||
|
transaction = await connection.begin()
|
||||||
|
# `create_savepoint` sorgt dafür, dass session.commit() nur den Savepoint freigibt
|
||||||
|
# und der abschließende Rollback wirklich alle Testdaten entfernt.
|
||||||
|
maker = async_sessionmaker(
|
||||||
|
bind=connection,
|
||||||
|
expire_on_commit=False,
|
||||||
|
autoflush=False,
|
||||||
|
join_transaction_mode="create_savepoint",
|
||||||
|
)
|
||||||
|
async with maker() as db_session:
|
||||||
|
yield db_session
|
||||||
|
await transaction.rollback()
|
||||||
|
await connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client(session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||||
|
"""HTTP-Client gegen die App, alle Requests laufen in der Test-Transaktion."""
|
||||||
|
|
||||||
|
async def _override() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
fastapi_app.dependency_overrides[get_session] = _override
|
||||||
|
transport = ASGITransport(app=fastapi_app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
||||||
|
yield http_client
|
||||||
|
fastapi_app.dependency_overrides.clear()
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Tests des Stammdaten-Seeds."""
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models import Category, NotificationRule
|
||||||
|
from app.models.enums import EntryKind
|
||||||
|
from app.services.seed import seed_all
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_erzeugt_kategoriebaum(session: AsyncSession) -> None:
|
||||||
|
await seed_all(session)
|
||||||
|
|
||||||
|
total = (await session.execute(select(func.count()).select_from(Category))).scalar_one()
|
||||||
|
assert total == 35
|
||||||
|
|
||||||
|
roots = (
|
||||||
|
(await session.execute(select(Category).where(Category.parent_id.is_(None))))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert {root.name for root in roots} == {
|
||||||
|
"Wohnen",
|
||||||
|
"Versicherungen",
|
||||||
|
"Abos & Medien",
|
||||||
|
"Mobilität",
|
||||||
|
"Lebenshaltung",
|
||||||
|
"Finanzen",
|
||||||
|
"Einkünfte",
|
||||||
|
}
|
||||||
|
|
||||||
|
einkuenfte = next(root for root in roots if root.name == "Einkünfte")
|
||||||
|
assert einkuenfte.kind is EntryKind.INCOME
|
||||||
|
|
||||||
|
miete = (await session.execute(select(Category).where(Category.name == "Miete"))).scalar_one()
|
||||||
|
assert miete.is_fixed_cost is True
|
||||||
|
assert miete.kind is EntryKind.EXPENSE
|
||||||
|
assert miete.parent_id is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_ist_idempotent(session: AsyncSession) -> None:
|
||||||
|
first = await seed_all(session)
|
||||||
|
second = await seed_all(session)
|
||||||
|
|
||||||
|
assert first["categories"] == 35
|
||||||
|
assert second["categories"] == 0
|
||||||
|
assert second["notification_rules"] == 0
|
||||||
|
|
||||||
|
total = (await session.execute(select(func.count()).select_from(Category))).scalar_one()
|
||||||
|
assert total == 35
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_legt_benachrichtigungsregeln_an(session: AsyncSession) -> None:
|
||||||
|
await seed_all(session)
|
||||||
|
rules = (await session.execute(select(NotificationRule))).scalars().all()
|
||||||
|
assert len(rules) == 4
|
||||||
|
assert all(rule.is_active is False for rule in rules)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Tests der Systemendpunkte und des Fehlerformats."""
|
||||||
|
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
async def test_health_meldet_datenbank_ok(client: AsyncClient) -> None:
|
||||||
|
response = await client.get("/api/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
assert body["database"] == "ok"
|
||||||
|
# Zeitstempel muss in Europe/Berlin geliefert werden (+01:00 oder +02:00).
|
||||||
|
assert body["time"].endswith(("+01:00", "+02:00"))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_version_liefert_stammdaten(client: AsyncClient) -> None:
|
||||||
|
body = (await client.get("/api/version")).json()
|
||||||
|
assert body["name"] == "moneyfy"
|
||||||
|
assert body["timezone"] == "Europe/Berlin"
|
||||||
|
assert body["currency"] == "EUR"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unbekannte_route_liefert_fehlerformat(client: AsyncClient) -> None:
|
||||||
|
response = await client.get("/api/gibt-es-nicht")
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json() == {"detail": "Not Found", "code": "not_found"}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Lokale Entwicklung: Hot Reload und offene Ports.
|
||||||
|
# Kopieren nach `docker-compose.override.yml` – die Datei wird nicht eingecheckt.
|
||||||
|
services:
|
||||||
|
moneyfy-db:
|
||||||
|
# Datenbank direkt erreichbar, z. B. für psql oder einen SQL-Client.
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432"
|
||||||
|
|
||||||
|
moneyfy-backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
target: development
|
||||||
|
image: moneyfy-backend:dev
|
||||||
|
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
|
environment:
|
||||||
|
ENVIRONMENT: development
|
||||||
|
DEBUG: "true"
|
||||||
|
COOKIE_SECURE: "false"
|
||||||
|
SCHEDULER_ENABLED: "false"
|
||||||
|
CORS_ORIGINS: http://localhost:5173
|
||||||
|
volumes:
|
||||||
|
# Quellcode einblenden, damit --reload greift.
|
||||||
|
- ./backend/app:/app/app:ro
|
||||||
|
- ./backend/alembic:/app/alembic:ro
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8000:8000"
|
||||||
|
|
||||||
|
moneyfy-frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
target: development
|
||||||
|
image: moneyfy-frontend:dev
|
||||||
|
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
|
||||||
|
environment:
|
||||||
|
VITE_API_BASE_URL: http://localhost:8000
|
||||||
|
volumes:
|
||||||
|
- ./frontend/src:/app/src
|
||||||
|
- ./frontend/public:/app/public
|
||||||
|
- ./frontend/index.html:/app/index.html:ro
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5173:5173"
|
||||||
Reference in New Issue
Block a user