complete back up from Project manager

This commit is contained in:
patsy 2026-06-26 18:21:22 +02:00
parent 90585b4eae
commit e3616cc22e
43 changed files with 3880 additions and 0 deletions

41
.env.example Normal file
View file

@ -0,0 +1,41 @@
# --- Flask Core ---
# Geheimer Schlüssel für Session-Signierung. Erzeugen z.B. mit:
# python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=change-me-to-a-random-secret
# Umgebung: development | production
FLASK_ENV=development
# Domain/Host (erst final setzen, wenn Domain feststeht). Beispiel: hub.example.com
# Leer lassen für lokale Entwicklung (IP:Port).
SERVER_NAME=
# --- Datenbank ---
# SQLite-Pfad. Standard: instance/hub.sqlite (relativ zur instance/-Ordner).
# Für Postgres später z.B.: postgresql://user:pass@localhost/hub
DATABASE_URL=sqlite:///hub.sqlite
# --- Auth / Self-Registration ---
# Komma-getrennte Allowlist erlaubter E-Mail-Adressen (vorerst nur eine).
ALLOWED_EMAILS=deine@adresse.com
# Gültigkeitsdauer des Login-Codes in Minuten.
LOGIN_CODE_TTL_MINUTES=10
# Rate-Limit für Code-Anfragen (Flask-Limiter Syntax), z.B. "5 per hour".
LOGIN_CODE_RATE_LIMIT=5 per hour
# Speicher-Backend für Rate-Limits.
# Dev/Einzelworker: memory:// | Produktion (mehrere gunicorn-Worker): redis://localhost:6379
RATELIMIT_STORAGE_URI=memory://
# --- SMTP / Mailversand ---
# Anbieter frei wechselbar (Proton Bridge lokal, oder EU-Relay wie Infomaniak/Mailjet).
MAIL_SERVER=127.0.0.1
MAIL_PORT=1025
MAIL_USE_TLS=false
MAIL_USE_SSL=false
MAIL_USERNAME=
MAIL_PASSWORD=
# Absenderadresse der Login-Mails.
MAIL_DEFAULT_SENDER=hub@example.com

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
# Vendored Schriftdateien als Binär behandeln (kein Diff/Textmerge).
*.ttf binary

213
CLAUDE.md Normal file
View file

@ -0,0 +1,213 @@
# CLAUDE.md — Projekt-Hub
Diese Datei beschreibt den Kontext, die Architektur und die Konventionen für dieses Projekt.
Sie dient Claude (und anderen Entwicklern) als Einstiegspunkt, bevor Code geschrieben wird.
## Hintergrund / Idee
Der Nutzer hat ADHS und führt ein analoges Bullet Journal (Logbuch, Weekly Bridge, Monthly
Log). Projektbezogene Arbeitsaufgaben sollen davon getrennt digital verwaltet werden.
Ein erster Prototyp war eine einzelne HTML-Datei mit LocalStorage (Projekte, Aufgaben,
Fälligkeitsdaten, Export/Import als JSON). Diese Datei dient als **funktionale Referenz**
für Feature-Set und UI/UX — siehe `reference/projekte.html` (wird vom Nutzer bereitgestellt).
Das Ziel jetzt: aus dieser Idee eine **Flask-Webapp** machen, die auf einem eigenen VPS
läuft (volle Datenhoheit, kein Drittanbieter-Cloud-Dienst). Diese App ist der erste Baustein
eines **Hubs**, der später um weitere kleine Apps/Module erweitert wird — alle hinter einem
gemeinsamen Login.
## Kernprinzipien
1. **Datenschutz / Selbsthosting**: Alle Daten bleiben auf dem eigenen VPS. Keine externen
Tracker, kein CDN-Zwang für kritische Funktionen (Fallback ohne Internet sollte
funktionieren oder zumindest nicht hart brechen).
2. **ADHS-freundlich**: Niedrigschwellig, wenig Klicks, schnelles Erfassen ("Brain dump"),
sofortiges visuelles Feedback (Fälligkeits-Badges: überfällig/bald/ok), keine
überladenen Screens.
3. **Erweiterbarkeit**: Architektur so anlegen, dass weitere "Mini-Apps" als eigene
Module/Blueprints unter demselben Login + derselben Navigation ergänzt werden können,
ohne den Kern umzubauen.
4. **Einfacher Login**: Kein Passwort. Login per E-Mail-Adresse + zeitlich begrenztem
Code (10 Minuten gültig), der per Mail zugeschickt wird ("Magic Code" / OTP-Login).
## Tech-Stack
- **Backend**: Python, Flask
- **Datenbank**: SQLite (ausreichend für Einzelnutzer/kleine Gruppe; Pfad konfigurierbar,
später ggf. auf Postgres migrierbar — daher SQLAlchemy als ORM verwenden)
- **Auth**: Flask-Login für Session-Verwaltung + eigene Logik für E-Mail-Code-Versand
(Flask-Mail oder smtplib, SMTP-Zugangsdaten über Umgebungsvariablen)
- **Frontend**: Server-seitig gerenderte Templates (Jinja2) + minimal JavaScript für
interaktive Elemente (Checkbox-Toggle, Filter, evtl. htmx für dynamische Updates ohne
vollständigen Page-Reload). Kein schweres SPA-Framework — Ladezeit und Einfachheit
haben Priorität.
- **Styling**: Eigenes CSS im Stil des Prototyps (Dark Theme, siehe Referenzdatei) als
Basis-Theme für den ganzen Hub, das von allen Modulen genutzt wird.
- **Deployment**: VPS, lauffähig hinter einem Reverse Proxy (nginx) via gunicorn.
`.env`-Datei für Secrets (SMTP, SECRET_KEY, Datenbankpfad). `requirements.txt` pflegen.
## Architektur-Überblick
```
projekt-hub/
├── app/
│ ├── __init__.py # App-Factory, Extensions-Init, Blueprint-Registrierung
│ ├── config.py # Config-Klassen (liest aus .env)
│ ├── extensions.py # db, login_manager, mail etc.
│ ├── models.py # User, LoginCode (gemeinsame Modelle)
│ ├── auth/ # Auth-Modul (Blueprint)
│ │ ├── __init__.py
│ │ ├── routes.py # /login, /verify, /logout
│ │ └── forms.py # E-Mail-Eingabe, Code-Eingabe
│ ├── projects/ # Modul 1: Projekte & Aufgaben (aus dem Prototyp)
│ │ ├── __init__.py
│ │ ├── routes.py
│ │ ├── models.py # Project, Task
│ │ └── templates/projects/
│ ├── templates/
│ │ ├── base.html # gemeinsames Layout, Navigation, Theme
│ │ └── auth/
│ │ ├── login.html
│ │ └── verify.html
│ └── static/
│ └── css/main.css # Dark-Theme-Basis aus dem Prototyp
├── migrations/ # Flask-Migrate / Alembic
├── instance/ # SQLite-Datei (nicht versionieren)
├── .env.example
├── requirements.txt
├── wsgi.py
└── run.py # lokaler Dev-Server
```
### Erweiterbarkeit für künftige Module
Jedes neue Mini-App-Modul:
- ist ein eigenes Python-Package mit eigenem Blueprint (`app/<modulname>/`)
- bringt eigene Models, Routes, Templates mit (Templates unter `templates/<modulname>/`)
- wird in `app/__init__.py` per `app.register_blueprint(...)` eingebunden
- erscheint automatisch als Eintrag in der gemeinsamen Navigation (`base.html`), idealerweise
über eine zentrale Liste/Registry, die Modulname + URL + Icon enthält
- nutzt denselben `current_user` (Flask-Login) — kein eigenes Auth nötig
Ziel: ein neues Modul hinzufügen = neuen Ordner anlegen, Blueprint registrieren,
Navigationseintrag ergänzen. Kern-App, Auth und Theme bleiben unangetastet.
## Datenmodell (Modul "Projekte")
**User**
- id
- email (unique)
- created_at
**LoginCode**
- id
- user_id (FK)
- code (6-stellig, numerisch)
- created_at
- expires_at (created_at + 10 Minuten)
- used (bool)
**Project**
- id
- user_id (FK)
- name
- color (Hex-Wert, aus fester Palette wie im Prototyp)
- created_at
**Task**
- id
- user_id (FK)
- project_id (FK, nullable — Aufgaben ohne Projekt erlaubt)
- text
- due_date (nullable)
- done (bool, default False)
- created_at
## Auth-Flow (E-Mail-Code-Login)
1. Nutzer gibt E-Mail-Adresse auf `/login` ein.
2. Backend prüft, ob User mit dieser E-Mail existiert — falls nicht, wird er angelegt
(passwortlose Self-Registration; ggf. später auf Allowlist beschränkbar für
Einzelnutzer-Setup).
3. Backend generiert 6-stelligen Code, speichert Hash + `expires_at = now + 10min`,
sendet Code per E-Mail (Betreff klar, kurzer Text, kein HTML-Tracking).
4. Nutzer landet auf `/verify`, gibt Code ein.
5. Backend prüft: Code korrekt, nicht abgelaufen, nicht bereits benutzt → Login via
Flask-Login (`login_user`), Code als `used` markieren.
6. Alte/abgelaufene Codes regelmäßig aufräumen (z.B. bei jedem neuen Code-Request für
denselben User).
7. Rate-Limiting: max. X Code-Anfragen pro E-Mail pro Stunde, um Spam/Missbrauch zu
verhindern (z.B. Flask-Limiter).
Sicherheitsaspekte:
- Code wird gehasht gespeichert (nicht im Klartext in der DB).
- Session-Cookie: `Secure`, `HttpOnly`, `SameSite=Lax`.
- HTTPS wird vom Reverse Proxy (nginx + Let's Encrypt) terminiert — App selbst geht von
HTTPS aus (`PREFERRED_URL_SCHEME = 'https'`).
## Feature-Set Modul "Projekte" (aus dem Prototyp übernehmen)
- Projekte anlegen/löschen, mit fester Farbpalette (zyklisch zugewiesen)
- Aufgaben anlegen mit: Text, optionalem Projekt, optionalem Fälligkeitsdatum
- Aufgaben abhaken (done/undone), löschen
- Fälligkeits-Badges: überfällig (rot), diese Woche (orange), normal (grün), kein Datum
- Ansicht "Nach Projekt" (gruppiert, mit Klapp-/Zähl-Funktion) + Ansicht "Alle Aufgaben"
(gefiltert: alle / offen / überfällig / diese Woche / erledigt)
- Statistik-Leiste (offen / erledigt / überfällig / Anzahl Projekte)
- Da Daten jetzt serverseitig in SQLite liegen und auf dem VPS gesichert werden,
entfällt der JSON-Export/Import als Sync-/Backup-Mechanismus. Stattdessen:
**PDF-Export** — eine projektbasierte To-Do-Liste zum Ausdrucken (offene und
erledigte Aufgaben, erledigte abgehakt/durchgestrichen, Aufgaben ohne Projekt in
eigenem Abschnitt). Erzeugt mit fpdf2 (reines Python, keine System-Abhängigkeiten);
DejaVu-Schrift im Repo mitgeliefert für identisches Rendering auf dem VPS.
## Nicht-Ziele (für jetzt)
- Keine Multi-Tenant-Mandantentrennung über User hinaus (Familien-/Team-Sharing später,
falls gewünscht)
- Kein OAuth/Social Login
- Kein Offline-First/PWA-Sync (kann später ergänzt werden)
- Kein Rich-Text/Markdown in Aufgaben (bewusst simpel halten)
## Nächste Schritte (Vorschlag für Umsetzungsreihenfolge)
1. Projektgerüst (App-Factory, Config, `.env.example`, `requirements.txt`)
2. Models + DB-Migration (User, LoginCode, Project, Task)
3. Auth-Modul (E-Mail-Code-Login, Templates für `login.html` / `verify.html`)
4. Basis-Layout `base.html` mit Navigation + Dark-Theme-CSS (aus Prototyp übernehmen)
5. Modul "Projekte": Routes + Templates, 1:1-Funktionsumfang aus dem HTML-Prototyp
6. PDF-Export (projektbasierte To-Do-Liste zum Ausdrucken, fpdf2)
7. Deployment-Vorbereitung: `wsgi.py`, gunicorn-Konfiguration, Beispiel-nginx-Config,
systemd-Service-Datei
## Entscheidungen (geklärt)
- **SMTP-Anbieter**: Proton Mail (eigene Adresse). Da Proton Mail Bridge eine
Desktop-Anwendung ist und nur mit zahlendem Plan läuft, wird sie testweise auf dem
VPS via Docker betrieben (lokaler SMTP-Relay auf `127.0.0.1`). Falls das auf dem
Server unzuverlässig läuft, ist ein **Fallback auf einen EU-SMTP-Relay-Dienst**
(z.B. Infomaniak, Mailjet EU-Region) als austauschbare Alternative vorgesehen — die
App liest SMTP-Host/Port/User/Passwort ausschließlich aus `.env`, sodass der Anbieter
ohne Codeänderung wechselbar ist.
- **Self-Registration**: Vorerst nur **eine** erlaubte E-Mail-Adresse (Allowlist mit
einem Eintrag in `.env`, z.B. `ALLOWED_EMAILS=deine@adresse.com`). Spätere
Erweiterung auf mehrere Adressen ist durch eine kommagetrennte Liste oder eigene
DB-Tabelle vorbereitet, aber jetzt nicht nötig.
- **Domain**: Noch nicht final. App wird so konfiguriert, dass Domain/Host über `.env`
(`SERVER_NAME` bzw. Reverse-Proxy-Konfiguration) gesetzt wird. Bis zur
Domain-Entscheidung ist die App über IP:Port oder eine Subdomain-Platzhalter
erreichbar; Cookie-Settings (`SESSION_COOKIE_DOMAIN`) werden erst final gesetzt,
wenn die Domain feststeht.
## Referenzdatei
`reference/projekte.html` — der funktionsfähige HTML/JS-Prototyp (Dark Theme,
LocalStorage, Export/Import). Dient als Vorlage für:
- Farbpalette und CSS-Variablen (Basis für `static/css/main.css`)
- UI-Struktur (Stats-Leiste, Formular, Projekt-Karten, Aufgabenliste, Filter-Buttons)
- Fälligkeits-Badge-Logik (`dueBadge`-Funktion → als Jinja-Filter oder Python-Helper
übernehmen)
- Datenmodell-Felder (Project: id/name/color; Task: id/text/project/due/done)

364
DEPLOYMENT.md Normal file
View file

@ -0,0 +1,364 @@
# DEPLOYMENT.md — Projekt-Hub auf dem VPS installieren
Diese Anleitung beschreibt, wie die fertige Flask-App (siehe `CLAUDE.md` und
`TASKLISTE.md`) auf einem Ubuntu-VPS produktiv läuft. Zielarchitektur:
```
Internet → nginx (Port 80/443, TLS) → gunicorn (Unix-Socket) → Flask-App → SQLite
```
systemd sorgt dafür, dass die App automatisch beim Booten startet und bei einem Absturz neu startet.
Vorausgesetzt: Ubuntu-VPS mit SSH-Zugang und sudo-Rechten. Domain ist optional —
diese Anleitung funktioniert zunächst auch nur mit der Server-IP (HTTPS dann erst,
sobald eine Domain feststeht).
---
## 1. System vorbereiten
```bash
sudo apt update && sudo apt upgrade -y
sudo apt install -y python3 python3-venv python3-pip nginx git
```
Eigenen, nicht-root Nutzer für die App anlegen (falls noch nicht vorhanden):
```bash
sudo adduser projekthub
sudo usermod -aG sudo projekthub # optional, falls dieser Nutzer auch administrieren soll
```
Ab hier alles als `projekthub`-Nutzer ausführen (`su - projekthub`), außer wo
explizit `sudo` für Systemdienste nötig ist.
---
## 2. Code auf den Server bringen
Variante A — über Git (empfohlen, falls Repo z.B. auf einem privaten Git-Server
oder GitHub liegt):
```bash
cd ~
git clone <DEIN_REPO_URL> projekt-hub
cd projekt-hub
```
Variante B — Dateien direkt hochladen (z.B. von deinem Rechner aus):
```bash
scp -r projekt-hub/ projekthub@DEINE_SERVER_IP:~/projekt-hub
```
---
## 3. Python-Umgebung einrichten
```bash
cd ~/projekt-hub
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
```
`gunicorn` und der Redis-Client sind bereits in `requirements.txt` enthalten —
kein separates Installieren nötig. Der **Redis-Server** (für Rate-Limiting bei
mehreren Workern, siehe Abschnitt 8a) ist ein Systemdienst und wird separat
installiert:
```bash
sudo apt install -y redis-server
sudo systemctl enable --now redis-server
```
---
## 4. Konfigurationsdatei `.env` anlegen
```bash
cp .env.example .env
nano .env
```
Wichtige Werte für die Produktion:
```ini
FLASK_ENV=production
SECRET_KEY=<langer-zufaelliger-string>
# Relativer sqlite-Name wird automatisch in den instance/-Ordner aufgelöst
# (-> instance/hub.sqlite). Keinen instance/-Pfad voranstellen.
DATABASE_URL=sqlite:///hub.sqlite
# Login
ALLOWED_EMAILS=deine@adresse.com
# Rate-Limiting über gemeinsames Backend (mehrere Worker), siehe Abschnitt 8a
RATELIMIT_STORAGE_URI=redis://localhost:6379
# Mail (siehe Abschnitt 7)
MAIL_SERVER=127.0.0.1
MAIL_PORT=1025
MAIL_USERNAME=deine@adresse.com
MAIL_PASSWORD=<bridge-oder-relay-passwort>
MAIL_USE_TLS=true
# Domain (sobald bekannt, siehe Abschnitt 9)
SERVER_NAME=
SESSION_COOKIE_SECURE=true
```
Einen zufälligen `SECRET_KEY` generieren:
```bash
python3 -c "import secrets; print(secrets.token_hex(32))"
```
`.env` darf **niemals** ins Git-Repo committen — sicherstellen, dass `.gitignore`
`.env` enthält.
---
## 5. Datenbank initialisieren
```bash
mkdir -p instance
flask db upgrade
```
Prüfen, dass `instance/hub.sqlite` existiert:
```bash
ls -la instance/
```
---
## 6. Testlauf mit dem Flask-Dev-Server (nur zur Kontrolle)
```bash
flask run --host=0.0.0.0 --port=5000
```
Im Browser `http://DEINE_SERVER_IP:5000` aufrufen — die Login-Seite sollte
erscheinen. Danach mit `Strg+C` wieder stoppen; der Dev-Server ist **nicht** für
Dauerbetrieb gedacht.
---
## 7. E-Mail-Versand über Proton einrichten
### Option A: Proton Mail Bridge (Docker)
Die Bridge initialisiert beim Start lokale IMAP/SMTP-Server, über die die App
Mails verschicken kann. Auf dem VPS läuft sie containerisiert:
```bash
sudo apt install -y docker.io
sudo systemctl enable --now docker
docker volume create proton-bridge
docker run -d --name proton-bridge \
-v proton-bridge:/root \
-p 127.0.0.1:1025:25 \
-p 127.0.0.1:1143:143 \
spameier/proton-bridge
```
Danach einmalig in den Container einloggen und die Bridge mit den Proton-Zugangsdaten
verbinden:
```bash
docker exec -it proton-bridge bridge --cli
# im Bridge-CLI: "login" eingeben und den Anweisungen folgen
```
Die Bridge zeigt danach lokale SMTP-Zugangsdaten (Benutzername + generiertes
Passwort) an — diese in die `.env` unter `MAIL_USERNAME` / `MAIL_PASSWORD`
eintragen, `MAIL_SERVER=127.0.0.1`, `MAIL_PORT=1025`.
**Bekannte Stolpersteine:** Proton Bridge nutzt ein selbstsigniertes Zertifikat und initiiert den TLS-Handshake selbst, was bei direkter Relay-Einbindung zu Problemen führen kann. Falls der Mailversand aus Flask nicht klappt, hilft es,
Postfix als lokalen Zwischen-Relay zu betreiben, der an die Bridge weiterleitet —
oder direkt auf Option B auszuweichen.
### Option B: EU-SMTP-Relay als Fallback
Falls Option A zu instabil ist: bei einem europäischen Transactional-Mail-Anbieter
(z.B. Infomaniak, Mailjet EU) ein kostenloses/günstiges Konto anlegen, SMTP-Zugangsdaten
erhalten und in `.env` eintragen — `MAIL_SERVER`, `MAIL_PORT`, `MAIL_USERNAME`,
`MAIL_PASSWORD`, `MAIL_USE_TLS` entsprechend anpassen. **Am Flask-Code ändert sich
nichts**, da die App generisch über `Flask-Mail`/SMTP konfiguriert ist.
### Testmail verschicken
```bash
flask shell
```
```python
from app.extensions import mail
from flask_mail import Message
msg = Message("Testmail Projekt-Hub", recipients=["deine@adresse.com"])
msg.body = "Funktioniert!"
mail.send(msg)
```
---
## 8. gunicorn + systemd einrichten
Die Gunicorn-Konfiguration liegt versioniert im Repo: **`gunicorn.conf.py`**
(Worker-Anzahl, Socket, Logging). Startbefehl testen (im `.venv`):
```bash
gunicorn -c gunicorn.conf.py wsgi:app
```
Nginx an Gunicorn über einen Unix-Socket statt TCP-Port anzubinden reduziert den TCP-Overhead und ist die bevorzugte Variante, wenn beide auf demselben Host laufen. Mit `Strg+C` stoppen, danach den systemd-Service einrichten.
Die systemd-Unit liegt ebenfalls im Repo (**`deploy/projekt-hub.service`**) und
lädt die Gunicorn-Config per `-c`. Kopieren und ggf. Pfade/Nutzer anpassen:
```bash
sudo cp deploy/projekt-hub.service /etc/systemd/system/projekt-hub.service
sudo nano /etc/systemd/system/projekt-hub.service # Pfade/Nutzer prüfen
```
Die Worker-Anzahl steuert `gunicorn.conf.py` (Default: (2 × CPU-Kerne) + 1,
per env `GUNICORN_WORKERS` überschreibbar).
Service aktivieren und starten:
```bash
sudo systemctl daemon-reload
sudo systemctl enable projekt-hub
sudo systemctl start projekt-hub
sudo systemctl status projekt-hub
```
---
## 8a. Redis für Rate-Limiting
Flask-Limiter (Login-Code-Anfragen) braucht bei **mehreren** gunicorn-Workern ein
gemeinsames Speicher-Backend — sonst zählt jeder Worker eigene Limits und das
Limit greift faktisch nicht. Redis wurde in Abschnitt 3 installiert; in der `.env`
verweist `RATELIMIT_STORAGE_URI` darauf:
```ini
RATELIMIT_STORAGE_URI=redis://localhost:6379
```
Damit Redis vor der App startet, in der systemd-Unit (auskommentierte Zeilen
aktivieren):
```ini
After=network.target redis-server.service
Wants=redis-server.service
```
Schnelltest, dass Redis läuft: `redis-cli ping``PONG`. Wer bewusst nur **einen**
Worker fährt, kann auch bei `memory://` bleiben.
Logs ansehen:
```bash
sudo journalctl -u projekt-hub -f
```
---
## 9. nginx als Reverse Proxy
Die nginx-Vorlage liegt im Repo: **`deploy/nginx/projekt-hub.conf`** (Socket-Proxy
+ Static-Auslieferung). Kopieren und `server_name` (zunächst Server-IP) anpassen:
```bash
sudo cp deploy/nginx/projekt-hub.conf /etc/nginx/sites-available/projekt-hub
sudo nano /etc/nginx/sites-available/projekt-hub # server_name / Pfade prüfen
```
Aktivieren:
```bash
sudo ln -s /etc/nginx/sites-available/projekt-hub /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
Im Browser `http://DEINE_SERVER_IP` aufrufen — die Login-Seite sollte jetzt über
nginx ausgeliefert werden.
---
## 10. Domain & HTTPS (sobald die Domain feststeht)
1. DNS A-Record der gewählten Domain/Subdomain auf die Server-IP zeigen lassen
2. In der nginx-Config `server_name` auf die Domain ändern, `nginx -t` +
`systemctl reload nginx`
3. Certbot installieren und Zertifikat holen:
```bash
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d deine-domain.de
```
Certbot passt die nginx-Config automatisch für HTTPS + Redirect von HTTP an.
4. In `.env`: `SERVER_NAME=deine-domain.de`, `SESSION_COOKIE_SECURE=true`,
`PREFERRED_URL_SCHEME=https` setzen, danach App neu starten:
```bash
sudo systemctl restart projekt-hub
```
---
## 11. Updates einspielen
Wenn neue Features (z.B. weitere Module) hinzukommen:
```bash
cd ~/projekt-hub
git pull # oder neue Dateien per scp hochladen
source .venv/bin/activate
pip install -r requirements.txt # falls neue Abhängigkeiten
flask db upgrade # falls neue Migrationen
sudo systemctl restart projekt-hub
```
---
## 12. Backup
Die gesamte App-Datenbank ist eine einzelne SQLite-Datei. Einfaches Backup per
Cronjob (täglich, Beispiel):
```bash
crontab -e
```
Zeile hinzufügen:
```
0 3 * * * cp /home/projekthub/projekt-hub/instance/hub.sqlite /home/projekthub/backups/hub-$(date +\%F).sqlite
```
Vorher den Ordner anlegen: `mkdir -p ~/backups`. Da SQLite auch während Schreib-
zugriffen kopiert werden kann, ist für ein konsistentes Backup `sqlite3 hub.sqlite
".backup '/pfad/hub-$(date +%F).sqlite'"` die sauberere Variante.
> Hinweis: Der frühere JSON-Export wurde durch den **PDF-Export** ersetzt (eine
> druckbare, projektbasierte To-Do-Liste, kein Datensicherungs-Format). Die
> Datensicherung erfolgt also ausschließlich über die SQLite-Datei wie oben.
---
## 13. Sicherheits-Checkliste (kurz)
- [ ] `.env` hat restriktive Dateirechte: `chmod 600 .env`
- [ ] `FLASK_ENV=production` gesetzt (kein Debug-Modus)
- [ ] Firewall (`ufw`) aktiv, nur Ports 22, 80, 443 offen:
```bash
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
```
- [ ] SSH-Login per Passwort deaktiviert, nur Key-Auth (falls noch nicht der Fall)
- [ ] Regelmäßige `apt upgrade` einplanen (z.B. `unattended-upgrades`)

0
README.md Normal file
View file

166
TASKLISTE.md Normal file
View file

@ -0,0 +1,166 @@
# TASKLISTE — Projekt-Hub Schritt für Schritt
Diese Liste begleitet dich von der Idee bis zur laufenden App auf deinem VPS.
Reihenfolge ist bewusst gewählt: erst lokal entwickeln und testen, dann deployen.
Häkchen kannst du selbst setzen, indem du `[ ]` zu `[x]` änderst.
Querverweis: technische Details zum Server-Setup stehen in `DEPLOYMENT.md`.
Architektur- und Designentscheidungen stehen in `CLAUDE.md`.
---
## Phase 0 — Vorbereitung (lokal, einmalig)
- [ ] Python 3.11+ auf deinem Rechner installiert prüfen (`python3 --version`)
- [ ] Projektordner anlegen, z.B. `projekt-hub/`
- [ ] Git-Repository initialisieren (`git init`) — auch wenn du es nur lokal/privat
nutzt, hilft Versionskontrolle enorm
- [ ] `CLAUDE.md` und `reference/projekte.html` in den Ordner legen (sind bereits
vorbereitet)
- [ ] `.gitignore` anlegen mit mindestens: `.env`, `instance/`, `__pycache__/`,
`*.pyc`, `.venv/`
---
## Phase 1 — Projektgerüst mit Claude erstellen
- [ ] Mit Claude Code (oder Claude in diesem Chat) die `CLAUDE.md` als Kontext geben
und das Grundgerüst generieren lassen:
- App-Factory (`app/__init__.py`)
- Config-Klassen (`app/config.py`)
- Extensions (`app/extensions.py`: db, login_manager, mail)
- `requirements.txt`
- `.env.example`
- `wsgi.py` und `run.py`
- [ ] Virtuelle Umgebung anlegen und aktivieren:
```bash
python3 -m venv .venv
source .venv/bin/activate
```
- [ ] Abhängigkeiten installieren: `pip install -r requirements.txt`
- [ ] `.env` aus `.env.example` erstellen und lokale Platzhalterwerte eintragen
(echte Proton-Zugangsdaten erst später, siehe Phase 4)
- [ ] App startet lokal ohne Fehler: `flask run` bzw. `python run.py`
---
## Phase 2 — Datenmodell & Datenbank
- [ ] Models erstellen lassen: `User`, `LoginCode`, `Project`, `Task` (siehe
Datenmodell-Abschnitt in `CLAUDE.md`)
- [ ] Flask-Migrate einrichten (`flask db init`)
- [ ] Erste Migration erzeugen und anwenden:
```bash
flask db migrate -m "initial models"
flask db upgrade
```
- [ ] Prüfen, dass `instance/app.db` (oder ähnlich) erstellt wurde
---
## Phase 3 — Auth-Modul (E-Mail-Code-Login)
- [ ] Blueprint `auth` mit Routen `/login`, `/verify`, `/logout` erstellen lassen
- [ ] Templates `login.html` (E-Mail-Eingabe) und `verify.html` (Code-Eingabe) im
Dark-Theme-Stil des Prototyps
- [ ] Logik: 6-stelligen Code generieren, gehasht in `LoginCode` speichern,
`expires_at` = jetzt + 10 Minuten
- [ ] Logik: Code per Mail versenden (E-Mail-Versand erst mit Dummy/Konsole testen —
„Code wird in Terminal ausgegeben statt gemailt“, bis SMTP steht)
- [ ] Logik: Code-Verifizierung (korrekt? abgelaufen? schon benutzt?)
- [ ] Allowlist-Check: nur `ALLOWED_EMAILS` aus `.env` darf sich einloggen
- [ ] Flask-Login einrichten: `login_user`, `@login_required`, `current_user`
- [ ] Rate-Limiting für `/login` einrichten (Flask-Limiter), z.B. max. 5 Anfragen/Stunde
- [ ] Lokal testen: kompletter Login-Durchlauf mit Code aus Terminal-Ausgabe
---
## Phase 4 — E-Mail-Versand mit Proton einrichten
- [ ] Proton-Mail-Plan prüfen: unterstützt Bridge (zahlender Plan erforderlich)
- [ ] **Option A (Proton Bridge)**: Bridge-Container/Setup gemäß `DEPLOYMENT.md`
einrichten, lokale SMTP-Zugangsdaten generieren
- [ ] **Option B (Fallback EU-Relay)**: falls Bridge auf dem Server Probleme macht,
alternativen EU-SMTP-Anbieter einrichten (z.B. Infomaniak) — Zugangsdaten in
`.env` eintragen, Code bleibt unverändert (nur SMTP-Settings ändern sich)
- [ ] `.env` mit echten SMTP-Zugangsdaten füllen (`MAIL_SERVER`, `MAIL_PORT`,
`MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_USE_TLS`)
- [ ] Testmail lokal verschicken (kleines Testskript oder über `/login`-Flow)
- [ ] Mail-Text prüfen: kurz, klar, kein Tracking, Code gut lesbar (z.B. groß,
monospace)
---
## Phase 5 — Basis-Layout & Theme
- [ ] `base.html` erstellen: Navigation (Platzhalter für künftige Module), Header,
Logout-Button
- [ ] `static/css/main.css` aus dem Farbschema von `reference/projekte.html`
übernehmen (CSS-Variablen: `--bg`, `--surface`, `--accent`, etc.)
- [ ] Login-/Verify-Templates an `base.html` anpassen
- [ ] Mobile-Ansicht prüfen (Viewport, Touch-Targets groß genug)
---
## Phase 6 — Modul „Projekte“ (Kernfunktion)
- [ ] Blueprint `projects` erstellen
- [ ] Routen: Übersicht, Projekt anlegen/löschen, Aufgabe anlegen/löschen/abhaken
- [ ] Template: Stats-Leiste (offen/erledigt/überfällig/Projekte)
- [ ] Template: Formular „Neue Aufgabe“ (Text, Projekt-Auswahl, Fälligkeitsdatum)
- [ ] Template: Projekt-Karten mit Aufgabenliste, Klapp-Funktion
- [ ] Template: „Alle Aufgaben“-Liste mit Filtern (alle/offen/überfällig/diese
Woche/erledigt)
- [ ] Fälligkeits-Badge-Logik aus dem Prototyp als Python-Helper/Jinja-Filter
übernehmen (überfällig/bald/ok/kein Datum)
- [ ] Alles `@login_required` und auf `current_user` beschränkt (jeder Nutzer sieht
nur eigene Daten)
- [ ] Manuell durchklicken: Projekt anlegen → Aufgaben anlegen → abhaken → filtern →
löschen — entspricht der Funktionalität des HTML-Prototyps
---
## Phase 7 — Backup-Export
- [ ] Route `/export` (oder „Einstellungen“-Bereich): erzeugt JSON-Datei mit allen
Projekten und Aufgaben des eingeloggten Users zum Download
- [ ] Kurzer Hinweistext in der UI: „Lade regelmäßig ein Backup herunter und speichere
es z.B. auf Proton Drive“
---
## Phase 8 — Lokaler Gesamttest
- [ ] Kompletten Flow einmal komplett durchspielen: Login per Code → Projekte/Aufgaben
anlegen → Backup exportieren → Logout → erneut einloggen → Daten noch da?
- [ ] Mit zwei verschiedenen Browsern/Geräten im selben WLAN testen (Mobile-Ansicht!)
- [ ] `requirements.txt` final aktualisieren (`pip freeze > requirements.txt`
danach manuell aufräumen, nur wirklich benötigte Pakete behalten)
---
## Phase 9 — Deployment auf den VPS
→ Details siehe `DEPLOYMENT.md`. Kurzfassung:
- [ ] Code auf den VPS bringen (Git-Push/Pull oder `scp`)
- [ ] Python-Umgebung auf dem Server einrichten
- [ ] `.env` auf dem Server mit echten Produktionswerten anlegen (NICHT ins
Repo committen)
- [ ] Datenbank-Migration auf dem Server ausführen
- [ ] gunicorn + systemd-Service einrichten
- [ ] nginx als Reverse Proxy konfigurieren
- [ ] HTTPS via Let's Encrypt einrichten (sobald Domain feststeht)
- [ ] Proton-Mail-Versand auf dem Server testen (Testmail)
- [ ] Kompletten Login-Flow auf der echten Domain/IP testen
---
## Phase 10 — Danach (spätere Erweiterungen, nicht jetzt)
- [ ] Domain final festlegen, Cookie-Settings (`SESSION_COOKIE_DOMAIN`) anpassen
- [ ] Weitere Mini-Apps als neue Blueprints ergänzen (Navigation erweitert sich
automatisch, falls Registry-Pattern aus `CLAUDE.md` umgesetzt wurde)
- [ ] Mehrere E-Mail-Adressen zur Allowlist hinzufügen, falls gewünscht
- [ ] Regelmäßige automatische Backups einrichten (z.B. Cronjob, der die
SQLite-Datei sichert)

85
app/__init__.py Normal file
View file

@ -0,0 +1,85 @@
"""App-Factory für den Projekt-Hub.
Erzeugt die Flask-App, lädt die Config, bindet die Extensions und registriert
die Blueprints der einzelnen Mini-App-Module. Jedes neue Modul wird hier per
register_blueprint(...) eingebunden und über die zentrale Navigations-Registry
in der Navigation sichtbar gemacht Kern bleibt unangetastet.
"""
import os
from flask import Flask, redirect, url_for
from .config import get_config
from .extensions import db, migrate, login_manager, mail, limiter, csrf
# --- Zentrale Navigations-Registry ---------------------------------------
# Jedes Modul trägt hier einen Eintrag bei (Label, Endpoint, Icon). base.html
# rendert daraus die gemeinsame Navigation. Neues Modul = neuer Eintrag.
NAV_MODULES = [
{"label": "Projekte", "endpoint": "projects.index", "icon": "📁"},
]
def create_app(config_name: str | None = None) -> Flask:
app = Flask(
__name__,
instance_relative_config=True,
instance_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "instance"),
)
app.config.from_object(get_config(config_name))
# instance/-Ordner sicherstellen (SQLite-Datei landet hier).
os.makedirs(app.instance_path, exist_ok=True)
# Relativen sqlite-Pfad in den instance/-Ordner umbiegen.
uri = app.config.get("SQLALCHEMY_DATABASE_URI", "")
if uri.startswith("sqlite:///") and not uri.startswith("sqlite:////"):
db_name = uri.replace("sqlite:///", "", 1)
abs_path = os.path.join(app.instance_path, db_name)
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{abs_path}"
_init_extensions(app)
_register_blueprints(app)
_register_context(app)
@app.route("/")
def index():
return redirect(url_for("projects.index"))
return app
def _init_extensions(app: Flask) -> None:
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
mail.init_app(app)
limiter.init_app(app)
csrf.init_app(app)
# Modelle importieren, damit SQLAlchemy/Alembic sie kennen (Autogenerate).
from . import models # noqa: F401
from .projects import models as project_models # noqa: F401
from .models import User
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))
def _register_blueprints(app: Flask) -> None:
from .auth import bp as auth_bp
from .projects import bp as projects_bp
app.register_blueprint(auth_bp)
app.register_blueprint(projects_bp)
def _register_context(app: Flask) -> None:
@app.context_processor
def inject_nav():
# Navigations-Registry in allen Templates verfügbar machen.
return {"nav_modules": NAV_MODULES}

11
app/auth/__init__.py Normal file
View file

@ -0,0 +1,11 @@
"""Auth-Modul (Blueprint): E-Mail-Code-Login.
Routen und Formulare folgen in Schritt 3. Hier wird vorerst nur der Blueprint
definiert, damit die App-Factory ihn registrieren kann.
"""
from flask import Blueprint
bp = Blueprint("auth", __name__, url_prefix="/auth")
from . import routes # noqa: E402,F401

24
app/auth/forms.py Normal file
View file

@ -0,0 +1,24 @@
"""Auth-Formulare: E-Mail-Eingabe, Code-Eingabe (Flask-WTF, mit CSRF-Schutz)."""
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired, Email, Regexp
class EmailForm(FlaskForm):
email = StringField(
"E-Mail",
validators=[DataRequired(), Email(message="Bitte eine gültige E-Mail eingeben.")],
)
submit = SubmitField("Code anfordern")
class CodeForm(FlaskForm):
code = StringField(
"Code",
validators=[
DataRequired(),
Regexp(r"^\d{6}$", message="Bitte den 6-stelligen Code eingeben."),
],
)
submit = SubmitField("Einloggen")

105
app/auth/routes.py Normal file
View file

@ -0,0 +1,105 @@
"""Auth-Routen: /login (Code anfordern), /verify (Code prüfen), /logout."""
from urllib.parse import urlparse
from flask import (
render_template,
redirect,
url_for,
flash,
session,
request,
current_app,
)
from flask_login import login_user, logout_user, login_required, current_user
from ..extensions import limiter
from ..models import User
from . import bp
from .forms import EmailForm, CodeForm
from .service import (
email_allowed,
get_or_create_user,
issue_login_code,
send_login_code,
verify_code,
)
def _rate_key() -> str:
"""Rate-Limit pro E-Mail (Fallback: IP), damit Code-Spam begrenzt ist."""
return (request.form.get("email") or request.remote_addr or "").lower()
def _is_safe_next(target: str | None) -> bool:
"""Open-Redirect verhindern: nur lokale, relative Pfade erlauben."""
if not target:
return False
parsed = urlparse(target)
return not parsed.netloc and not parsed.scheme and target.startswith("/")
@bp.route("/login", methods=["GET", "POST"])
@limiter.limit(
lambda: current_app.config["LOGIN_CODE_RATE_LIMIT"],
methods=["POST"],
key_func=_rate_key,
)
def login():
if current_user.is_authenticated:
return redirect(url_for("projects.index"))
form = EmailForm()
if form.validate_on_submit():
email = form.email.data.lower().strip()
if not email_allowed(email):
flash("Diese E-Mail-Adresse ist nicht freigeschaltet.", "error")
return render_template("auth/login.html", form=form)
user = get_or_create_user(email)
code = issue_login_code(user)
try:
send_login_code(user, code)
except Exception: # noqa: BLE001
flash("Code konnte nicht gesendet werden. Bitte später erneut versuchen.", "error")
return render_template("auth/login.html", form=form)
session["auth_email"] = email
flash("Wir haben dir einen Code geschickt. Bitte gib ihn unten ein.", "info")
return redirect(url_for("auth.verify"))
return render_template("auth/login.html", form=form)
@bp.route("/verify", methods=["GET", "POST"])
def verify():
if current_user.is_authenticated:
return redirect(url_for("projects.index"))
email = session.get("auth_email")
if not email:
flash("Bitte zuerst deine E-Mail-Adresse eingeben.", "info")
return redirect(url_for("auth.login"))
form = CodeForm()
if form.validate_on_submit():
user = User.query.filter_by(email=email).first()
if user and verify_code(user, form.code.data.strip()):
login_user(user)
session.pop("auth_email", None)
flash("Eingeloggt. Willkommen zurück!", "success")
next_url = request.args.get("next")
return redirect(next_url if _is_safe_next(next_url) else url_for("projects.index"))
flash("Code ungültig oder abgelaufen. Bitte erneut versuchen.", "error")
return render_template("auth/verify.html", form=form, email=email)
@bp.route("/logout")
@login_required
def logout():
logout_user()
session.pop("auth_email", None)
flash("Du wurdest abgemeldet.", "info")
return redirect(url_for("auth.login"))

95
app/auth/service.py Normal file
View file

@ -0,0 +1,95 @@
"""Auth-Service-Logik: Allowlist, User-Anlage, Code-Erzeugung/-Versand/-Prüfung.
Trennt die fachliche Logik von den Routen. Codes werden 6-stellig numerisch
erzeugt, nur gehasht gespeichert und per E-Mail versandt.
"""
import secrets
from datetime import timedelta
from flask import current_app
from flask_mail import Message
from ..extensions import db, mail
from ..models import User, LoginCode, utcnow
def email_allowed(email: str) -> bool:
"""True, wenn die E-Mail freigeschaltet ist.
Leere Allowlist (z.B. lokale Entwicklung ohne .env) = keine Einschränkung.
In Produktion sollte ALLOWED_EMAILS gesetzt sein (Self-Registration begrenzt).
"""
allow = current_app.config.get("ALLOWED_EMAILS", [])
return (not allow) or (email.lower() in allow)
def get_or_create_user(email: str) -> User:
"""User per E-Mail holen oder passwortlos anlegen (Self-Registration)."""
email = email.lower().strip()
user = User.query.filter_by(email=email).first()
if user is None:
user = User(email=email)
db.session.add(user)
db.session.commit()
return user
def issue_login_code(user: User) -> str:
"""Neuen Login-Code erzeugen, alte unbenutzte Codes des Users verwerfen.
Gibt den Klartext-Code zurück (nur zum sofortigen Versand) gespeichert
wird ausschließlich der Hash.
"""
LoginCode.query.filter_by(user_id=user.id, used=False).delete()
code = f"{secrets.randbelow(1_000_000):06d}"
ttl = current_app.config.get("LOGIN_CODE_TTL_MINUTES", 10)
lc = LoginCode(user_id=user.id, expires_at=utcnow() + timedelta(minutes=ttl))
lc.set_code(code)
db.session.add(lc)
db.session.commit()
return code
def send_login_code(user: User, code: str) -> None:
"""Code per E-Mail senden (Klartext, kein HTML/Tracking).
Im Debug-Modus wird der Code zusätzlich geloggt und Versandfehler werden
verschluckt, damit lokal ohne SMTP getestet werden kann. In Produktion
wird eine Versand-Exception weitergereicht.
"""
ttl = current_app.config.get("LOGIN_CODE_TTL_MINUTES", 10)
msg = Message(
subject="Dein Login-Code für den Projekt-Hub",
recipients=[user.email],
body=(
"Hallo,\n\n"
f"dein Login-Code lautet: {code}\n\n"
f"Der Code ist {ttl} Minuten gültig und nur einmal verwendbar.\n"
"Wenn du keinen Login angefordert hast, ignoriere diese Mail.\n"
),
)
try:
mail.send(msg)
except Exception as exc: # noqa: BLE001
current_app.logger.warning("Mailversand fehlgeschlagen: %s", exc)
if not current_app.debug:
raise
if current_app.debug:
current_app.logger.info("DEV — Login-Code für %s: %s", user.email, code)
def verify_code(user: User, code: str) -> bool:
"""Neuesten unbenutzten Code des Users prüfen; bei Erfolg als used markieren."""
lc = (
LoginCode.query.filter_by(user_id=user.id, used=False)
.order_by(LoginCode.created_at.desc())
.first()
)
if lc is None or not lc.is_valid(code):
return False
lc.used = True
db.session.commit()
return True

82
app/config.py Normal file
View file

@ -0,0 +1,82 @@
"""Config-Klassen für die Projekt-Hub-App.
Liest Werte aus Umgebungsvariablen (.env wird in run.py / wsgi.py via
python-dotenv geladen). Keine Secrets im Code alles über die Umgebung.
"""
import os
def _as_bool(value: str | None, default: bool = False) -> bool:
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
class Config:
"""Basis-Config, gemeinsam für alle Umgebungen."""
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-insecure-change-me")
# --- Datenbank ---
# Default landet in der instance/-Datei (Pfad wird in der App-Factory gesetzt,
# falls hier ein relativer sqlite-Name steht).
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///hub.sqlite")
SQLALCHEMY_TRACK_MODIFICATIONS = False
# --- Host / URL ---
SERVER_NAME = os.environ.get("SERVER_NAME") or None
PREFERRED_URL_SCHEME = "https"
# --- Session-Cookie-Sicherheit ---
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
# In Produktion über HTTPS erzwingen; in Dev (HTTP) abschaltbar.
SESSION_COOKIE_SECURE = _as_bool(os.environ.get("SESSION_COOKIE_SECURE"), default=True)
# --- Auth ---
ALLOWED_EMAILS = [
e.strip().lower()
for e in os.environ.get("ALLOWED_EMAILS", "").split(",")
if e.strip()
]
LOGIN_CODE_TTL_MINUTES = int(os.environ.get("LOGIN_CODE_TTL_MINUTES", "10"))
LOGIN_CODE_RATE_LIMIT = os.environ.get("LOGIN_CODE_RATE_LIMIT", "5 per hour")
# --- Rate-Limiting (Flask-Limiter) ---
# Speicher-Backend. Default memory:// (pro Prozess, reicht für Dev/Einzelworker).
# In Produktion mit mehreren gunicorn-Workern: gemeinsames Backend nötig, sonst
# zählt jeder Worker eigene Limits. Empfehlung: redis://localhost:6379
RATELIMIT_STORAGE_URI = os.environ.get("RATELIMIT_STORAGE_URI", "memory://")
# Restzeit-/Limit-Header in Antworten mitschicken.
RATELIMIT_HEADERS_ENABLED = True
# --- Mail ---
MAIL_SERVER = os.environ.get("MAIL_SERVER", "127.0.0.1")
MAIL_PORT = int(os.environ.get("MAIL_PORT", "1025"))
MAIL_USE_TLS = _as_bool(os.environ.get("MAIL_USE_TLS"))
MAIL_USE_SSL = _as_bool(os.environ.get("MAIL_USE_SSL"))
MAIL_USERNAME = os.environ.get("MAIL_USERNAME") or None
MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD") or None
MAIL_DEFAULT_SENDER = os.environ.get("MAIL_DEFAULT_SENDER", "hub@example.com")
class DevelopmentConfig(Config):
DEBUG = True
SESSION_COOKIE_SECURE = _as_bool(os.environ.get("SESSION_COOKIE_SECURE"), default=False)
class ProductionConfig(Config):
DEBUG = False
config_by_name = {
"development": DevelopmentConfig,
"production": ProductionConfig,
}
def get_config(name: str | None = None):
"""Wählt die Config-Klasse anhand von FLASK_ENV (Default: development)."""
name = name or os.environ.get("FLASK_ENV", "development")
return config_by_name.get(name, DevelopmentConfig)

26
app/extensions.py Normal file
View file

@ -0,0 +1,26 @@
"""Zentrale Instanziierung der Flask-Extensions.
Hier werden die Extension-Objekte ohne App erzeugt und in der App-Factory
(app/__init__.py) via init_app(app) gebunden. So vermeiden wir zirkuläre
Importe und können Module sauber auf gemeinsame Extensions zugreifen.
"""
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_mail import Mail
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_wtf import CSRFProtect
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
mail = Mail()
limiter = Limiter(key_func=get_remote_address)
csrf = CSRFProtect()
# Auth-Konfiguration: Wohin umleiten, wenn Login nötig ist.
login_manager.login_view = "auth.login"
login_manager.login_message = "Bitte zuerst einloggen."
login_manager.login_message_category = "info"

84
app/models.py Normal file
View file

@ -0,0 +1,84 @@
"""Gemeinsame Modelle: User, LoginCode.
Modul-spezifische Modelle (Project, Task) liegen in app/projects/models.py.
Alle Zeitstempel werden in UTC gespeichert.
"""
from datetime import datetime, timezone
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from .extensions import db
def utcnow() -> datetime:
"""Zeitzonenbewusster UTC-Zeitstempel (für DB-Defaults)."""
return datetime.now(timezone.utc)
def _as_aware(dt: datetime) -> datetime:
"""SQLite gibt Datetimes naiv zurück — als UTC interpretieren."""
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
class User(UserMixin, db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
login_codes = db.relationship(
"LoginCode",
back_populates="user",
cascade="all, delete-orphan",
)
projects = db.relationship(
"Project",
back_populates="user",
cascade="all, delete-orphan",
)
tasks = db.relationship(
"Task",
back_populates="user",
cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<User {self.email}>"
class LoginCode(db.Model):
"""Einmaliger, zeitlich begrenzter Login-Code (gehasht gespeichert)."""
__tablename__ = "login_codes"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(
db.Integer, db.ForeignKey("users.id"), nullable=False, index=True
)
code_hash = db.Column(db.String(255), nullable=False)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
expires_at = db.Column(db.DateTime, nullable=False)
used = db.Column(db.Boolean, default=False, nullable=False)
user = db.relationship("User", back_populates="login_codes")
def set_code(self, code: str) -> None:
"""Klartext-Code hashen und ablegen (nie im Klartext speichern)."""
self.code_hash = generate_password_hash(code)
def check_code(self, code: str) -> bool:
return check_password_hash(self.code_hash, code)
@property
def is_expired(self) -> bool:
return utcnow() > _as_aware(self.expires_at)
def is_valid(self, code: str) -> bool:
"""Code ist gültig: nicht benutzt, nicht abgelaufen, korrekt."""
return (not self.used) and (not self.is_expired) and self.check_code(code)
def __repr__(self) -> str:
return f"<LoginCode user_id={self.user_id} used={self.used}>"

17
app/projects/__init__.py Normal file
View file

@ -0,0 +1,17 @@
"""Modul 1: Projekte & Aufgaben (Blueprint).
Routen, Modelle (Project, Task) und Templates folgen in Schritt 5. Templates
liegen unter app/projects/templates/projects/ und werden über den Blueprint
gefunden.
"""
from flask import Blueprint
bp = Blueprint(
"projects",
__name__,
url_prefix="/projekte",
template_folder="templates",
)
from . import routes # noqa: E402,F401

Binary file not shown.

Binary file not shown.

70
app/projects/helpers.py Normal file
View file

@ -0,0 +1,70 @@
"""Hilfsfunktionen des Projekte-Moduls.
Enthält die feste Farbpalette (zyklisch zugewiesen), die Fälligkeits-Badge-Logik
(portiert aus dem Prototyp `dueBadge`), die Aufgaben-Sortierung und ein
locale-unabhängiges deutsches Datumslabel.
"""
from datetime import date
# Feste Palette wie im Prototyp — neue Projekte bekommen zyklisch die nächste Farbe.
PROJECT_COLORS = [
"#e94560", "#f5a623", "#4ecca3", "#a78bfa", "#60a5fa",
"#f472b6", "#34d399", "#fb923c", "#818cf8", "#38bdf8",
]
_WEEKDAYS_DE = [
"Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag",
]
_MONTHS_DE = [
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
]
def next_color(existing_count: int) -> str:
"""Nächste Farbe aus der Palette (zyklisch nach Anzahl vorhandener Projekte)."""
return PROJECT_COLORS[existing_count % len(PROJECT_COLORS)]
def _fmt(d: date) -> str:
"""Datum als dd.mm.yy (wie im Prototyp)."""
return f"{d.day:02d}.{d.month:02d}.{str(d.year)[2:]}"
def due_badge(due: date | None, done: bool) -> dict:
"""Fälligkeits-Badge: CSS-Klasse + Text. Portiert aus dem Prototyp.
- kein Datum -> 'kein Datum' (neutral)
- erledigt -> Datum neutral
- überfällig -> rot, mit Tagen drüber
- <= 7 Tage -> orange, mit Resttagen
- sonst -> grün
"""
if due is None:
return {"cls": "due-none", "text": "kein Datum"}
label = _fmt(due)
if done:
return {"cls": "due-none", "text": label}
diff = (due - date.today()).days
if diff < 0:
return {"cls": "due-overdue", "text": f"{label} ({abs(diff)}d über)"}
if diff <= 7:
return {"cls": "due-soon", "text": f"{label} ({diff}d)"}
return {"cls": "due-ok", "text": f"📅 {label}"}
def sort_tasks(tasks: list) -> list:
"""Sortierung wie im Prototyp: offen vor erledigt, dann nach Datum (ohne zuletzt)."""
return sorted(
tasks,
key=lambda t: (t.done, t.due_date is None, t.due_date or date.max),
)
def today_label() -> str:
"""Deutsches Datumslabel ohne Locale-Abhängigkeit (z.B. 'Donnerstag, 11. Juni 2026')."""
d = date.today()
return f"{_WEEKDAYS_DE[d.weekday()]}, {d.day}. {_MONTHS_DE[d.month - 1]} {d.year}"

50
app/projects/models.py Normal file
View file

@ -0,0 +1,50 @@
"""Modelle des Projekte-Moduls: Project, Task.
Aufgaben dürfen ohne Projekt existieren (project_id nullable). Beim Löschen
eines Projekts werden seine Aufgaben nicht gelöscht, sondern in der Routen-Logik
(Schritt 5) auf project_id=None gesetzt.
"""
from ..extensions import db
from ..models import utcnow
class Project(db.Model):
__tablename__ = "projects"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(
db.Integer, db.ForeignKey("users.id"), nullable=False, index=True
)
name = db.Column(db.String(120), nullable=False)
# Hex-Farbe aus fester Palette (z.B. "#5b8def").
color = db.Column(db.String(7), nullable=False, default="#5b8def")
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
user = db.relationship("User", back_populates="projects")
tasks = db.relationship("Task", back_populates="project")
def __repr__(self) -> str:
return f"<Project {self.name}>"
class Task(db.Model):
__tablename__ = "tasks"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(
db.Integer, db.ForeignKey("users.id"), nullable=False, index=True
)
project_id = db.Column(
db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True
)
text = db.Column(db.String(500), nullable=False)
due_date = db.Column(db.Date, nullable=True)
done = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
user = db.relationship("User", back_populates="tasks")
project = db.relationship("Project", back_populates="tasks")
def __repr__(self) -> str:
return f"<Task {self.text[:30]!r} done={self.done}>"

175
app/projects/pdf.py Normal file
View file

@ -0,0 +1,175 @@
"""PDF-Export: projektbasierte To-Do-Liste zum Ausdrucken.
Erzeugt mit fpdf2 (reines Python, keine System-Abhängigkeiten). Die DejaVu-
Schrift ist im Repo mitgeliefert (app/projects/fonts/), damit das Ergebnis auf
dem VPS unabhängig von dort installierten Fonts identisch aussieht und beliebiger
Aufgabentext (Umlaute, Sonderzeichen) korrekt gerendert wird.
Inhalt laut Festlegung: offene UND erledigte Aufgaben (erledigte abgehakt und
durchgestrichen), Aufgaben ohne Projekt in einem eigenen Abschnitt am Ende.
"""
import os
from datetime import date
from fpdf import FPDF
from fpdf.enums import XPos, YPos
from .helpers import sort_tasks, today_label
FONT_DIR = os.path.join(os.path.dirname(__file__), "fonts")
# Farben (RGB) — bewusst auch in S/W-Druck lesbar (Info steht zusätzlich als Text).
COL_TEXT = (25, 25, 25)
COL_MUTED = (130, 130, 130)
COL_OVERDUE = (200, 50, 60)
COL_SOON = (190, 120, 20)
COL_RULE = (210, 210, 210)
def _hex_to_rgb(h: str) -> tuple:
h = h.lstrip("#")
return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4))
def _due_text(due: date | None, done: bool):
"""Fälligkeit als reiner Text + Farbe (ohne Emoji, druckfreundlich)."""
if due is None:
return None, COL_MUTED
label = f"{due.day:02d}.{due.month:02d}.{str(due.year)[2:]}"
if done:
return label, COL_MUTED
diff = (due - date.today()).days
if diff < 0:
return f"{label} · {abs(diff)} Tag(e) überfällig", COL_OVERDUE
if diff == 0:
return f"{label} · heute fällig", COL_SOON
if diff <= 7:
return f"{label} · in {diff} Tag(en)", COL_SOON
return label, COL_MUTED
class TodoPDF(FPDF):
def __init__(self):
super().__init__(format="A4")
self.set_auto_page_break(auto=True, margin=18)
self.set_margins(15, 15, 15)
self.add_font("DejaVu", "", os.path.join(FONT_DIR, "DejaVuSans.ttf"))
self.add_font("DejaVu", "B", os.path.join(FONT_DIR, "DejaVuSans-Bold.ttf"))
def header(self):
self.set_font("DejaVu", "B", 15)
self.set_text_color(*COL_TEXT)
self.cell(0, 9, "Projekte & Aufgaben", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
self.set_font("DejaVu", "", 9)
self.set_text_color(*COL_MUTED)
self.cell(0, 5, today_label(), new_x=XPos.LMARGIN, new_y=YPos.NEXT)
self.ln(3)
self.set_draw_color(*COL_RULE)
self.set_line_width(0.2)
y = self.get_y()
self.line(self.l_margin, y, self.w - self.r_margin, y)
self.ln(4)
def footer(self):
self.set_y(-14)
self.set_font("DejaVu", "", 8)
self.set_text_color(*COL_MUTED)
self.cell(0, 5, f"Seite {self.page_no()}", align="C")
def _checkbox(pdf: TodoPDF, done: bool, size: float = 3.6) -> None:
"""Kästchen als Vektor zeichnen (scharf im Druck); bei done mit Haken."""
x, y = pdf.get_x(), pdf.get_y()
top = y + 1.1
pdf.set_draw_color(*COL_TEXT)
pdf.set_line_width(0.3)
pdf.rect(x, top, size, size)
if done:
pdf.line(x + 0.7, top + size * 0.55, x + size * 0.42, top + size - 0.6)
pdf.line(x + size * 0.42, top + size - 0.6, x + size - 0.4, top + 0.6)
pdf.set_x(x + size + 2.5)
def _task_line(pdf: TodoPDF, task) -> None:
pdf.set_x(pdf.l_margin + 4)
_checkbox(pdf, task.done)
text_x = pdf.get_x()
avail = pdf.w - pdf.r_margin - text_x
pdf.set_font("DejaVu", "", 10.5)
pdf.set_text_color(*(COL_MUTED if task.done else COL_TEXT))
y_before = pdf.get_y()
pdf.multi_cell(avail, 5.2, task.text, new_x=XPos.LMARGIN, new_y=YPos.NEXT)
if task.done:
width = min(avail, pdf.get_string_width(task.text))
pdf.set_draw_color(*COL_MUTED)
pdf.set_line_width(0.3)
pdf.line(text_x, y_before + 2.6, text_x + width, y_before + 2.6)
due_str, col = _due_text(task.due_date, task.done)
if due_str:
pdf.set_x(text_x)
pdf.set_font("DejaVu", "", 8.5)
pdf.set_text_color(*col)
pdf.cell(0, 4.4, due_str, new_x=XPos.LMARGIN, new_y=YPos.NEXT)
pdf.ln(1.4)
def _section_header(pdf: TodoPDF, name: str, color_hex, open_count: int, total: int) -> None:
pdf.ln(2.5)
if color_hex:
x, y = pdf.get_x(), pdf.get_y()
pdf.set_fill_color(*_hex_to_rgb(color_hex))
pdf.ellipse(x, y + 1.6, 3, 3, style="F")
pdf.set_x(x + 5)
pdf.set_font("DejaVu", "B", 12)
pdf.set_text_color(*COL_TEXT)
pdf.cell(pdf.get_string_width(name) + 1, 6, name, new_x=XPos.RIGHT, new_y=YPos.TOP)
pdf.set_font("DejaVu", "", 9)
pdf.set_text_color(*COL_MUTED)
pdf.cell(0, 6, f" ({open_count} offen · {total} gesamt)",
new_x=XPos.LMARGIN, new_y=YPos.NEXT)
pdf.ln(1)
def build_todo_pdf(projects: list, tasks: list) -> bytes:
"""Baut die druckbare To-Do-PDF aus Projekten und Aufgaben des Users."""
by_pid = {}
for t in tasks:
by_pid.setdefault(t.project_id, []).append(t)
pdf = TodoPDF()
pdf.add_page()
rendered = False
for p in projects:
ptasks = sort_tasks(by_pid.get(p.id, []))
if not ptasks:
continue
rendered = True
open_count = sum(1 for t in ptasks if not t.done)
_section_header(pdf, p.name, p.color, open_count, len(ptasks))
for t in ptasks:
_task_line(pdf, t)
no_project = sort_tasks(by_pid.get(None, []))
if no_project:
rendered = True
open_count = sum(1 for t in no_project if not t.done)
_section_header(pdf, "Ohne Projekt", None, open_count, len(no_project))
for t in no_project:
_task_line(pdf, t)
if not rendered:
pdf.set_font("DejaVu", "", 11)
pdf.set_text_color(*COL_MUTED)
pdf.cell(0, 8, "Keine Aufgaben vorhanden.",
new_x=XPos.LMARGIN, new_y=YPos.NEXT)
return bytes(pdf.output())

206
app/projects/routes.py Normal file
View file

@ -0,0 +1,206 @@
"""Routen des Projekte-Moduls — voller Funktionsumfang aus dem HTML-Prototyp.
Alles ist login_required und strikt auf current_user gescoped. Schreibende
Aktionen laufen als POST + Redirect (PRG-Muster), damit kein Reload doppelt
ausführt.
"""
from datetime import date, datetime, timedelta
from urllib.parse import urlparse
from flask import render_template, request, redirect, url_for, flash, Response
from flask_login import login_required, current_user
from ..extensions import db
from .models import Project, Task
from .helpers import next_color, due_badge, sort_tasks, today_label
from .pdf import build_todo_pdf
from . import bp
VALID_FILTERS = {"all", "open", "overdue", "soon", "done"}
def _redirect_back():
"""Zur vorigen Ansicht zurück (erhält z.B. den Filter), sonst zur Übersicht."""
ref = request.referrer
if ref:
parsed = urlparse(ref)
if not parsed.netloc or parsed.netloc == urlparse(request.host_url).netloc:
return redirect(ref)
return redirect(url_for("projects.index"))
def _owned_project_or_none(project_id):
"""Projekt des aktuellen Users per (string) ID holen — oder None."""
try:
pid = int(project_id)
except (TypeError, ValueError):
return None
return Project.query.filter_by(id=pid, user_id=current_user.id).first()
@bp.route("/")
@login_required
def index():
flt = request.args.get("filter", "all")
if flt not in VALID_FILTERS:
flt = "all"
today = date.today()
week = today + timedelta(days=7)
projects = (
Project.query.filter_by(user_id=current_user.id)
.order_by(Project.created_at)
.all()
)
tasks = Task.query.filter_by(user_id=current_user.id).all()
projects_by_id = {p.id: p for p in projects}
# Statistik-Leiste
stats = {
"open": sum(1 for t in tasks if not t.done),
"done": sum(1 for t in tasks if t.done),
"overdue": sum(1 for t in tasks if not t.done and t.due_date and t.due_date < today),
"projects": len(projects),
}
# Ansicht "Nach Projekt"
project_views = []
for p in projects:
ptasks = sort_tasks([t for t in tasks if t.project_id == p.id])
project_views.append(
{
"project": p,
"tasks": ptasks,
"open": sum(1 for t in ptasks if not t.done),
"total": len(ptasks),
}
)
proj_open_total = sum(v["open"] for v in project_views)
# Ansicht "Alle Aufgaben" (gefiltert)
def keep(t):
if flt == "open":
return not t.done
if flt == "done":
return t.done
if flt == "overdue":
return not t.done and t.due_date and t.due_date < today
if flt == "soon":
return not t.done and t.due_date and today <= t.due_date <= week
return True
all_tasks = sort_tasks([t for t in tasks if keep(t)])
return render_template(
"projects/index.html",
projects=projects,
projects_by_id=projects_by_id,
project_views=project_views,
proj_open_total=proj_open_total,
all_tasks=all_tasks,
stats=stats,
active_filter=flt,
today_label=today_label(),
)
# ── Projekte ─────────────────────────────────────────────────────────────
@bp.route("/projects", methods=["POST"])
@login_required
def create_project():
name = (request.form.get("name") or "").strip()
if not name:
flash("Projektname fehlt.", "error")
return _redirect_back()
count = Project.query.filter_by(user_id=current_user.id).count()
project = Project(user_id=current_user.id, name=name[:120], color=next_color(count))
db.session.add(project)
db.session.commit()
return _redirect_back()
@bp.route("/projects/<int:project_id>/delete", methods=["POST"])
@login_required
def delete_project(project_id):
project = Project.query.filter_by(id=project_id, user_id=current_user.id).first_or_404()
# Aufgaben bleiben erhalten, verlieren nur die Projektzuordnung.
for t in project.tasks:
t.project_id = None
db.session.delete(project)
db.session.commit()
flash("Projekt gelöscht. Aufgaben bleiben erhalten.", "info")
return _redirect_back()
# ── Aufgaben ─────────────────────────────────────────────────────────────
@bp.route("/tasks", methods=["POST"])
@login_required
def create_task():
text = (request.form.get("text") or "").strip()
if not text:
flash("Aufgabentext fehlt.", "error")
return _redirect_back()
project = _owned_project_or_none(request.form.get("project_id"))
due_raw = (request.form.get("due_date") or "").strip()
due = None
if due_raw:
try:
due = datetime.strptime(due_raw, "%Y-%m-%d").date()
except ValueError:
due = None
task = Task(
user_id=current_user.id,
text=text[:500],
project_id=project.id if project else None,
due_date=due,
)
db.session.add(task)
db.session.commit()
return _redirect_back()
@bp.route("/tasks/<int:task_id>/toggle", methods=["POST"])
@login_required
def toggle_task(task_id):
task = Task.query.filter_by(id=task_id, user_id=current_user.id).first_or_404()
task.done = not task.done
db.session.commit()
return _redirect_back()
@bp.route("/tasks/<int:task_id>/delete", methods=["POST"])
@login_required
def delete_task(task_id):
task = Task.query.filter_by(id=task_id, user_id=current_user.id).first_or_404()
db.session.delete(task)
db.session.commit()
return _redirect_back()
# ── PDF-Export (druckbare To-Do-Liste) ──────────────────────────────────
@bp.route("/export.pdf")
@login_required
def export_pdf():
projects = (
Project.query.filter_by(user_id=current_user.id)
.order_by(Project.created_at)
.all()
)
tasks = Task.query.filter_by(user_id=current_user.id).all()
pdf_bytes = build_todo_pdf(projects, tasks)
filename = f"aufgaben_{date.today().isoformat()}.pdf"
return Response(
pdf_bytes,
mimetype="application/pdf",
headers={"Content-Disposition": f"inline; filename={filename}"},
)
# Fälligkeits-Badge als Template-Global verfügbar machen.
bp.add_app_template_global(due_badge, "due_badge")

View file

View file

@ -0,0 +1,21 @@
{# Wiederverwendbare Aufgabenzeile — genutzt in Projektkarten und der "Alle Aufgaben"-Liste.
show_project: ob das Projekt-Tag angezeigt wird (in der Projektkarte unnötig). #}
{% macro task_row(task, show_project, projects_by_id) %}
{% set badge = due_badge(task.due_date, task.done) %}
<div class="task{{ ' done' if task.done }}">
<form method="post" action="{{ url_for('projects.toggle_task', task_id=task.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="checkbox" {{ 'checked' if task.done }} onchange="this.form.submit()"
title="Erledigt umschalten">
</form>
<div class="task-body">
<span class="task-text">{{ task.text }}</span>{% if show_project and task.project_id and projects_by_id.get(task.project_id) %}{% set p = projects_by_id[task.project_id] %}<span class="task-project-tag" style="background:{{ p.color }}22;color:{{ p.color }}">{{ p.name }}</span>{% endif %}
<div><span class="task-due {{ badge.cls }}">{{ badge.text }}</span></div>
</div>
<form method="post" action="{{ url_for('projects.delete_task', task_id=task.id) }}"
data-confirm="Aufgabe löschen?">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="task-delete" type="submit" title="Löschen"></button>
</form>
</div>
{% endmacro %}

View file

@ -0,0 +1,121 @@
{% extends "base.html" %}
{% from "projects/_macros.html" import task_row %}
{% block title %}Projekte & Aufgaben · Projekt-Hub{% endblock %}
{% block content %}
<div class="page-head">
<h1>Projekte <span>&amp;</span> Aufgaben</h1>
<div class="today">{{ today_label }}</div>
</div>
{# ── Statistik-Leiste ── #}
<div class="stats">
<div class="stat"><strong>{{ stats.open }}</strong>Offen</div>
<div class="stat"><strong>{{ stats.done }}</strong>Erledigt</div>
<div class="stat"><strong style="color:var(--overdue)">{{ stats.overdue }}</strong>Überfällig</div>
<div class="stat"><strong>{{ stats.projects }}</strong>Projekte</div>
</div>
{# ── Druckbare PDF-Liste ── #}
<div class="sync-bar">
<span class="sync-label">Drucken</span>
<a class="btn-sync export" href="{{ url_for('projects.export_pdf') }}" target="_blank"
rel="noopener">PDF / Druckliste</a>
<span class="sync-hint">Projektbasierte To-Do-Liste als PDF zum Ausdrucken</span>
</div>
{# ── Aufgabe hinzufügen ── #}
<form class="add-form" method="post" action="{{ url_for('projects.create_task') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row">
<input type="text" name="text" placeholder="Neue Aufgabe …" autocomplete="off"
autofocus required>
</div>
<div class="row">
<select name="project_id">
<option value="">— kein Projekt —</option>
{% for p in projects %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</select>
<input type="date" name="due_date">
<button class="btn-add" type="submit">+ Hinzufügen</button>
</div>
</form>
{# ── Projekt anlegen ── #}
<form class="project-mgmt" method="post" action="{{ url_for('projects.create_project') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<span style="font-size:.78rem;color:var(--muted)">Projekt:</span>
<input type="text" name="name" placeholder="Name …" autocomplete="off" required>
<button class="btn-small" type="submit">+ Projekt anlegen</button>
</form>
{# ── Ansicht: Nach Projekt ── #}
<div class="section">
<div class="section-header clickable js-collapse" data-target="projects-body">
<span class="section-title">Nach Projekt</span>
<span class="section-count">{{ proj_open_total }} offen</span>
<button class="collapse-btn" type="button"></button>
</div>
<div id="projects-body">
{% for view in project_views %}
{% set p = view.project %}
<div class="project-card">
<div class="project-card-header js-collapse" data-target="proj-body-{{ p.id }}">
<span class="project-dot" style="background:{{ p.color }}"></span>
<span class="project-name">{{ p.name }}</span>
<span class="project-meta">{{ view.open }} offen · {{ view.total }} gesamt</span>
<form method="post" action="{{ url_for('projects.delete_project', project_id=p.id) }}"
data-confirm="Projekt löschen? Aufgaben bleiben erhalten."
onclick="event.stopPropagation()">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="project-delete" type="submit" title="Projekt löschen"></button>
</form>
</div>
<div class="task-list" id="proj-body-{{ p.id }}">
{% for t in view.tasks %}
{{ task_row(t, false, projects_by_id) }}
{% else %}
<div class="empty-hint">Keine Aufgaben — oben hinzufügen.</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="empty-hint">Noch keine Projekte — oben anlegen.</div>
{% endfor %}
</div>
</div>
<hr class="divider">
{# ── Ansicht: Alle Aufgaben (gefiltert) ── #}
<div class="section">
<div class="section-header">
<span class="section-title">Alle Aufgaben</span>
<span class="section-count">{{ all_tasks|length }}</span>
</div>
<div class="filter-bar">
<span class="filter-label">Zeigen:</span>
{% set filters = [('all', 'Alle'), ('open', 'Offen'), ('overdue', 'Überfällig'),
('soon', 'Diese Woche'), ('done', 'Erledigt')] %}
{% for key, label in filters %}
<a class="filter-btn {{ 'active' if active_filter == key }}"
href="{{ url_for('projects.index', filter=key) }}#alle">{{ label }}</a>
{% endfor %}
</div>
<div class="project-card" id="alle">
<div class="task-list all-tasks-list">
{% for t in all_tasks %}
{{ task_row(t, true, projects_by_id) }}
{% else %}
<div class="empty-hint">Keine Aufgaben in dieser Ansicht.</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}

485
app/static/css/main.css Normal file
View file

@ -0,0 +1,485 @@
/* =========================================================================
Projekt-Hub Dark-Theme-Basis
Palette & Komponenten aus dem Prototyp (reference/projekte.html) übernommen
und als gemeinsames Theme für den ganzen Hub aufbereitet. Module nutzen
diese Variablen und Komponentenklassen.
========================================================================= */
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #1a1a2e;
--surface: #16213e;
--card: #0f3460;
--accent: #e94560;
--accent2: #f5a623;
--text: #eaeaea;
--muted: #8892a4;
--done: #3a4a5c;
--done-text: #566272;
--overdue: #ff6b6b;
--soon: #f5a623;
--ok: #4ecca3;
--border: rgba(255, 255, 255, 0.07);
--font: 'Segoe UI', system-ui, sans-serif;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
min-height: 100vh;
}
a { color: var(--accent); }
/* =========================================================================
Hub-Navigation (gemeinsame Leiste über allen Modulen)
========================================================================= */
.hub-nav {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.85rem 1.5rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.hub-brand {
font-weight: 700;
font-size: 1.05rem;
letter-spacing: -0.02em;
color: var(--text);
text-decoration: none;
}
.hub-brand span { color: var(--accent); }
.hub-nav-list {
display: flex;
gap: 0.4rem;
list-style: none;
}
.hub-nav-list a {
color: var(--muted);
text-decoration: none;
font-size: 0.85rem;
padding: 0.35rem 0.7rem;
border-radius: 20px;
transition: background 0.15s, color 0.15s;
}
.hub-nav-list a:hover { color: var(--text); background: var(--card); }
.hub-nav-auth { margin-left: auto; display: flex; align-items: center; gap: 0.75rem; }
.hub-user { color: var(--muted); font-size: 0.82rem; }
.hub-nav-auth a {
color: var(--muted);
text-decoration: none;
font-size: 0.82rem;
transition: color 0.15s;
}
.hub-nav-auth a:hover { color: var(--accent); }
.hub-main {
max-width: 760px;
margin: 0 auto;
padding: 2rem 1rem 4rem;
}
.muted { color: var(--muted); }
.hidden { display: none; }
/* Lösch-Formulare in Aufgaben/Projekten sollen das Flex-Layout nicht stören. */
.task form, .project-card-header form { display: inline-flex; }
/* =========================================================================
Flash-Meldungen
========================================================================= */
.flashes { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1.5rem; }
.flash {
padding: 0.65rem 0.9rem;
border-radius: 8px;
border: 1px solid var(--border);
font-size: 0.9rem;
}
.flash-info { background: var(--surface); color: var(--text); }
.flash-success { background: rgba(78, 204, 163, 0.12); border-color: var(--ok); color: var(--ok); }
.flash-error { background: rgba(255, 107, 107, 0.14); border-color: var(--overdue); color: var(--overdue); }
/* =========================================================================
Auth-Karten / Formulare
========================================================================= */
.auth-card {
max-width: 380px;
margin: 3rem auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.75rem;
}
.auth-card h1 { margin-top: 0; margin-bottom: 0.4rem; font-size: 1.4rem; }
.auth-card .muted { margin-bottom: 1.2rem; font-size: 0.88rem; line-height: 1.5; }
.stack { display: flex; flex-direction: column; gap: 0.5rem; }
.stack label { font-size: 0.85rem; color: var(--muted); }
.stack input[type="text"],
.stack input[type="email"] {
width: 100%;
padding: 0.6rem 0.7rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 1rem;
font-family: var(--font);
outline: none;
transition: border-color 0.15s;
}
.stack input:focus { border-color: var(--accent); }
.field-error { color: var(--overdue); font-size: 0.83rem; }
.btn {
margin-top: 0.5rem;
padding: 0.6rem 0.9rem;
background: var(--accent);
border: none;
border-radius: 8px;
color: #fff;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.btn:hover { opacity: 0.85; }
/* =========================================================================
Modul "Projekte" Komponenten (aus dem Prototyp)
========================================================================= */
/* ── Seitenkopf ── */
.page-head {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 2rem;
}
.page-head h1 {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.page-head h1 span { color: var(--accent); }
.today { font-size: 0.8rem; color: var(--muted); }
/* ── Stats-Leiste ── */
.stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 2rem; }
.stat {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 1rem;
font-size: 0.8rem;
color: var(--muted);
}
.stat strong { color: var(--text); font-size: 1.1rem; display: block; }
/* ── Aufgaben-Formular ── */
.add-form {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.2rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 2.5rem;
}
.add-form .row { display: flex; gap: 0.6rem; flex-wrap: wrap; }
.add-form input[type="text"],
.add-form input[type="date"],
.add-form select {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: 0.55rem 0.8rem;
font-size: 0.9rem;
font-family: var(--font);
outline: none;
transition: border-color 0.15s;
}
.add-form input[type="text"] { flex: 1; min-width: 180px; }
.add-form input[type="date"] { width: 145px; color-scheme: dark; }
.add-form select { width: 150px; }
.add-form input:focus, .add-form select:focus { border-color: var(--accent); }
.btn-add {
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.55rem 1.2rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.15s;
}
.btn-add:hover { opacity: 0.85; }
/* ── Projektverwaltung ── */
.project-mgmt {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
margin-bottom: 1rem;
}
.project-mgmt input[type="text"] {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: 0.4rem 0.7rem;
font-size: 0.85rem;
font-family: var(--font);
outline: none;
width: 160px;
}
.project-mgmt input:focus { border-color: var(--accent); }
.btn-small {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-small:hover { background: var(--card); }
/* ── Abschnitte ── */
.section { margin-bottom: 2rem; }
.section-header {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.8rem;
user-select: none;
}
.section-header.clickable { cursor: pointer; }
.section-title {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.section-count {
font-size: 0.7rem;
background: var(--card);
color: var(--muted);
border-radius: 20px;
padding: 0.1rem 0.5rem;
}
.collapse-btn {
margin-left: auto;
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 0.75rem;
padding: 0;
}
/* ── Projektkarte ── */
.project-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
margin-bottom: 1rem;
overflow: hidden;
}
.project-card-header {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.85rem 1rem;
cursor: pointer;
border-bottom: 1px solid var(--border);
}
.project-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.project-name { font-weight: 600; font-size: 0.95rem; flex: 1; }
.project-meta { font-size: 0.75rem; color: var(--muted); }
.project-delete {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 0.85rem;
padding: 0.2rem 0.4rem;
border-radius: 4px;
transition: color 0.15s;
}
.project-delete:hover { color: var(--accent); }
/* ── Aufgaben-Eintrag ── */
.task-list { padding: 0.4rem 0; }
.task {
display: flex;
align-items: flex-start;
gap: 0.7rem;
padding: 0.55rem 1rem;
border-bottom: 1px solid var(--border);
transition: background 0.1s;
}
.task:last-child { border-bottom: none; }
.task:hover { background: rgba(255, 255, 255, 0.03); }
.task.done { opacity: 0.5; }
.task input[type="checkbox"] {
margin-top: 0.15rem;
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
flex-shrink: 0;
}
.task-body { flex: 1; min-width: 0; }
.task-text { font-size: 0.9rem; line-height: 1.4; word-break: break-word; }
.task.done .task-text { text-decoration: line-through; color: var(--done-text); }
.task-due {
display: inline-block;
margin-top: 0.25rem;
font-size: 0.72rem;
font-weight: 600;
padding: 0.1rem 0.5rem;
border-radius: 20px;
letter-spacing: 0.03em;
}
.due-overdue { background: rgba(255, 107, 107, 0.18); color: var(--overdue); }
.due-soon { background: rgba(245, 166, 35, 0.18); color: var(--soon); }
.due-ok { background: rgba(78, 204, 163, 0.12); color: var(--ok); }
.due-none { background: var(--card); color: var(--muted); }
.task-project-tag {
font-size: 0.68rem;
padding: 0.1rem 0.45rem;
border-radius: 20px;
margin-left: 0.3rem;
font-weight: 600;
}
.task-delete {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 0.8rem;
padding: 0.1rem 0.3rem;
border-radius: 4px;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
flex-shrink: 0;
}
.task:hover .task-delete { opacity: 1; }
.task-delete:hover { color: var(--accent); }
.empty-hint {
text-align: center;
color: var(--muted);
font-size: 0.85rem;
padding: 1.5rem;
}
/* ── Filter-Leiste ── */
.filter-bar {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
margin-bottom: 0.75rem;
}
.filter-btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--muted);
border-radius: 20px;
padding: 0.3rem 0.8rem;
font-size: 0.78rem;
cursor: pointer;
transition: all 0.15s;
}
.filter-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.filter-btn:hover:not(.active) { border-color: var(--muted); color: var(--text); }
.filter-label { font-size: 0.75rem; color: var(--muted); margin-right: 0.2rem; }
/* ── Trenner ── */
.divider { margin-bottom: 2rem; border: none; border-top: 1px solid var(--border); }
/* ── Backup/Sync-Leiste ── */
.sync-bar {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.9rem 1.2rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 2.5rem;
}
.sync-label {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin-right: 0.25rem;
}
.btn-sync {
background: var(--card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.4rem 0.9rem;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
display: inline-flex;
align-items: center;
gap: 0.4rem;
text-decoration: none;
}
.btn-sync:hover { background: #1a3a5c; border-color: var(--accent); }
.btn-sync.export { border-color: var(--ok); color: var(--ok); }
.btn-sync.export:hover { background: rgba(78, 204, 163, 0.1); }
.btn-sync.import { border-color: var(--accent2); color: var(--accent2); }
.btn-sync.import:hover { background: rgba(245, 166, 35, 0.1); }
.sync-hint { font-size: 0.72rem; color: var(--muted); margin-left: auto; }
/* ── Toast ── */
.sync-toast {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%) translateY(80px);
background: var(--card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem 1.2rem;
font-size: 0.85rem;
font-weight: 500;
pointer-events: none;
transition: transform 0.25s ease, opacity 0.25s ease;
opacity: 0;
z-index: 100;
white-space: nowrap;
}
.sync-toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }

26
app/static/js/app.js Normal file
View file

@ -0,0 +1,26 @@
// Minimal-JS für den Projekt-Hub — Progressive Enhancement, keine Abhängigkeiten.
// 1) Bestätigungsdialog für Formulare mit data-confirm (z.B. Löschen).
document.addEventListener("submit", function (e) {
var form = e.target;
if (form.dataset && form.dataset.confirm) {
if (!window.confirm(form.dataset.confirm)) {
e.preventDefault();
}
}
});
// 2) Klappbare Bereiche: Klick auf .js-collapse blendet das Ziel (data-target) aus.
document.addEventListener("click", function (e) {
var head = e.target.closest(".js-collapse");
if (!head) return;
// Klicks auf interaktive Elemente (z.B. Lösch-Button) nicht als Klapp-Klick werten.
if (e.target.closest("button, a, input, form") && !e.target.closest(".collapse-btn")) {
return;
}
var target = document.getElementById(head.dataset.target);
if (!target) return;
var collapsed = target.classList.toggle("hidden");
var btn = head.querySelector(".collapse-btn");
if (btn) btn.textContent = collapsed ? "▼" : "▲";
});

View file

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Anmelden · Projekt-Hub{% endblock %}
{% block content %}
<div class="auth-card">
<h1>Anmelden</h1>
<p class="muted">Gib deine E-Mail-Adresse ein — wir schicken dir einen
6-stelligen Code zum Einloggen.</p>
<form method="post" class="stack" novalidate>
{{ form.hidden_tag() }}
<label for="email">{{ form.email.label.text }}</label>
{{ form.email(id="email", type="email", autocomplete="email", autofocus=true,
placeholder="du@beispiel.de") }}
{% for err in form.email.errors %}
<span class="field-error">{{ err }}</span>
{% endfor %}
{{ form.submit(class="btn") }}
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Code eingeben · Projekt-Hub{% endblock %}
{% block content %}
<div class="auth-card">
<h1>Code eingeben</h1>
<p class="muted">Wir haben einen Code an <strong>{{ email }}</strong> geschickt.
Er ist nur kurz gültig.</p>
<form method="post" class="stack" novalidate>
{{ form.hidden_tag() }}
<label for="code">{{ form.code.label.text }}</label>
{{ form.code(id="code", inputmode="numeric", autocomplete="one-time-code",
autofocus=true, maxlength="6", placeholder="123456") }}
{% for err in form.code.errors %}
<span class="field-error">{{ err }}</span>
{% endfor %}
{{ form.submit(class="btn") }}
</form>
<p class="muted"><a href="{{ url_for('auth.login') }}">Andere E-Mail verwenden</a></p>
</div>
{% endblock %}

41
app/templates/base.html Normal file
View file

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Projekt-Hub{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
</head>
<body>
<nav class="hub-nav">
<a class="hub-brand" href="{{ url_for('index') }}">Projekt<span>Hub</span>
<ul class="hub-nav-list">
{% for mod in nav_modules %}
<li><a href="{{ url_for(mod.endpoint) }}">{{ mod.icon }} {{ mod.label }}</a></li>
{% endfor %}
</ul>
<div class="hub-nav-auth">
{% if current_user.is_authenticated %}
<span class="hub-user">{{ current_user.email }}</span>
<a href="{{ url_for('auth.logout') }}">Abmelden</a>
{% endif %}
</div>
</nav>
<main class="hub-main">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flashes">
{% for category, message in messages %}
<div class="flash flash-{{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<script src="{{ url_for('static', filename='js/app.js') }}" defer></script>
</body>
</html>

32
deploy/README.md Normal file
View file

@ -0,0 +1,32 @@
# deploy/ — Deployment-Artefakte
Versionierte Vorlagen für den VPS-Betrieb. Die ausführliche Schritt-für-Schritt-
Anleitung steht in [`../DEPLOYMENT.md`](../DEPLOYMENT.md); hier liegen die Dateien,
die dort referenziert werden.
| Datei | Zweck | Ziel auf dem VPS |
|-------|-------|------------------|
| `../gunicorn.conf.py` | Gunicorn-Konfiguration (Worker, Socket, Logging) | bleibt im Projektordner, via `-c` geladen |
| `projekt-hub.service` | systemd-Unit (Autostart, Neustart bei Absturz) | `/etc/systemd/system/projekt-hub.service` |
| `nginx/projekt-hub.conf` | nginx-Reverse-Proxy (Socket-Proxy + Static) | `/etc/nginx/sites-available/projekt-hub` |
## Vor dem Kopieren anpassen
Alle Dateien gehen von diesen Annahmen aus — bei Abweichung anpassen:
- Nutzer: `projekthub`, Gruppe `www-data`
- Projektpfad: `/home/projekthub/projekt-hub`
- venv: `.venv` im Projektordner
- Socket: `projekt-hub.sock` im Projektordner
## Rate-Limiting-Backend
Mit mehreren gunicorn-Workern braucht Flask-Limiter ein gemeinsames Backend,
sonst zählt jeder Worker eigene Limits. In der `.env`:
```ini
RATELIMIT_STORAGE_URI=redis://localhost:6379
```
Redis installieren: `sudo apt install -y redis-server` (siehe `DEPLOYMENT.md`).
Ohne Redis bleibt `memory://` möglich — dann am besten nur **ein** Worker.

View file

@ -0,0 +1,36 @@
# nginx-Reverse-Proxy für den Projekt-Hub.
#
# Installation:
# sudo cp deploy/nginx/projekt-hub.conf /etc/nginx/sites-available/projekt-hub
# sudo ln -s /etc/nginx/sites-available/projekt-hub /etc/nginx/sites-enabled/
# sudo nginx -t && sudo systemctl reload nginx
#
# HTTPS wird hier NICHT manuell konfiguriert — certbot --nginx ergänzt die
# TLS-Direktiven automatisch, sobald eine Domain feststeht (siehe DEPLOYMENT.md §10).
server {
listen 80;
listen [::]:80;
# Zunächst Server-IP; später auf die echte Domain ändern und certbot laufen lassen.
server_name DEINE_SERVER_IP;
# Statische Dateien direkt von nginx ausliefern (schneller, entlastet gunicorn).
location /static/ {
alias /home/projekthub/projekt-hub/app/static/;
expires 30d;
access_log off;
}
location / {
proxy_pass http://unix:/home/projekthub/projekt-hub/projekt-hub.sock;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Wichtig: App nimmt HTTPS an (PREFERRED_URL_SCHEME=https, Secure-Cookies).
proxy_set_header X-Forwarded-Proto $scheme;
}
# PDF-Export und sonstige Antworten dürfen ruhig etwas größer sein.
client_max_body_size 4m;
}

View file

@ -0,0 +1,34 @@
# systemd-Service für den Projekt-Hub (Gunicorn).
#
# Installation:
# sudo cp deploy/projekt-hub.service /etc/systemd/system/projekt-hub.service
# # Pfade/Nutzer unten ggf. anpassen, dann:
# sudo systemctl daemon-reload
# sudo systemctl enable --now projekt-hub
# sudo systemctl status projekt-hub
#
# Annahme: Code unter /home/projekthub/projekt-hub, venv unter .venv.
[Unit]
Description=Projekt-Hub Flask App (Gunicorn)
After=network.target
# Falls Rate-Limiting über lokales Redis läuft, Redis vorher starten:
# After=network.target redis-server.service
# Wants=redis-server.service
[Service]
User=projekthub
Group=www-data
WorkingDirectory=/home/projekthub/projekt-hub
Environment="PATH=/home/projekthub/projekt-hub/.venv/bin"
ExecStart=/home/projekthub/projekt-hub/.venv/bin/gunicorn -c gunicorn.conf.py wsgi:app
Restart=always
RestartSec=3
# Etwas Härtung (optional, aber empfohlen):
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
[Install]
WantedBy=multi-user.target

31
gunicorn.conf.py Normal file
View file

@ -0,0 +1,31 @@
"""Gunicorn-Konfiguration für den Projekt-Hub.
Aufruf (im aktivierten venv, WorkingDirectory = Projektordner):
gunicorn -c gunicorn.conf.py wsgi:app
Wird vom systemd-Service (deploy/projekt-hub.service) genutzt. Werte sind über
Umgebungsvariablen überschreibbar, sinnvolle Defaults für einen kleinen VPS.
"""
import multiprocessing
import os
# Bind-Adresse: standardmäßig ein Unix-Socket relativ zum WorkingDirectory.
# nginx spricht denselben Socket per absoluter Pfadangabe an
# (z.B. /home/projekthub/projekt-hub/projekt-hub.sock).
bind = os.environ.get("GUNICORN_BIND", "unix:projekt-hub.sock")
# Worker-Anzahl: Faustregel (2 × CPU-Kerne) + 1, per env begrenzbar.
workers = int(os.environ.get("GUNICORN_WORKERS", str(multiprocessing.cpu_count() * 2 + 1)))
worker_class = os.environ.get("GUNICORN_WORKER_CLASS", "sync")
timeout = int(os.environ.get("GUNICORN_TIMEOUT", "30"))
# Socket-Dateirechte, damit nginx (Gruppe www-data) zugreifen kann.
umask = 0o007
# Logs auf stdout/stderr → landen via systemd im journal (journalctl -u projekt-hub).
accesslog = "-"
errorlog = "-"
loglevel = os.environ.get("GUNICORN_LOGLEVEL", "info")
proc_name = "projekt-hub"

0
instance/.gitkeep Normal file
View file

1
migrations/README Normal file
View file

@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View file

@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[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

113
migrations/env.py Normal file
View file

@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View file

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,93 @@
"""initial schema: users, login_codes, projects, tasks
Revision ID: cc4f09c8a12f
Revises:
Create Date: 2026-06-11 02:25:05.478589
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'cc4f09c8a12f'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=True)
op.create_table('login_codes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('code_hash', sa.String(length=255), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('used', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('login_codes', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_login_codes_user_id'), ['user_id'], unique=False)
op.create_table('projects',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('color', sa.String(length=7), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('projects', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_projects_user_id'), ['user_id'], unique=False)
op.create_table('tasks',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('project_id', sa.Integer(), nullable=True),
sa.Column('text', sa.String(length=500), nullable=False),
sa.Column('due_date', sa.Date(), nullable=True),
sa.Column('done', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('tasks', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_tasks_project_id'), ['project_id'], unique=False)
batch_op.create_index(batch_op.f('ix_tasks_user_id'), ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tasks', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_tasks_user_id'))
batch_op.drop_index(batch_op.f('ix_tasks_project_id'))
op.drop_table('tasks')
with op.batch_alter_table('projects', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_projects_user_id'))
op.drop_table('projects')
with op.batch_alter_table('login_codes', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_login_codes_user_id'))
op.drop_table('login_codes')
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_users_email'))
op.drop_table('users')
# ### end Alembic commands ###

873
reference/projekte.html Normal file
View file

@ -0,0 +1,873 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Projekte</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #1a1a2e;
--surface: #16213e;
--card: #0f3460;
--accent: #e94560;
--accent2: #f5a623;
--text: #eaeaea;
--muted: #8892a4;
--done: #3a4a5c;
--done-text: #566272;
--overdue: #ff6b6b;
--soon: #f5a623;
--ok: #4ecca3;
--border: rgba(255,255,255,0.07);
--font: 'Segoe UI', system-ui, sans-serif;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
min-height: 100vh;
padding: 2rem 1rem 4rem;
}
header {
max-width: 760px;
margin: 0 auto 2.5rem;
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: .5rem;
}
h1 {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -.02em;
color: var(--text);
}
h1 span { color: var(--accent); }
.today {
font-size: .8rem;
color: var(--muted);
}
/* ── Stats bar ── */
.stats {
max-width: 760px;
margin: 0 auto 2rem;
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.stat {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: .5rem 1rem;
font-size: .8rem;
color: var(--muted);
}
.stat strong { color: var(--text); font-size: 1.1rem; display: block; }
/* ── Add form ── */
.add-form {
max-width: 760px;
margin: 0 auto 2.5rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.2rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
.add-form .row {
display: flex;
gap: .6rem;
flex-wrap: wrap;
}
.add-form input[type="text"],
.add-form input[type="date"],
.add-form select {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: .55rem .8rem;
font-size: .9rem;
font-family: var(--font);
outline: none;
transition: border-color .15s;
}
.add-form input[type="text"] { flex: 1; min-width: 180px; }
.add-form input[type="date"] { width: 145px; color-scheme: dark; }
.add-form select { width: 150px; }
.add-form input:focus, .add-form select:focus {
border-color: var(--accent);
}
.btn-add {
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
padding: .55rem 1.2rem;
font-size: .9rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: opacity .15s;
}
.btn-add:hover { opacity: .85; }
/* ── Project management ── */
.project-mgmt {
max-width: 760px;
margin: 0 auto 1rem;
display: flex;
gap: .5rem;
flex-wrap: wrap;
align-items: center;
}
.project-mgmt input[type="text"] {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: .4rem .7rem;
font-size: .85rem;
font-family: var(--font);
outline: none;
width: 160px;
}
.project-mgmt input:focus { border-color: var(--accent); }
.btn-small {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: .4rem .8rem;
font-size: .8rem;
cursor: pointer;
transition: background .15s;
}
.btn-small:hover { background: var(--card); }
/* ── Sections ── */
.section {
max-width: 760px;
margin: 0 auto 2rem;
}
.section-header {
display: flex;
align-items: center;
gap: .6rem;
margin-bottom: .8rem;
cursor: pointer;
user-select: none;
}
.section-title {
font-size: .75rem;
font-weight: 700;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--muted);
}
.section-count {
font-size: .7rem;
background: var(--card);
color: var(--muted);
border-radius: 20px;
padding: .1rem .5rem;
}
.collapse-btn {
margin-left: auto;
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: .75rem;
padding: 0;
}
/* ── Project card ── */
.project-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
margin-bottom: 1rem;
overflow: hidden;
}
.project-card-header {
display: flex;
align-items: center;
gap: .7rem;
padding: .85rem 1rem;
cursor: pointer;
border-bottom: 1px solid var(--border);
}
.project-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.project-name {
font-weight: 600;
font-size: .95rem;
flex: 1;
}
.project-meta {
font-size: .75rem;
color: var(--muted);
}
.project-delete {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: .85rem;
padding: .2rem .4rem;
border-radius: 4px;
transition: color .15s;
}
.project-delete:hover { color: var(--accent); }
/* ── Task item ── */
.task-list { padding: .4rem 0; }
.task {
display: flex;
align-items: flex-start;
gap: .7rem;
padding: .55rem 1rem;
border-bottom: 1px solid var(--border);
transition: background .1s;
}
.task:last-child { border-bottom: none; }
.task:hover { background: rgba(255,255,255,.03); }
.task.done { opacity: .5; }
.task input[type="checkbox"] {
margin-top: .15rem;
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
flex-shrink: 0;
}
.task-body { flex: 1; min-width: 0; }
.task-text {
font-size: .9rem;
line-height: 1.4;
word-break: break-word;
}
.task.done .task-text {
text-decoration: line-through;
color: var(--done-text);
}
.task-due {
display: inline-block;
margin-top: .25rem;
font-size: .72rem;
font-weight: 600;
padding: .1rem .5rem;
border-radius: 20px;
letter-spacing: .03em;
}
.due-overdue { background: rgba(255,107,107,.18); color: var(--overdue); }
.due-soon { background: rgba(245,166,35,.18); color: var(--soon); }
.due-ok { background: rgba(78,204,163,.12); color: var(--ok); }
.due-none { background: var(--card); color: var(--muted); }
.task-project-tag {
font-size: .68rem;
padding: .1rem .45rem;
border-radius: 20px;
margin-left: .3rem;
font-weight: 600;
}
.task-delete {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: .8rem;
padding: .1rem .3rem;
border-radius: 4px;
opacity: 0;
transition: opacity .15s, color .15s;
flex-shrink: 0;
}
.task:hover .task-delete { opacity: 1; }
.task-delete:hover { color: var(--accent); }
/* ── All tasks section ── */
.all-tasks-list .task { border-bottom: 1px solid var(--border); }
.empty-hint {
text-align: center;
color: var(--muted);
font-size: .85rem;
padding: 1.5rem;
}
/* ── Filter bar ── */
.filter-bar {
max-width: 760px;
margin: 0 auto .75rem;
display: flex;
gap: .5rem;
flex-wrap: wrap;
align-items: center;
}
.filter-btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--muted);
border-radius: 20px;
padding: .3rem .8rem;
font-size: .78rem;
cursor: pointer;
transition: all .15s;
}
.filter-btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.filter-btn:hover:not(.active) { border-color: var(--muted); color: var(--text); }
.filter-label {
font-size: .75rem;
color: var(--muted);
margin-right: .2rem;
}
/* ── Divider ── */
.divider {
max-width: 760px;
margin: 0 auto 2rem;
border: none;
border-top: 1px solid var(--border);
}
/* ── Sync bar ── */
.sync-bar {
max-width: 760px;
margin: 0 auto 2.5rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: .9rem 1.2rem;
display: flex;
align-items: center;
gap: .75rem;
flex-wrap: wrap;
}
.sync-label {
font-size: .75rem;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--muted);
margin-right: .25rem;
}
.btn-sync {
background: var(--card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: .4rem .9rem;
font-size: .82rem;
font-weight: 600;
cursor: pointer;
transition: background .15s, border-color .15s;
display: flex;
align-items: center;
gap: .4rem;
}
.btn-sync:hover { background: #1a3a5c; border-color: var(--accent); }
.btn-sync.export { border-color: var(--ok); color: var(--ok); }
.btn-sync.export:hover { background: rgba(78,204,163,.1); }
.btn-sync.import { border-color: var(--accent2); color: var(--accent2); }
.btn-sync.import:hover { background: rgba(245,166,35,.1); }
.sync-hint {
font-size: .72rem;
color: var(--muted);
margin-left: auto;
}
.sync-toast {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%) translateY(80px);
background: var(--card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: .6rem 1.2rem;
font-size: .85rem;
font-weight: 500;
pointer-events: none;
transition: transform .25s ease, opacity .25s ease;
opacity: 0;
z-index: 100;
white-space: nowrap;
}
.sync-toast.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
</style>
</head>
<body>
<header>
<h1>Projekte <span>&amp;</span> Aufgaben</h1>
<div class="today" id="today-date"></div>
</header>
<!-- Stats -->
<div class="stats" id="stats"></div>
<!-- Sync bar -->
<div class="sync-bar">
<span class="sync-label">↕ Sync</span>
<button class="btn-sync export" onclick="exportData()">⬇ Exportieren</button>
<button class="btn-sync import" onclick="document.getElementById('import-file').click()">⬆ Importieren</button>
<input type="file" id="import-file" accept=".json" style="display:none" onchange="importData(event)">
<span class="sync-hint">Exportieren → auf Proton Drive laden → auf anderem Gerät importieren</span>
</div>
<div id="sync-toast" class="sync-toast"></div>
<!-- Add task -->
<div class="add-form">
<div class="row">
<input type="text" id="new-task-text" placeholder="Neue Aufgabe …" />
</div>
<div class="row">
<select id="new-task-project"></select>
<input type="date" id="new-task-due" />
<button class="btn-add" onclick="addTask()">+ Hinzufügen</button>
</div>
</div>
<!-- Project management -->
<div class="project-mgmt">
<span style="font-size:.78rem;color:var(--muted)">Projekt:</span>
<input type="text" id="new-project-name" placeholder="Name …" />
<button class="btn-small" onclick="addProject()">+ Projekt anlegen</button>
</div>
<!-- Projects section -->
<div class="section" id="projects-section">
<div class="section-header" onclick="toggleSection('projects-body')">
<span class="section-title">Nach Projekt</span>
<span class="section-count" id="proj-count">0</span>
<button class="collapse-btn" id="projects-collapse-btn"></button>
</div>
<div id="projects-body"></div>
</div>
<hr class="divider">
<!-- All tasks section -->
<div class="section">
<div class="section-header">
<span class="section-title">Alle Aufgaben</span>
<span class="section-count" id="all-count">0</span>
</div>
<div class="filter-bar">
<span class="filter-label">Zeigen:</span>
<button class="filter-btn active" onclick="setFilter('all', this)">Alle</button>
<button class="filter-btn" onclick="setFilter('open', this)">Offen</button>
<button class="filter-btn" onclick="setFilter('overdue', this)">Überfällig</button>
<button class="filter-btn" onclick="setFilter('soon', this)">Diese Woche</button>
<button class="filter-btn" onclick="setFilter('done', this)">Erledigt</button>
</div>
<div class="project-card">
<div class="task-list all-tasks-list" id="all-tasks-list"></div>
</div>
</div>
<script>
// ── Data ──────────────────────────────────────────────────────────────
const STORE_KEY = 'projekte_v1';
const PROJECT_COLORS = [
'#e94560','#f5a623','#4ecca3','#a78bfa','#60a5fa',
'#f472b6','#34d399','#fb923c','#818cf8','#38bdf8'
];
let data = {
projects: [
{ id: 'p1', name: 'Beispielprojekt', color: PROJECT_COLORS[0] }
],
tasks: [
{ id: 't1', text: 'Diese Aufgabe abhaken ✓', project: 'p1', due: '', done: false, created: Date.now() }
]
};
let activeFilter = 'all';
let collapsedProjects = {};
let sectionCollapsed = false;
function save() {
localStorage.setItem(STORE_KEY, JSON.stringify(data));
}
function load() {
const raw = localStorage.getItem(STORE_KEY);
if (raw) {
try { data = JSON.parse(raw); } catch(e) {}
}
}
// ── IDs ───────────────────────────────────────────────────────────────
function uid() { return 'id_' + Math.random().toString(36).slice(2); }
// ── Date helpers ──────────────────────────────────────────────────────
function today() {
const d = new Date();
return d.toISOString().split('T')[0];
}
function daysUntil(dateStr) {
if (!dateStr) return null;
const t = new Date(today());
const d = new Date(dateStr);
return Math.round((d - t) / 86400000);
}
function dueBadge(dateStr, done) {
if (!dateStr) return `<span class="task-due due-none">kein Datum</span>`;
const diff = daysUntil(dateStr);
const [y, m, dd] = dateStr.split('-');
const label = `${dd}.${m}.${y.slice(2)}`;
if (done) return `<span class="task-due due-none">${label}</span>`;
if (diff < 0) return `<span class="task-due due-overdue">⚠ ${label} (${Math.abs(diff)}d über)</span>`;
if (diff <= 7) return `<span class="task-due due-soon">⏳ ${label} (${diff}d)</span>`;
return `<span class="task-due due-ok">📅 ${label}</span>`;
}
// ── Projects ──────────────────────────────────────────────────────────
function addProject() {
const inp = document.getElementById('new-project-name');
const name = inp.value.trim();
if (!name) return;
const color = PROJECT_COLORS[data.projects.length % PROJECT_COLORS.length];
data.projects.push({ id: uid(), name, color });
inp.value = '';
save();
render();
}
function deleteProject(id) {
if (!confirm('Projekt löschen? Aufgaben bleiben erhalten.')) return;
data.projects = data.projects.filter(p => p.id !== id);
data.tasks.forEach(t => { if (t.project === id) t.project = ''; });
save();
render();
}
function projectById(id) {
return data.projects.find(p => p.id === id) || null;
}
// ── Tasks ─────────────────────────────────────────────────────────────
function addTask() {
const textEl = document.getElementById('new-task-text');
const projEl = document.getElementById('new-task-project');
const dueEl = document.getElementById('new-task-due');
const text = textEl.value.trim();
if (!text) { textEl.focus(); return; }
data.tasks.push({
id: uid(),
text,
project: projEl.value,
due: dueEl.value,
done: false,
created: Date.now()
});
textEl.value = '';
save();
render();
textEl.focus();
}
function toggleDone(id) {
const t = data.tasks.find(t => t.id === id);
if (t) { t.done = !t.done; save(); render(); }
}
function deleteTask(id) {
data.tasks = data.tasks.filter(t => t.id !== id);
save();
render();
}
// ── Filter ────────────────────────────────────────────────────────────
function setFilter(f, btn) {
activeFilter = f;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
render();
}
function applyFilter(tasks) {
const tod = today();
const weekOut = new Date(tod);
weekOut.setDate(weekOut.getDate() + 7);
const weekStr = weekOut.toISOString().split('T')[0];
if (activeFilter === 'open') return tasks.filter(t => !t.done);
if (activeFilter === 'done') return tasks.filter(t => t.done);
if (activeFilter === 'overdue') return tasks.filter(t => !t.done && t.due && t.due < tod);
if (activeFilter === 'soon') return tasks.filter(t => !t.done && t.due && t.due >= tod && t.due <= weekStr);
return tasks;
}
// ── Collapse ──────────────────────────────────────────────────────────
function toggleSection(id) {
const el = document.getElementById(id);
const btn = document.getElementById('projects-collapse-btn');
sectionCollapsed = !sectionCollapsed;
el.style.display = sectionCollapsed ? 'none' : '';
btn.textContent = sectionCollapsed ? '▼' : '▲';
}
function toggleProject(pid) {
collapsedProjects[pid] = !collapsedProjects[pid];
render();
}
// ── Render ────────────────────────────────────────────────────────────
function buildTaskEl(task, showProject) {
const proj = projectById(task.project);
const div = document.createElement('div');
div.className = 'task' + (task.done ? ' done' : '');
let projTag = '';
if (showProject && proj) {
projTag = `<span class="task-project-tag" style="background:${proj.color}22;color:${proj.color}">${proj.name}</span>`;
}
div.innerHTML = `
<input type="checkbox" ${task.done ? 'checked' : ''} onchange="toggleDone('${task.id}')">
<div class="task-body">
<span class="task-text">${escHtml(task.text)}</span>${projTag}
<div>${dueBadge(task.due, task.done)}</div>
</div>
<button class="task-delete" onclick="deleteTask('${task.id}')" title="Löschen"></button>
`;
return div;
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function render() {
// Today
document.getElementById('today-date').textContent =
new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long', year:'numeric' });
// Stats
const open = data.tasks.filter(t => !t.done).length;
const done = data.tasks.filter(t => t.done).length;
const overdue = data.tasks.filter(t => !t.done && t.due && t.due < today()).length;
document.getElementById('stats').innerHTML = `
<div class="stat"><strong>${open}</strong>Offen</div>
<div class="stat"><strong>${done}</strong>Erledigt</div>
<div class="stat"><strong style="color:var(--overdue)">${overdue}</strong>Überfällig</div>
<div class="stat"><strong>${data.projects.length}</strong>Projekte</div>
`;
// Project selector in form
const sel = document.getElementById('new-task-project');
const prev = sel.value;
sel.innerHTML = '<option value="">— kein Projekt —</option>' +
data.projects.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
if (prev) sel.value = prev;
// Projects body
const pb = document.getElementById('projects-body');
pb.innerHTML = '';
let totalProjOpen = 0;
data.projects.forEach(proj => {
const tasks = data.tasks.filter(t => t.project === proj.id);
const openT = tasks.filter(t => !t.done).length;
totalProjOpen += openT;
const collapsed = collapsedProjects[proj.id];
const card = document.createElement('div');
card.className = 'project-card';
const hdr = document.createElement('div');
hdr.className = 'project-card-header';
hdr.innerHTML = `
<span class="project-dot" style="background:${proj.color}"></span>
<span class="project-name">${escHtml(proj.name)}</span>
<span class="project-meta">${openT} offen · ${tasks.length} gesamt</span>
<button class="project-delete" onclick="event.stopPropagation();deleteProject('${proj.id}')" title="Projekt löschen"></button>
`;
hdr.onclick = () => { collapsedProjects[proj.id] = !collapsed; render(); };
card.appendChild(hdr);
if (!collapsed) {
const list = document.createElement('div');
list.className = 'task-list';
const sorted = [...tasks].sort((a,b) => {
if (a.done !== b.done) return a.done ? 1 : -1;
if (!a.due && !b.due) return 0;
if (!a.due) return 1;
if (!b.due) return -1;
return a.due.localeCompare(b.due);
});
if (sorted.length === 0) {
list.innerHTML = '<div class="empty-hint">Keine Aufgaben — oben hinzufügen.</div>';
} else {
sorted.forEach(t => list.appendChild(buildTaskEl(t, false)));
}
card.appendChild(list);
}
pb.appendChild(card);
});
if (data.projects.length === 0) {
pb.innerHTML = '<div class="empty-hint">Noch keine Projekte — oben anlegen.</div>';
}
document.getElementById('proj-count').textContent = totalProjOpen + ' offen';
// All tasks
const allList = document.getElementById('all-tasks-list');
allList.innerHTML = '';
const filtered = applyFilter([...data.tasks]).sort((a,b) => {
if (a.done !== b.done) return a.done ? 1 : -1;
if (!a.due && !b.due) return 0;
if (!a.due) return 1;
if (!b.due) return -1;
return a.due.localeCompare(b.due);
});
document.getElementById('all-count').textContent = filtered.length;
if (filtered.length === 0) {
allList.innerHTML = '<div class="empty-hint">Keine Aufgaben in dieser Ansicht.</div>';
} else {
filtered.forEach(t => allList.appendChild(buildTaskEl(t, true)));
}
}
// ── Enter key ─────────────────────────────────────────────────────────
document.getElementById('new-task-text').addEventListener('keydown', e => {
if (e.key === 'Enter') addTask();
});
document.getElementById('new-project-name').addEventListener('keydown', e => {
if (e.key === 'Enter') addProject();
});
// ── Export / Import ───────────────────────────────────────────────────
function exportData() {
const filename = 'projekte_' + today() + '.json';
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
showToast('✓ Exportiert als ' + filename);
}
function importData(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
try {
const imported = JSON.parse(e.target.result);
if (!imported.projects || !imported.tasks) throw new Error('Ungültiges Format');
const existing = data.tasks.length + data.projects.length;
const msg = existing > 0
? `Import ersetzt ${data.tasks.length} Aufgaben und ${data.projects.length} Projekte durch die importierten Daten. Fortfahren?`
: 'Daten importieren?';
if (existing === 0 || confirm(msg)) {
data = imported;
save();
render();
showToast('✓ ' + imported.tasks.length + ' Aufgaben importiert');
}
} catch(err) {
showToast('⚠ Fehler: Datei konnte nicht gelesen werden');
}
event.target.value = '';
};
reader.readAsText(file);
}
function showToast(msg) {
const el = document.getElementById('sync-toast');
el.textContent = msg;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 3000);
}
// ── Init ──────────────────────────────────────────────────────────────
load();
render();
</script>
</body>
</html>

13
requirements.txt Normal file
View file

@ -0,0 +1,13 @@
Flask>=3.0
Flask-SQLAlchemy>=3.1
Flask-Migrate>=4.0
Flask-Login>=0.6
Flask-Mail>=0.9
Flask-Limiter>=3.5
Flask-WTF>=1.2
email-validator>=2.1
fpdf2>=2.7
python-dotenv>=1.0
gunicorn>=21.2
# Optional: nur genutzt, wenn RATELIMIT_STORAGE_URI auf redis:// zeigt (Produktion).
redis>=5.0

16
run.py Normal file
View file

@ -0,0 +1,16 @@
"""Lokaler Dev-Server.
Lädt .env, erzeugt die App via Factory und startet den Flask-Dev-Server.
Aufruf: python run.py
"""
from dotenv import load_dotenv
load_dotenv()
from app import create_app # noqa: E402
app = create_app()
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000, debug=True)

12
wsgi.py Normal file
View file

@ -0,0 +1,12 @@
"""WSGI-Einstiegspunkt für Produktion (gunicorn).
Aufruf z.B.: gunicorn --bind 127.0.0.1:8000 wsgi:app
"""
from dotenv import load_dotenv
load_dotenv()
from app import create_app # noqa: E402
app = create_app("production")