Whole ehrenamtsapp7

This commit is contained in:
patsy 2026-06-25 18:58:44 +02:00
parent 0c83ce4d6e
commit 987deb79a9
72 changed files with 7164 additions and 0 deletions

10
.env.example Normal file
View file

@ -0,0 +1,10 @@
# Vorlage für /opt/checkpoint/.env (NICHT ins Git enthält Geheimnisse).
# Kopieren: cp .env.example .env und SECRET_KEY ersetzen.
# Schaltet die Produktions-Härtung scharf (sichere Cookies, ProxyFix,
# SECRET_KEY-Pflicht).
CP_ENV=production
# Langes Zufalls-Secret. Erzeugen z. B. mit:
# python -c "import secrets; print(secrets.token_urlsafe(48))"
SECRET_KEY=bitte-durch-langes-zufalls-secret-ersetzen

213
CLAUDE-backup.md Normal file
View file

@ -0,0 +1,213 @@
# CLAUDE.md — CheckPoint Ehrenamt App
> Projektanker. Claude Code liest diese Datei automatisch zu Beginn jeder Session.
> Sie ist die verbindliche Quelle für Stack, Regeln und Logik. Bei Widerspruch
> zwischen Wunsch und dieser Datei: nachfragen, nicht raten.
---
## Zweck
Interne, mobile-first Web-App für das Vor-Ort-Team Prävention der AIDS-Hilfe.
Sie organisiert Einsätze (Terminabstimmung + Dienstverteilung) und ersetzt
schrittweise die WhatsApp/Signal-Zettelwirtschaft. **Das Herzstück ist die
Dienstplanung — alles andere ordnet sich dem unter.**
---
## Stack & Rahmenbedingungen (fest)
| Bereich | Entscheidung |
|---|---|
| Sprache/Backend | **Python + Flask** |
| Templating | **Jinja2**, server-gerendertes HTML (klassische Multi-Page-App) |
| ORM/DB | **SQLAlchemy**, **SQLite** für v1 (ein VPS, 17 Nutzer). Keine Annahmen treffen, die einen späteren Umstieg auf PostgreSQL verbauen. |
| Passwörter | `werkzeug.security` (Hashing), nie Klartext |
| CSS | **selbst gebaut, kein CSS-Framework**. Basis: `tokens.css` + `components.css` |
| JavaScript | **so wenig wie möglich, nur Vanilla, kein Framework**. Einzige Ausnahme v1: Chat-Polling (s. u.) |
| Hosting | **eigener VPS in der EU** |
| Plattform | mobile-first Web-App, **kein App Store**, „zum Homescreen" möglich |
| UI-Sprache | **Deutsch** |
**Erlaubte Zusatz-Bibliotheken (klein halten):** Flask, SQLAlchemy/Flask-SQLAlchemy,
Werkzeug, optional Flask-Login. Keine schweren Frameworks, kein CSS-/JS-Framework.
---
## Architektur-Prinzipien
1. **Server-rendered first.** Seiten kommen fertig vom Server. Formulare sind echte
`POST`-Formulare. Kein clientseitiges Routing, kein SPA-Verhalten.
2. **JavaScript nur, wo es sich beweist.** Einzige JS-Ausnahme in v1: der Chat holt
neue Nachrichten per kleinem `fetch`-Polling. Sonst nirgends JS verlangen.
3. **Mandantenfähig ab Tag 1.** Siehe eigener Abschnitt. Jeder Datensatz gehört zu
genau einem Team.
4. **Mobile-first.** Getestet in App-Breite 360460px.
5. **Barrierearmut ist Pflicht, kein Extra.** 44px Touch-Ziele, sichtbarer Fokus,
Status nie nur über Farbe, sinnvolle `aria-label`.
---
## Rollen
| Rolle | Rechte |
|---|---|
| **Admin** | Logins vergeben, Planungszeiträume und Einsätze anlegen, Dienstplan bauen und veröffentlichen, Dokumente/Fotos freigeben, im Kanal „Ankündigungen" posten, Team verwalten. |
| **Ehrenamtliche:r** | Verfügbarkeit melden, eigenen Dienstplan sehen, freie Dienste übernehmen, Dokumente/Fotos hochladen (Freigabe nötig), im Kanal „Team" schreiben, Profil pflegen. |
- **Keine Selbstregistrierung.** Logins legt ausschließlich der Admin an.
- **v1:** Nutzername + Passwort. (E-Mail + Code = Future Log.)
---
## Mandantenfähigkeit (Pflichtregeln)
- Zentrale Einheit **`team`** (= Mandant). Das CheckPoint-Team ist das erste von
perspektivisch mehreren.
- **Jeder** Datensatz (user, planungszeitraum, einsatz, verfuegbarkeit, zuteilung,
dokument, kanal, nachricht) trägt eine **`team_id`**.
- **Jede** Datenbankabfrage filtert nach dem Team des eingeloggten Nutzers. Nie Daten
über Teamgrenzen hinweg ausliefern. Das aktive Team wird aus der Session abgeleitet.
- Das Corporate Design wird **pro Team** über die Brand-Tokens in `tokens.css`
gesetzt (serverseitig in den `<head>` injiziert). Code darf keine Markenfarbe
fest verdrahten — immer Tokens verwenden.
---
## Datenmodell (Regeln, konzeptionell)
Jede Tabelle hat `team_id`. Namen/Felder sind Richtwerte, keine starre Vorgabe.
- **team** — Mandant. Felder u. a.: Name, Brand-Tokens (Primär, Primär-stark, Akzent,
Akzent-stark, Schrift), Logo-Pfad.
- **user**`rolle` (admin | ehrenamt), Nutzername, Passwort-Hash, Anzeigename,
optionales Profilbild, optionale freiwillige Angaben.
- **planungszeitraum** — Block über einen frei wählbaren Zeitraum (kein Mindest­zeitraum).
`status`: in_planung | veroeffentlicht.
- **einsatz** — gehört **optional** zu einem Planungszeitraum (`planungszeitraum_id`
nullbar). Ohne Zeitraum = **Einzeltermin**, der direkt zu besetzen ist. Datum,
Uhrzeit (Start/Ende), Art/Ort (z. B. Tour Altstadt, Party, Sonderveranstaltung).
Braucht **2 Hauptplätze + 1 Springerplatz**.
- **verfuegbarkeit** — (user × einsatz) → kann | kann_nicht.
- **zuteilung** — (user × einsatz) mit `platztyp` (haupt | springer) und
`status` (zugeteilt | abgesagt | offen | uebernommen).
- **dokument** — Datei (PDF/Word/Bild). Felder: Titel (Pflicht), Uploader, `status`
(wartet_auf_freigabe | freigegeben | abgelehnt), `ablage` (aktuell | archiv).
- **kanal** — pro Team zwei feste: `ankuendigungen`, `team`.
- **nachricht** — Kanal, Autor, Text, Zeitstempel. **Nur Text.**
---
## Kern-Business-Logik (verbindlich)
### Planungszyklus
1. Admin legt einen Planungszeitraum (frei wählbare Länge) an und trägt die kuratierten
Einsätze ein (Freitags-Touren, Samstags-Einsätze/Partys, Sonderveranstaltungen).
Alternativ: einzelne Einzeltermine ohne Zeitraum, direkt zum Besetzen.
2. Status `in_planung` → Ehrenamtliche melden pro Einsatz **kann / kann nicht**.
Änderung der Verfügbarkeit nur solange `in_planung`.
3. Admin baut den Plan **manuell**: pro Einsatz **2 Haupt + 1 Springer**. Die App zeigt
pro Person **Anzahl bisheriger Einsätze** und **letzten Einsatz** als Hilfe
(keine automatische Verteilung in v1).
4. Bei zu wenigen Verfügbaren: Einsatz als **unterbesetzt** markieren.
5. Admin **veröffentlicht** → alle sehen ihren persönlichen Dienstplan.
### Absage & Übernahme
- Sagt eine **Hauptperson** ab → **Springer rückt automatisch nach** (Status `uebernommen`).
- Der frei gewordene **Springerplatz** wird `offen` → zum **„Übernehmen"** für alle.
- Gibt es keinen Springer / sagt er auch ab → offener Platz für alle.
- „Übernehmen" dient **nur dem Nachrücken**, nie der Erstverteilung.
### Dokumente
- Upload durch alle, aber Status startet **`wartet_auf_freigabe`** (nur Admin + Uploader sichtbar).
- Admin gibt frei oder lehnt ab (optional Grund). Erst nach Freigabe für alle sichtbar.
- Admin-Uploads gehen direkt durch.
- Liste geteilt in **Aktuell** und **Archiv** (Verschieben macht der Admin).
- **Foto-Regel:** nur ohne erkennbare Dritte bzw. mit deren Einverständnis.
### Chat
- Zwei Kanäle: **Ankündigungen** (nur Admin postet, alle lesen) und **Team** (alle posten).
- Nur Text. Neue Nachrichten via Hintergrund-`fetch`-Polling (einzige v1-JS-Ausnahme).
### Benachrichtigungen
- **v1: nur in-App** (neuer Dienst, offener Dienst, neue Nachricht). Push/E-Mail = Future Log.
---
## Designsystem
- Quelle der Wahrheit: **`tokens.css`** (mandantenfähig, hell + dunkel via `light-dark()`).
- **Hell/Dunkel** folgt standardmäßig der Systemeinstellung. Zusätzlich gibt es einen
manuellen Umschalter (Topbar-Icon, schaltet System → Hell → Dunkel). Er läuft
**serverseitig ohne JS**: ein POST setzt ein `theme`-Cookie, der Server schreibt
`data-theme="light|dark"` an `<html>` (bei „System" kein Attribut). „System" ist
Default und jederzeit wieder wählbar.
- **Nie feste Farben** im Komponenten-CSS — immer Tokens (`var(--cp-...)`).
- Erledigt (Session 4 + Nachzug): `components.css` enthält **kein** Farb-Literal mehr.
Die früher feste weiße Button-Schrift auf Türkis/Rot läuft jetzt über das Token
`--cp-on-brand` (BRAND-Ebene, bewusst themeunabhängig, kein `light-dark()`, da die
Flächen `--cp-teal`/`--cp-red` in beiden Themes derselbe Vollton sind). Pro Team
überschreibbar. Regel daher ausnahmslos: nie Farb-Literale im Komponenten-CSS.
- Status-Mapping: Türkis = offen/bestätigt/verfügbar · Rot = dringend/Vertretung ·
Neutral = Info/intern/erledigt.
- Schrift: DM Sans.
---
## UI-Regeln
- Mobile-first bei 360460px Basisbreite; auf Tablet/Desktop wächst die zentrierte
Spalte (inkl. fixierter Topbar/Bottom-Nav) ab 768px auf ~720px. Viel Weißraum,
klare Karten (Radius 12px, Innenabstand 16px), keine verschachtelten Karten.
- **Bottom-Navigation, 4 Punkte: Start · Termine · Team · Profil.** (Nicht „Kalender".)
- Jede Ansicht hat **eine klare Hauptaktion** (Button oder FAB).
- Buttons: Primär = Türkis/weiß · Dringend = Rot/weiß · Ghost = Fläche mit feinem
Rahmen. Kurze Beschriftungen: „Speichern", „Eintragen", „Übernehmen", „Details".
- Formulare: Labels immer sichtbar über dem Feld, Felder ≥ 44px, kurze freundliche
Hilfetexte, Fehler klar und nicht nur über Farbe.
## Texte / Copy
- **Deutsch**, Satzanfang groß (sentence case), aktive Verben, keine Floskeln.
- Eine Aktion heißt im ganzen Flow gleich (Button „Übernehmen" → Bestätigung „Übernommen").
- Fehlermeldungen sagen, was passiert ist und wie es weitergeht — ohne Entschuldigung.
---
## Bewusst raus aus v1 (Out of Scope)
Keine öffentlichen Seiten · keine Klient:innendaten · keine Bezahlfunktion ·
kein Kalender-Sync (Terminübersicht = Liste) · keine Dateien/Bilder im Chat ·
keine automatische Dienstverteilung · kein 1:1-Chat · kein Push/E-Mail.
---
## Vorgeschlagene Projektstruktur
```
checkpoint-ehrenamt/
├── CLAUDE.md
├── TASKS.md
├── app/
│ ├── __init__.py # App-Factory, DB-Init, Team-Scoping
│ ├── models.py # SQLAlchemy-Modelle (alle mit team_id)
│ ├── auth.py # Login, Logout, Admin-Anlage
│ ├── routes/ # Blueprints: planung, dienste, dokumente, chat, profil
│ ├── templates/ # Jinja2 (base.html + Teilansichten)
│ └── static/
│ ├── tokens.css
│ └── components.css
├── instance/ # SQLite-DB, Uploads (nicht im Git)
└── requirements.txt
```
---
## Arbeitsweise mit den Modellen (Continue)
- **Mistral (Architect):** Planung, Design-Entscheidungen, Reviews. Nie zum Schreiben
von Produktivcode.
- **Qwen (Coder):** Implementierung, Bugfixes, Inline-Edits (`Ctrl+I`). Immer ein
Stück nach dem anderen (Datenmodell → Service → Route → Template).
- **Claude Code:** Scaffold, Mehrdateien-Arbeit, übergreifendes Verdrahten.
- **Eine Aufgabe pro Session.** Immer `@file`-Kontext. Plan vor Code.

272
CLAUDE.md Normal file
View file

@ -0,0 +1,272 @@
# CLAUDE.md — CheckPoint Ehrenamt App
> Projektanker. Claude Code liest diese Datei automatisch zu Beginn jeder Session.
> Sie ist die verbindliche Quelle für Stack, Regeln und Logik. Bei Widerspruch
> zwischen Wunsch und dieser Datei: nachfragen, nicht raten.
---
## Zweck
Interne, mobile-first Web-App für das Vor-Ort-Team Prävention der AIDS-Hilfe.
Sie organisiert Einsätze (Terminabstimmung + Dienstverteilung) und ersetzt
schrittweise die WhatsApp/Signal-Zettelwirtschaft. **Das Herzstück ist die
Dienstplanung — alles andere ordnet sich dem unter.**
---
## Stack & Rahmenbedingungen (fest)
| Bereich | Entscheidung |
|---|---|
| Sprache/Backend | **Python + Flask** |
| Templating | **Jinja2**, server-gerendertes HTML (klassische Multi-Page-App) |
| ORM/DB | **SQLAlchemy**, **SQLite** für v1 (ein VPS, 17 Nutzer). Keine Annahmen treffen, die einen späteren Umstieg auf PostgreSQL verbauen. |
| Passwörter | `werkzeug.security` (Hashing), nie Klartext |
| CSS | **selbst gebaut, kein CSS-Framework**. Basis: `tokens.css` + `components.css` (User-Shell) + `components-admin.css` (Admin-Shell) |
| JavaScript | **so wenig wie möglich, nur Vanilla, kein Framework**. Einzige Ausnahme v1: Chat-Polling (s. u.) |
| Hosting | **eigener VPS in der EU** |
| Plattform | mobile-first Web-App, **kein App Store**, „zum Homescreen" möglich |
| UI-Sprache | **Deutsch** |
**Erlaubte Zusatz-Bibliotheken (klein halten):** Flask, SQLAlchemy/Flask-SQLAlchemy,
Werkzeug, optional Flask-Login. Keine schweren Frameworks, kein CSS-/JS-Framework.
---
## Architektur-Prinzipien
1. **Server-rendered first.** Seiten kommen fertig vom Server. Formulare sind echte
`POST`-Formulare. Kein clientseitiges Routing, kein SPA-Verhalten.
2. **JavaScript nur, wo es sich beweist.** Zwei JS-Ausnahmen in v1: der Chat holt
neue Nachrichten per kleinem `fetch`-Polling, und die Admin-Sidebar wird auf
schmalen Bildschirmen per Hamburger-Icon als Off-Canvas-Overlay ein-/ausgeklappt
(Klasse toggeln, kein Framework). Sonst nirgends JS verlangen.
3. **Mandantenfähig ab Tag 1.** Siehe eigener Abschnitt. Jeder Datensatz gehört zu
genau einem Team.
4. **Mobile-first.** Getestet in App-Breite 360460px.
5. **Barrierearmut ist Pflicht, kein Extra.** 44px Touch-Ziele, sichtbarer Fokus,
Status nie nur über Farbe, sinnvolle `aria-label`.
---
## Rollen
| Rolle | Rechte |
|---|---|
| **Admin** | Logins vergeben, Planungszeiträume und Einsätze anlegen, Dienstplan bauen und veröffentlichen, Dokumente/Fotos freigeben, im Kanal „Ankündigungen" posten, Team verwalten. |
| **Ehrenamtliche:r** | Verfügbarkeit melden, eigenen Dienstplan sehen, freie Dienste übernehmen, Dokumente/Fotos hochladen (Freigabe nötig), im Kanal „Team" schreiben, Profil pflegen. |
- **Keine Selbstregistrierung.** Logins legt ausschließlich der Admin an.
- **v1:** Nutzername + Passwort. Der Nutzername ist nur **pro Team** eindeutig, daher
ist der Login team-scoped: bei mehreren Teams kommt ein **Team-Kürzel** (`team.slug`)
dazu; gibt es nur ein Team, entfällt das Feld. (E-Mail + Code = Future Log.)
---
## Mandantenfähigkeit (Pflichtregeln)
- Zentrale Einheit **`team`** (= Mandant). Das CheckPoint-Team ist das erste von
perspektivisch mehreren.
- **Jeder** Datensatz (user, planungszeitraum, einsatz, verfuegbarkeit, zuteilung,
dokument, kanal, nachricht) trägt eine **`team_id`**.
- **Jede** Datenbankabfrage filtert nach dem Team des eingeloggten Nutzers. Nie Daten
über Teamgrenzen hinweg ausliefern. Das aktive Team wird aus der Session abgeleitet.
- Das Corporate Design wird **pro Team** über die Brand-Tokens in `tokens.css`
gesetzt (serverseitig in den `<head>` injiziert). Code darf keine Markenfarbe
fest verdrahten — immer Tokens verwenden.
---
## Zwei Oberflächen, zwei Shells (Pflichtregel)
Es gibt **zwei eigenständige Layout-Shells**, eine pro Rolle. Sie sind keine
Variante derselben Vorlage, sondern zwei getrennte Templates mit jeweils eigenem
Tokensatz:
| | **User-Shell** | **Admin-Shell** |
|---|---|---|
| Für wen | Ehrenamtliche | Admin |
| Vorlage | `base_user.html` | `base_admin.html` |
| Navigation | Bottom-Nav, 4 Punkte (Start, Termine, Team, Profil) | Sidebar links, fest: Start, Planung, Dienste, Freigaben, Team, Chat-Verwaltung, Profil, Abmelden |
| Grundform | Mobile-first (App-Breite 360460px) | Desktop-first (Sidebar 260px), responsiv bis Mobile |
| Mobiles Verhalten | nativ mobil, keine Anpassung nötig | Sidebar wird unter dem Breakpoint zu einer **Off-Canvas-Navigation**: Hamburger-Icon oben links öffnet sie als Overlay |
| Tokens | `--cp-user-*` (Türkis/Rot, DM Sans) | `--cp-admin-*` (Navy/Teal/Gelb/Pink, Roboto Condensed + Alfa Slab One für Seitentitel) |
| CSS-Wurzelklasse | `.cp-shell-user` | `.cp-shell-admin` |
**Wichtig:** Die Admin-Startseite hat **keine eigene Kachel-Navigation** wie die
User-Startseite — die Sidebar selbst trägt die Hauptaktionen. Die Admin-Inhaltsfläche
zeigt direkt die Arbeit (Tabellen, Listen, Formulare), nicht zusätzliche Navigations-
Buttons.
**Mobile Sidebar-Mechanik (einzige zusätzliche v1-JS-Ausnahme neben dem Chat):**
Unter dem Breakpoint kollabiert die Sidebar zu einem Hamburger-Icon in einer
schmalen Topbar. Klick öffnet die Sidebar als Off-Canvas-Overlay (kleines
Vanilla-JS: Klasse toggeln, Overlay schließt bei Klick daneben oder auf einen
Menüpunkt). Kein Framework, keine Bibliothek.
**Mandantenfähigkeit gilt für beide Shells unabhängig:** ein neues Team tauscht
sowohl den `--cp-user-brand-*`- als auch den `--cp-admin-brand-*`-Block. Die beiden
Themes werden nie gemischt — Komponenten in der User-Shell nutzen ausschließlich
`--cp-user-*`, Komponenten in der Admin-Shell ausschließlich `--cp-admin-*`.
---
## Datenmodell (Regeln, konzeptionell)
Jede Tabelle hat `team_id`. Namen/Felder sind Richtwerte, keine starre Vorgabe.
- **team** — Mandant. Felder u. a.: Name, Brand-Tokens (Primär, Primär-stark, Akzent,
Akzent-stark, Schrift), Logo-Pfad.
- **user**`rolle` (admin | ehrenamt), Nutzername, Passwort-Hash, Anzeigename,
optionales Profilbild, optionale freiwillige Angaben.
- **planungszeitraum** — Block von mind. 2 Monaten. `status`: in_planung | veroeffentlicht.
- **einsatz** — gehört zu einem Planungszeitraum. Datum, Uhrzeit (Start/Ende), Art/Ort
(z. B. Tour Altstadt, Party, Sonderveranstaltung). Braucht **2 Hauptplätze + 1 Springerplatz**.
- **verfuegbarkeit** — (user × einsatz) → kann | kann_nicht.
- **zuteilung** — (user × einsatz) mit `platztyp` (haupt | springer) und
`status` (zugeteilt | abgesagt | offen | uebernommen).
- **dokument** — Datei (PDF/Word/Bild). Felder: Titel (Pflicht), Uploader, `status`
(wartet_auf_freigabe | freigegeben | abgelehnt), `ablage` (aktuell | archiv).
- **kanal** — pro Team zwei feste: `ankuendigungen`, `team`.
- **nachricht** — Kanal, Autor, Text, Zeitstempel. **Nur Text.**
---
## Kern-Business-Logik (verbindlich)
### Planungszyklus
1. Admin legt Planungszeitraum (≥ 2 Monate) an und trägt die kuratierten Einsätze ein
(Freitags-Touren, Samstags-Einsätze/Partys, Sonderveranstaltungen).
2. Status `in_planung` → Ehrenamtliche melden pro Einsatz **kann / kann nicht**.
Änderung der Verfügbarkeit nur solange `in_planung`.
3. Admin baut den Plan **manuell**: pro Einsatz **2 Haupt + 1 Springer**. Die App zeigt
pro Person **Anzahl bisheriger Einsätze** und **letzten Einsatz** als Hilfe
(keine automatische Verteilung in v1).
4. Bei zu wenigen Verfügbaren: Einsatz als **unterbesetzt** markieren.
5. Admin **veröffentlicht** → alle sehen ihren persönlichen Dienstplan.
### Absage & Übernahme
- Sagt eine **Hauptperson** ab → **Springer rückt automatisch nach** (Status `uebernommen`).
- Der frei gewordene **Springerplatz** wird `offen` → zum **„Übernehmen"** für alle.
- Gibt es keinen Springer / sagt er auch ab → offener Platz für alle.
- „Übernehmen" dient **nur dem Nachrücken**, nie der Erstverteilung.
### Dokumente
- Upload durch alle, aber Status startet **`wartet_auf_freigabe`** (nur Admin + Uploader sichtbar).
- Admin gibt frei oder lehnt ab (optional Grund). Erst nach Freigabe für alle sichtbar.
- Admin-Uploads gehen direkt durch.
- Liste geteilt in **Aktuell** und **Archiv** (Verschieben macht der Admin).
- **Foto-Regel:** nur ohne erkennbare Dritte bzw. mit deren Einverständnis.
### Chat
- Zwei Kanäle: **Ankündigungen** (nur Admin postet, alle lesen) und **Team** (alle posten).
- Nur Text. Neue Nachrichten via Hintergrund-`fetch`-Polling (einzige v1-JS-Ausnahme).
### Benachrichtigungen
- **v1: nur in-App** (neuer Dienst, offener Dienst, neue Nachricht). Push/E-Mail = Future Log.
---
## Designsystem
- Quelle der Wahrheit: **`tokens.css`** — enthält **zwei vollständig getrennte
Tokensätze**, `--cp-user-*` (User-Shell) und `--cp-admin-*` (Admin-Shell), je
mandantenfähig und mit hell + dunkel via `light-dark()`.
- **User-Theme:** Türkis/Rot, Schrift DM Sans. Status-Mapping: Türkis =
offen/bestätigt/verfügbar · Rot = dringend/Vertretung · Neutral = Info/intern/erledigt.
- **Admin-Theme:** Navy-Sidebar (in beiden Farbmodi gleich dunkel — bewusst, sie ist
das feste Markenelement), helle Arbeitsfläche, Teal für Aktionen/aktive Navigation,
Gelb nur für Sidebar-Icons, Pink nur als Markenakzent (nie als Fehlerfarbe — dafür
ist Rot da). Schrift: Roboto Condensed für UI/Tabellen/Formulare, Alfa Slab One nur
für kurze Seitentitel, Roboto Mono für Zahlen/IDs.
- **Hell/Dunkel** folgt standardmäßig der Systemeinstellung. Zusätzlich gibt es einen
manuellen Umschalter (Topbar/Sidebar-Icon: System → Hell → Dunkel), serverseitig
ohne JS — ein POST setzt ein `theme`-Cookie, der Server schreibt
`data-theme="light|dark"` an `<html>` (bei „System" kein Attribut).
- **Nie feste Farben** im Komponenten-CSS — immer Tokens. **Nie Theme-Tokens
mischen** — eine Komponente innerhalb der User-Shell nutzt nur `--cp-user-*`,
innerhalb der Admin-Shell nur `--cp-admin-*`.
- Erledigt: beide Komponenten-CSS sind frei von Farb-Literalen. Weiße Schrift auf
Voll-Marken-Flächen läuft über `--cp-user-on-brand` bzw. `--cp-admin-on-brand`
(themeunabhängig, pro Team überschreibbar).
---
## UI-Regeln
### User-Shell
- App-Breite 360460px, viel Weißraum, klare Karten (Radius 12px, Innenabstand 16px),
keine verschachtelten Karten.
- **Bottom-Navigation, 4 Punkte: Start · Termine · Team · Profil.** (Nicht „Kalender".)
- Jede Ansicht hat **eine klare Hauptaktion** (Button oder FAB).
- Buttons: Primär = Türkis/weiß · Dringend = Rot/weiß · Ghost = Fläche mit feinem
Rahmen. Kurze Beschriftungen: „Speichern", „Eintragen", „Übernehmen", „Details".
### Admin-Shell
- Desktop-Grundraster: Sidebar 260px + fließender Hauptbereich, Seitenabstand 32px.
- **Sidebar-Punkte (fest, in dieser Reihenfolge):** Start · Planung · Dienste ·
Freigaben · Team · Chat-Verwaltung · Profil — Abmelden unten abgetrennt.
- Aktiver Menüpunkt: Teal-Fläche, weißer Text, gelbes Icon. Inaktiv: gedimmtes Weiß,
gelbes Icon.
- Cards/Panels: Radius 812px (max. 14px), Innenabstand 2030px, sehr dezenter Schatten,
Panel-Header darf `--cp-admin-surface-soft` nutzen.
- Tabellenkopf in Navy mit weißem, fettem Text. Statusspalten farbig **mit Text**, nie
nur über Farbe.
- **Unter dem Breakpoint:** Sidebar kollabiert zu Hamburger-Icon, öffnet als
Off-Canvas-Overlay. Touch-Ziele bleiben ≥ 44px auch im Admin-Bereich.
### Beide Shells
- Formulare: Labels immer sichtbar über dem Feld, Felder ≥ 44px, kurze freundliche
Hilfetexte, Fehler klar und nicht nur über Farbe.
## Texte / Copy
- **Deutsch**, Satzanfang groß (sentence case), aktive Verben, keine Floskeln.
- Eine Aktion heißt im ganzen Flow gleich (Button „Übernehmen" → Bestätigung „Übernommen").
- Fehlermeldungen sagen, was passiert ist und wie es weitergeht — ohne Entschuldigung.
---
## Bewusst raus aus v1 (Out of Scope)
Keine öffentlichen Seiten · keine Klient:innendaten · keine Bezahlfunktion ·
kein Kalender-Sync (Terminübersicht = Liste) · keine Dateien/Bilder im Chat ·
keine automatische Dienstverteilung · kein 1:1-Chat · kein Push/E-Mail.
---
## Vorgeschlagene Projektstruktur
```
checkpoint-ehrenamt/
├── CLAUDE.md
├── TASKS.md
├── app/
│ ├── __init__.py # App-Factory, DB-Init, Team-Scoping
│ ├── models.py # SQLAlchemy-Modelle (alle mit team_id)
│ ├── auth.py # Login, Logout, Admin-Anlage
│ ├── routes/ # Blueprints: planung, dienste, dokumente, chat, profil
│ ├── templates/
│ │ ├── base_user.html # User-Shell: Bottom-Nav
│ │ ├── base_admin.html # Admin-Shell: Sidebar + mobiles Off-Canvas
│ │ └── ... # Teilansichten je Route
│ └── static/
│ ├── tokens.css # beide Theme-Tokensätze (--cp-user-*, --cp-admin-*)
│ ├── components.css # Komponenten der User-Shell
│ ├── components-admin.css # Komponenten der Admin-Shell
│ └── sidebar.js # Hamburger/Off-Canvas-Logik (Vanilla, ~20 Zeilen)
├── instance/ # SQLite-DB, Uploads (nicht im Git)
└── requirements.txt
```
---
## Arbeitsweise mit den Modellen (Continue)
- **Mistral (Architect):** Planung, Design-Entscheidungen, Reviews. Nie zum Schreiben
von Produktivcode.
- **Qwen (Coder):** Implementierung, Bugfixes, Inline-Edits (`Ctrl+I`). Immer ein
Stück nach dem anderen (Datenmodell → Service → Route → Template).
- **Claude Code:** Scaffold, Mehrdateien-Arbeit, übergreifendes Verdrahten.
- **Eine Aufgabe pro Session.** Immer `@file`-Kontext. Plan vor Code.

BIN
CheckPoint-Ehrenamt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

154
DEPLOY.md Normal file
View file

@ -0,0 +1,154 @@
# DEPLOY.md — CheckPoint Ehrenamt auf einen EU-VPS
> Ziel: App unter **gunicorn** hinter **nginx**, **HTTPS** per Let's Encrypt,
> Secrets aus der Umgebung, **SQLite + Uploads persistent** in `instance/`,
> tägliches **DB-Backup**. Befehle für die **Fish-Shell**, auf dem VPS auszuführen.
>
> Platzhalter überall ersetzen: Domain `checkpoint.example.org`, ggf. Pfad
> `/opt/checkpoint`. Annahme: frischer Debian/Ubuntu-VPS in der EU, Domain zeigt
> per A/AAAA-Record auf die VPS-IP, du hast einen sudo-fähigen Login.
---
## 1 · Systempakete
```fish
sudo apt update
sudo apt install -y python3 python3-venv python3-pip nginx sqlite3 git certbot python3-certbot-nginx
```
## 2 · Dienst-Nutzer + Verzeichnis
```fish
sudo useradd --system --create-home --home-dir /opt/checkpoint --shell /usr/sbin/nologin checkpoint
sudo mkdir -p /opt/checkpoint
```
## 3 · Code holen
```fish
sudo git clone <DEIN_REPO_URL> /opt/checkpoint
# oder per scp/rsync hochladen. Danach Eigentümer setzen:
sudo chown -R checkpoint:checkpoint /opt/checkpoint
```
## 4 · Virtualenv + Abhängigkeiten
```fish
cd /opt/checkpoint
sudo -u checkpoint python3 -m venv .venv
sudo -u checkpoint .venv/bin/pip install --upgrade pip
sudo -u checkpoint .venv/bin/pip install -r requirements.txt
```
## 5 · Secrets (`.env`)
`.env` liegt unter `/opt/checkpoint/.env`, ist **nicht** im Git und wird von systemd
geladen. Aus der Vorlage erzeugen und ein echtes Secret eintragen:
```fish
cd /opt/checkpoint
sudo -u checkpoint cp .env.example .env
# Secret erzeugen und einsetzen:
set SECRET (sudo -u checkpoint .venv/bin/python -c "import secrets; print(secrets.token_urlsafe(48))")
sudo -u checkpoint sed -i "s|^SECRET_KEY=.*|SECRET_KEY=$SECRET|" .env
sudo chmod 600 .env
```
`.env` sollte enthalten: `CP_ENV=production` und `SECRET_KEY=<lang & zufällig>`.
## 6 · Datenbank anlegen, Team seeden, Admin erstellen
```fish
cd /opt/checkpoint
# DB-Tabellen + erstes Team (CheckPoint) + die zwei Chat-Kanäle (idempotent):
sudo -u checkpoint --preserve-env=CP_ENV env CP_ENV=production .venv/bin/python seed.py
# Ersten Admin anlegen (Nutzername Anzeigename Passwort):
sudo -u checkpoint env CP_ENV=production SECRET_KEY=$SECRET \
.venv/bin/flask --app wsgi create-admin admin "Team-Leitung" "EinStarkesPasswort"
```
> Hinweis: DB (`instance/checkpoint.sqlite`) und Uploads (`instance/uploads/`)
> liegen unter `/opt/checkpoint/instance/` und bleiben über Neustarts/Updates
> erhalten. Nur dieses Verzeichnis ist schreibbar (systemd `ReadWritePaths`).
## 7 · gunicorn als systemd-Dienst
```fish
sudo cp /opt/checkpoint/deploy/checkpoint.service /etc/systemd/system/checkpoint.service
sudo systemctl daemon-reload
sudo systemctl enable --now checkpoint
sudo systemctl status checkpoint --no-pager
```
Test (lokal auf dem VPS): `curl -I http://127.0.0.1:8000` sollte eine Antwort
liefern (Redirect zur Anmeldung ist ok).
## 8 · nginx als Reverse Proxy
```fish
sudo cp /opt/checkpoint/deploy/nginx-checkpoint.conf /etc/nginx/sites-available/checkpoint
sudo sed -i "s/checkpoint.example.org/DEINE-DOMAIN/" /etc/nginx/sites-available/checkpoint
sudo ln -sf /etc/nginx/sites-available/checkpoint /etc/nginx/sites-enabled/checkpoint
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
```
`client_max_body_size 12m` ist gesetzt (passt zum 10-MB-Upload-Limit der App).
Statische Dateien (CSS/JS/Schriften) liefert nginx direkt; Uploads/Dokumente
laufen bewusst **nicht** über nginx, sondern zugriffsgeschützt über die App.
## 9 · HTTPS (Let's Encrypt)
```fish
sudo certbot --nginx -d DEINE-DOMAIN
```
certbot ergänzt den 443-Block, richtet die Weiterleitung 80→443 ein und erneuert
automatisch (Timer `certbot.timer`). Erst **nach** HTTPS greifen die `Secure`-Cookies
sinnvoll — `CP_ENV=production` setzt sie bereits voraus.
## 10 · Tägliches DB-Backup
```fish
sudo chmod +x /opt/checkpoint/deploy/backup-db.sh
sudo crontab -u checkpoint -l 2>/dev/null | cat - <(echo "30 3 * * * /opt/checkpoint/deploy/backup-db.sh") | sudo crontab -u checkpoint -
```
Sichert täglich 03:30 nach `/opt/checkpoint/backups/` (Online-`.backup`, also auch
im laufenden Betrieb konsistent) und hält 14 Tage vor. Backups liegen außerhalb
von Git. Tipp: zusätzlich regelmäßig vom VPS wegkopieren (anderer Ort).
## 11 · Updates einspielen
```fish
cd /opt/checkpoint
sudo -u checkpoint git pull
sudo -u checkpoint .venv/bin/pip install -r requirements.txt
sudo systemctl restart checkpoint
```
Neue Tabellen legt die App beim Start via `create_all()` an. **Achtung:** echte
Schema-*Änderungen* an bestehenden Tabellen macht SQLAlchemy so nicht — dafür
vorher Backup ziehen und ggf. manuell migrieren.
---
## Checkliste (Deliverables Session 15)
- [ ] App läuft unter gunicorn hinter nginx → Schritte 78
- [ ] HTTPS aktiv → Schritt 9
- [ ] Secrets über Umgebungsvariablen, nicht im Code → Schritt 5 (`.env`, systemd `EnvironmentFile`)
- [ ] DB + Uploads persistent, einfaches DB-Backup eingerichtet → `instance/` + Schritt 10
## Fehlersuche
- Dienst-Logs: `sudo journalctl -u checkpoint -e`
- nginx-Logs: `sudo tail -f /var/log/nginx/error.log`
- 413 beim Upload → `client_max_body_size` in nginx prüfen (muss ≥ 10 MB sein).
- 500 direkt nach Deploy → meist fehlendes/falsches `SECRET_KEY` in `.env`
(bei `CP_ENV=production` bricht die App ohne `SECRET_KEY` bewusst ab).
- „database is locked" → sollte dank WAL + busy_timeout nicht auftreten; sonst
Worker-Zahl in `gunicorn.conf.py` senken.
```

View file

@ -0,0 +1,270 @@
# Konzeptdokument — CheckPoint Ehrenamt App
> Interne Orga-App für das Vor-Ort-Team Prävention der AIDS-Hilfe.
> Ersetzt schrittweise die WhatsApp/Signal-Zettelwirtschaft.
> Stand: Konzeptphase (vor dem ersten Code).
---
## 1 · Ziel & Kernidee
Eine schlanke, mobile-first Web-App, mit der das Ehrenamtsteam seine Einsätze
organisiert, ohne auf WhatsApp/Signal angewiesen zu sein.
**Das Herzstück ist die Terminabstimmung und Dienstverteilung** — wer macht wann
was. Alles andere (Chat, Dokumente) ordnet sich diesem Zweck unter.
**Leitprinzip:** Lesbarkeit und Orientierung vor Funktionsfülle. Die App muss für
17 Ehrenamtliche im Alltag spürbar einfacher sein als eine WhatsApp-Umfrage.
**Übergang:** Ziel ist, WhatsApp/Signal als Orga-Werkzeug komplett zu ersetzen.
In der Anfangszeit darf parallel weiter dort kommuniziert werden — die App muss
aber eigenständig funktionieren und darf wichtige Infos nicht „verlieren", nur
weil jemand noch nicht umgestiegen ist.
---
## 2 · Nutzer & Rollen
| Rolle | Wer | Rechte |
|---|---|---|
| **Admin** | Koordination (du) | Legt Planungszeiträume und Termine an, baut den Dienstplan, vergibt Logins, gibt Dokumente/Fotos frei, postet Ankündigungen, verwaltet das Team. |
| **Ehrenamtliche:r** | die 17 Teammitglieder | Meldet Verfügbarkeit, sieht den eigenen Dienstplan, übernimmt freie Dienste, lädt Dokumente/Fotos hoch (Freigabe nötig), chattet im Team-Kanal. |
Für v1 reichen genau diese zwei Rollen. Weitere Abstufungen (z. B. „Co-Admin")
sind möglich, aber nicht nötig — siehe Future Log.
---
## 3 · Mandantenfähigkeit (von Tag 1)
Die App wird **von Anfang an mandantenfähig** gebaut (Entscheidung A1): mehrere
Teams parallel, je eigenes Corporate Design und eigene, sauber getrennte Inhalte.
**Konsequenzen fürs Datenmodell:**
- Es gibt eine zentrale Einheit **`team`** (= Mandant).
- **Jeder** Datensatz (Nutzer, Termin, Dienst, Dokument, Nachricht, …) hängt an
genau einem `team`.
- Kein Datensatz ist über Teamgrenzen hinweg sichtbar.
- Das Corporate Design (Farben, Logo, Schrift) wird **pro Team** über
CSS-Variablen-Tokens gesetzt. Das bestehende CheckPoint-CD ist der erste Mandant.
Das CheckPoint-Team ist also der erste von perspektivisch mehreren Teams. Diese
Trennung wird im ersten Datenmodell verankert, damit später kein Umbau nötig ist.
---
## 4 · Technischer Rahmen (feste Vorgaben)
| Bereich | Entscheidung |
|---|---|
| Backend | **Flask** (Python) |
| Templating | **Jinja2**, server-gerendertes HTML |
| Architektur | klassische Multi-Page-App (server-rendered) |
| CSS | **selbst gebaut**, kein CSS-Framework (Basis: vorhandenes Token-System) |
| JavaScript | **so wenig wie möglich, nur Vanilla**, kein JS-Framework |
| Hosting | **eigener VPS in der EU** |
| Plattform | mobile-first Web-App, **kein App Store**, „zum Homescreen hinzufügen" möglich |
**Chat-Sonderfall (Variante 2):** Alles ist server-gerendert und JS-frei —
**außer** der Chat holt sich neue Nachrichten per kleinem `fetch`-Polling im
Hintergrund (wenige Zeilen Vanilla-JS). So fühlt sich der Chat lebendig an, ohne
WebSockets oder Framework. Polls, „Übernehmen", Formulare und die Terminübersicht
laufen komplett ohne JavaScript als normale Formular-Abschicke.
---
## 5 · Auth & Onboarding
- **Logins werden ausschließlich vom Admin vergeben.** Keine Selbstregistrierung.
- **v1:** Nutzername + Passwort, vom Admin angelegt.
- Passwörter werden gehasht gespeichert (nie im Klartext).
- Neue Ehrenamtliche bekommen ihre Zugangsdaten vom Admin und können sich danach
einloggen.
- **Future Log:** E-Mail + Einmal-Code (Magic Link) als komfortablere Alternative —
Entscheidung dazu trifft das Team später gemeinsam.
---
## 6 · Datenmodell — Entscheidungen
Konzeptionelle Sicht (keine Implementierungsdetails). Jede Einheit gehört zu
einem `team`.
- **team** — der Mandant. Trägt das Corporate Design (Tokens, Logo) und bündelt alle Inhalte.
- **user** — Ehrenamtliche:r oder Admin. Feld `rolle`, Login-Daten, Anzeigename, optionales **Profilbild** und optionale freiwillige Angaben.
- **planungszeitraum** — ein Block von mindestens 2 Monaten. Status: *in Planung* (Verfügbarkeit wird gesammelt) → *veröffentlicht* (Plan steht).
- **einsatz** (Termin/Dienst) — gehört zu einem Planungszeitraum. Hat Datum, Uhrzeit, Ort/Art (z. B. Tour Altstadt, Party, Sonderveranstaltung). Braucht **2 Hauptplätze + 1 Springerplatz**.
- **verfuegbarkeit** — Rückmeldung einer Person zu einem Einsatz: *kann / kann nicht*.
- **zuteilung** — verbindet `user` mit `einsatz` und einem Platztyp (*Haupt* oder *Springer*) und Status (*zugeteilt / abgesagt / übernommen*).
- **dokument** — Datei (Dokument oder Bild) mit Titel, Uploader, Status (*wartet auf Freigabe / freigegeben / abgelehnt*) und Ablage (*aktuell / Archiv*).
- **kanal** — Chat-Kanal (für v1 zwei feste pro Team: *Ankündigungen*, *Team*).
- **nachricht** — Textnachricht in einem Kanal, mit Autor und Zeitstempel.
---
## 7 · Kernflows
### 7.1 Planungszyklus (Admin)
1. Admin legt einen **Planungszeitraum** an (mind. 2 Monate).
2. Admin trägt die relevanten **Einsätze** ein: regelmäßige **Freitags-Touren**
(Alt-/Neustadt), **Samstags-Einsätze** (oft Partys) und kuratierte
**Sonderveranstaltungen**. Die Termine sind nicht fix vorgegeben — der Admin
wählt sie pro Zeitraum aus.
3. Der Zeitraum geht in den Status *in Planung* → Verfügbarkeitsabfrage startet.
### 7.2 Verfügbarkeit melden (Ehrenamtliche)
- Jede:r sieht die Liste der Einsätze des aktuellen Zeitraums und meldet pro Datum
**kann / kann nicht**.
- Änderung der Verfügbarkeit ist möglich, **solange der Zeitraum *in Planung* ist**.
Nach Veröffentlichung läuft alles über Absage/Übernahme (siehe 7.4).
### 7.3 Dienstplan bauen (Admin)
- Pro Einsatz: **2 Hauptpersonen + 1 Springer**.
- Der Admin verteilt **manuell** und sieht dabei pro Person Hilfsinfos: wie oft
schon eingeteilt, wann zuletzt im Einsatz — damit niemand zu oft hintereinander
dran ist und die Last fair bleibt.
- Wenn sich für einen Einsatz **zu wenige** verfügbar gemeldet haben, zeigt die App
ihn als **unterbesetzt** an, damit der Admin nachsteuern kann.
- Ist der Plan fertig, **veröffentlicht** der Admin den Zeitraum. Ab jetzt sehen
alle ihren persönlichen Dienstplan.
> **Bewusste Entscheidung für v1:** Die Zuteilung ist manuell (Admin entscheidet),
> die App unterstützt nur mit Anzeige. Automatische Fairness-Verteilung ist Future Log.
### 7.4 Absage & Übernahme
- Sagt eine **Hauptperson** ab, **rückt der Springer automatisch nach**. Der Einsatz
bleibt voll besetzt.
- Der dadurch frei werdende **Springerplatz** wird **offen zum „Übernehmen"** — wer
zuerst klickt, bekommt ihn.
- Sagt auch der Springer ab (oder es gibt keinen), wird der offene Platz für alle
zur Übernahme angeboten.
- Der Self-Service-„Übernehmen"-Button dient **ausschließlich dem Nachrücken** bei
Ausfall — nicht der Erstverteilung.
### 7.7 Profil
- Bewusst **minimal** in v1.
- Jede:r kann ein **Profilbild** hochladen und **freiwillig** ein paar Angaben zu
sich machen. Welche Felder das genau sind, wird noch festgelegt (siehe offene
Notiz unten) — nichts davon ist Pflicht.
- Login-/Passwort-Verwaltung kann hier später andocken.
### 7.5 Chat
- **Zwei feste Kanäle pro Team:**
- **Ankündigungen / Orga** — nur der **Admin** postet, alle lesen (z. B. Hinweis
auf neues Protokoll, wichtige Orga-Infos).
- **Team** — alle posten frei und reden untereinander.
- **Nur Text** (keine Bilder/Dateien im Chat — die laufen über die Dokumentenablage).
- Neue Nachrichten erscheinen per Hintergrund-Polling (Variante 2).
- **Future Log:** 1:1-Direktnachrichten.
### 7.6 Dokumente & Fotos
- **Inhalte:** Protokolle (PDF/Word) und **Bilder** (z. B. Tour-Fotos). Alles andere
bleibt bei WhatsApp.
- **Upload durch alle**, aber mit **Freigabe-Pflicht**:
1. User lädt hoch (mit kurzem Pflicht-Titel „was ist das") → Status *wartet auf Freigabe*, sichtbar nur für Admin und den Uploader.
2. Admin sieht eine **„Freigaben"-Ansicht** → **Freigeben** oder **Ablehnen** (optional kurzer Grund).
3. Erst nach Freigabe erscheint das Dokument in der allgemeinen Liste.
- **Admin-Uploads** gehen direkt durch (keine Selbst-Freigabe).
- **Ablage:** eine einfache Liste, geteilt in **Aktuell** und **Archiv**. Verschieben
ins Archiv macht der Admin manuell.
- **Inhaltsregel (Foto-Schutz):** Hochgeladene Fotos nur **ohne erkennbare Dritte**
bzw. nur mit deren Einverständnis. Der Freigabe-Schritt sichert das zusätzlich ab.
---
## 8 · Benachrichtigungen
- **v1: nur in-App** — z. B. „du hast einen neuen Dienst", „neuer offener Dienst",
„neue Nachricht". Dezent, nicht nervig.
- **Push und/oder E-Mail: Future Log.** Push ist ohne App Store eingeschränkt
(v. a. iPhone) und würde v1 überladen.
---
## 9 · Design & Corporate Design
- Mobile-first, App-Breite ca. 360460px, viel Weißraum, klare Karten, runde
Aktionsflächen, Bottom-Navigation (**Start, Termine, Team, Profil**).
- Türkis als Hauptfarbe (Orientierung/Bestätigung), Rot sparsam (dringend,
Vertretung gesucht). Schrift: DM Sans.
- **Pro Team austauschbar** über CSS-Tokens — das CheckPoint-CD ist der erste
Mandant; weitere Teams bekommen ihre eigenen Tokens/Logos.
- Das vorhandene Mockup ist eine **erste Designidee**, keine verbindliche Vorlage.
Das finale Designsystem wird in Phase 1, Schritt 3 ausgearbeitet.
---
## 10 · Edge Cases & Regeln
- **Einsatz unterbesetzt:** zu wenige Verfügbare → sichtbarer *unterbesetzt*-Status für den Admin.
- **Springer fällt auch aus:** Platz wird für alle offen.
- **Verfügbarkeit nachträglich ändern:** nur solange *in Planung*; danach über Absage/Übernahme.
- **Memes/Unpassendes:** durch Freigabe-Pflicht abgefangen.
- **Übergangszeit:** App funktioniert eigenständig, auch wenn noch nicht alle umgestiegen sind.
---
## 11 · Datenschutz
- **Hosting auf eigenem VPS in der EU.**
- **Keine Klient:innendaten** in der App — es geht ausschließlich um die interne
Team-Orga.
- **Datensparsamkeit:** nur, was für die Orga nötig ist (Name, Login, Verfügbarkeit,
Zuteilung).
- Passwörter gehasht. Inhalte streng pro Team getrennt.
- Foto-Inhaltsregel (siehe 7.6) wegen des sensiblen AIDS-Hilfe-Kontexts.
> Hinweis: Für den realen Betrieb empfiehlt sich später eine knappe
> Datenschutzerklärung / ein Verarbeitungsverzeichnis in Abstimmung mit der
> AIDS-Hilfe. Das ist kein Entwicklungsthema, aber fürs Konzept vermerkt.
---
## 12 · Bewusst raus aus v1 (Out of Scope)
- Keine öffentlichen Seiten (rein internes Tool).
- Keine Klient:innendaten.
- Keine Bezahlfunktion.
- Kein Kalender-Sync (die Terminübersicht ist eine Liste, kein Kalender).
- Keine Bilder/Dateien im Chat.
- Keine automatische Dienstverteilung.
---
## 13 · Future Log (spätere Versionen)
- **1:1-Direktnachrichten** im Chat.
- **Push-Benachrichtigungen** und/oder **E-Mail-Benachrichtigungen**.
- **E-Mail + Einmal-Code (Magic Link)** als Login-Alternative.
- **Automatische Fairness-Verteilung** der Dienste (Vorschläge statt nur Anzeige).
- Weitere Rollen (z. B. Co-Admin).
- Laptop-Spiegelung des Setups / Desktop-Fallback über LAN (Entwicklungsumgebung).
---
## 14 · Geklärte Restpunkte
Alle zuvor offenen Punkte sind entschieden:
1. **Bottom-Nav:** zweiter Punkt heißt **„Termine"** (Liste der Einsätze), nicht „Kalender". ✔
2. **Fairness-Anzeige:** App zeigt pro Person *Anzahl Einsätze* und *letzter Einsatz*; Verteilung bleibt manuell. ✔
3. **Dokument-Dateitypen:** PDF, Word und Bilder (JPG/PNG) — ausreichend. ✔
4. **Profil:** minimal in v1 — Profilbild + optionale freiwillige Angaben (siehe 7.7). ✔
**Eine verbleibende Notiz (kein Blocker):** Welche freiwilligen Profil-Felder genau
angeboten werden, überlegst du dir noch. Lässt sich jederzeit ergänzen, ohne das
Konzept zu verändern.
---
**Damit ist das Konzept fix.** Nächste Schritte: **Schritt 3 (Designsystem)** und
danach **Schritt 4 (die drei Projektdateien: CLAUDE.md, TASKS.md,
Stakeholder-Dokument)**. Erst danach öffnest du VS Code.

View file

@ -0,0 +1,129 @@
# CheckPoint Ehrenamt — eine App für unser Vor-Ort-Team
*Zum Vorstellen im Team und bei der AIDS-Hilfe. Ohne Technik-Kauderwelsch.*
---
## Worum es geht
Unser Präventionsteam organisiert sich bisher über WhatsApp: Terminumfragen,
Absprachen in der Teamsitzung, Protokolle und allgemeine Kommunikation laufen alle
dort. Das hat lange funktioniert — wird aber zunehmend unübersichtlich. Und seit
ein Teil des Teams von WhatsApp zu Signal gewechselt ist, sind wir auf zwei Kanälen
unterwegs. Umfragen, Infos und Dokumente erreichen nicht mehr alle am selben Ort.
**Die Idee:** eine einzige, einfache App nur für unser Team. Sie bündelt das, was
heute über zwei Messenger verstreut ist — vor allem die Dienstplanung — an einem
verlässlichen Ort.
---
## Was die App können wird
### 1 · Termine abstimmen und Dienste verteilen (das Herzstück)
So wie heute, nur ohne Zettelwirtschaft:
- Die Koordination legt einen Planungszeitraum an (mindestens zwei Monate) und trägt
die Einsätze ein — die regelmäßigen Freitags-Touren, die Samstags-Einsätze und
besondere Veranstaltungen.
- Jede:r meldet pro Termin einfach **„kann ich" oder „kann ich nicht"**.
- Die Koordination sieht auf einen Blick, wer verfügbar ist, und verteilt die Dienste
— immer **zwei Personen plus eine Springer-Person** pro Einsatz. Die App hilft
dabei, niemanden zu oft hintereinander einzuteilen und die Einsätze fair zu verteilen.
**Warum so:** Das ist der Punkt, der heute am meisten Aufwand macht. Eine WhatsApp-
Umfrage zu zwei Monaten ist mühsam auszuwerten. Die App nimmt diese Arbeit ab und
macht für alle transparent, wer wann eingeteilt ist.
### 2 · Einspringen, wenn jemand ausfällt
Wird jemand krank oder muss absagen, **rückt automatisch die Springer-Person nach**.
Der dann offene Platz erscheint für alle sichtbar zum **Übernehmen** — wer mag, klickt
und übernimmt.
**Warum so:** Kurzfristige Ausfälle sind der Stressmoment. Statt hektischer
Nachrichten in der Gruppe ist sofort klar, dass ein Platz frei ist und wer ihn füllt.
### 3 · Eine Terminübersicht
Jede:r sieht die eigenen kommenden Einsätze als übersichtliche Liste — mit Datum,
Uhrzeit und ob als feste Person oder als Springer.
**Warum so:** Kein Suchen mehr im Chatverlauf. Die eigenen Dienste stehen klar an
einem Ort. (Bewusst eine Liste, kein voller Kalender — das reicht für unseren Zweck
und bleibt einfach.)
### 4 · Ein Team-Chat mit zwei Bereichen
- **Ankündigungen:** Hier postet nur die Koordination wichtige Infos. Alle lesen mit.
- **Team:** Hier können alle frei miteinander schreiben.
**Warum so:** Wichtige Orga-Infos gehen heute im Plauder-Verlauf unter. Die Trennung
sorgt dafür, dass Ankündigungen nicht verloren gehen — der lockere Austausch bleibt
trotzdem möglich.
### 5 · Dokumente und Fotos an einem Ort
Protokolle und andere Dokumente liegen geordnet in der App — geteilt in **Aktuell**
und **Archiv**. Auch Fotos von Touren können hochgeladen werden. Damit nichts
Unpassendes landet, **gibt die Koordination hochgeladene Inhalte vorher frei**.
**Warum so:** Protokolle im Chat sind nach zwei Wochen nicht mehr auffindbar. Hier
bleiben sie geordnet. Die Freigabe hält die Ablage sauber, ohne dass die Koordination
alles selbst hochladen muss.
### 6 · Ein einfaches Profil
Jede:r kann ein Profilbild hinterlegen und freiwillig ein paar Angaben machen — nichts
davon ist Pflicht.
---
## Wie man die App benutzt
Die App läuft **im Browser auf dem Handy** — kein Download aus einem App-Store nötig.
Man kann sie sich wie eine normale App auf den Startbildschirm legen. Sie funktioniert
auch auf hellen und dunklen Geräte-Einstellungen.
**Anmeldung:** Es gibt keine offene Registrierung. Die Koordination legt für jede
Person einen Zugang an. So ist sichergestellt, dass nur das Team Zugriff hat.
---
## Datenschutz
Das ist uns bei einer Anwendung im Umfeld der AIDS-Hilfe besonders wichtig:
- Die App speichert **nur Team-interne Orga-Daten** (wer ist wann im Einsatz, Termine,
Dokumente, Nachrichten). **Keine Daten von Klient:innen.**
- Sie läuft auf einem **eigenen Server in der EU** — nicht bei einem großen
US-Anbieter.
- Zugänge gibt es nur über die Koordination, Passwörter werden sicher gespeichert.
- Für hochgeladene Fotos gilt: nur ohne erkennbare unbeteiligte Personen
beziehungsweise nur mit deren Einverständnis. Die Freigabe sichert das zusätzlich ab.
---
## Was bewusst (noch) nicht kommt
Damit die erste Version übersichtlich und schnell nutzbar bleibt, lassen wir zunächst
weg: Push-Benachrichtigungen aufs Handy, E-Mail-Benachrichtigungen, private
Einzelchats und einen vollwertigen Kalender. Diese Dinge können später ergänzt
werden, wenn sich die App im Alltag bewährt hat.
---
## Und für andere Teams?
Die App wird von Anfang an so gebaut, dass auch **andere ehrenamtliche Teams** sie
nutzen könnten — jeweils mit eigenem Logo, eigenen Farben und streng getrennten
Daten. Für uns ändert das nichts; es hält nur die Tür für die Zukunft offen.
---
## Kurz gesagt
Aus zwei Messengern und vielen Umfragen wird **ein Ort**: für Termine, Dienste,
Dokumente und Absprachen. Weniger Suchen, weniger Aufwand bei der Planung, und für
alle klar, wer wann wo im Einsatz ist.

385
TASKS-backup.md Normal file
View file

@ -0,0 +1,385 @@
# TASKS.md — Build-Checkliste CheckPoint Ehrenamt App
> Eine Aufgabe pro Session. Nach Abhängigkeit geordnet — von oben nach unten abarbeiten.
> Pro Session: **Tool**, **Ziel**, **Session-Prompt** (zum Einfügen), **Deliverables**.
> Nach jeder Session: alle Häkchen setzen, committen, erst dann die nächste starten.
>
> Tool-Legende: 🛠 Claude Code · 💻 Continue + Qwen · 🏛 Continue + Mistral
> Modell wechseln: `llama-switch` → [1] Architect / [2] Coder
---
## Session 1 · Scaffold & Projektgerüst 🛠
**Ziel:** Lauffähiges Flask-Grundgerüst mit App-Factory, SQLite, Basis-Layout,
Static-Einbindung und Team-Scoping-Stub.
**Prompt:**
```
Lies CLAUDE.md. Erstelle das Projektgerüst gemäß der vorgeschlagenen Struktur:
Flask-App-Factory, SQLAlchemy mit SQLite (instance/), requirements.txt,
ein base.html mit eingebundener tokens.css und components.css, sowie ein
leeres Team-Scoping (Helper, der das aktive Team aus der Session liest).
Zeig mir zuerst den Plan und die Dateiliste, bevor du schreibst.
```
**Deliverables:**
- [x] App startet lokal (`flask run`) und zeigt eine leere Startseite
- [x] `tokens.css` + `components.css` in `base.html` eingebunden
- [x] SQLite initialisiert, `instance/` in `.gitignore`
- [x] Team-Scoping-Helper vorhanden (noch ohne echte Logik)
- [x] `requirements.txt` minimal gehalten
---
## Session 2 · Datenmodell & Mandanten 💻
**Ziel:** Alle Modelle aus CLAUDE.md, jedes mit `team_id`. Seed für das erste Team
(CheckPoint) inkl. Brand-Tokens und den zwei Chat-Kanälen.
**Prompt:**
```
@file app/models.py
@file CLAUDE.md
Erst den Ansatz erklären, dann implementieren — ein Modell nach dem anderen.
Baue die Modelle: team, user, planungszeitraum, einsatz, verfuegbarkeit,
zuteilung, dokument, kanal, nachricht. Jedes mit team_id. Danach ein Seed-Skript,
das das CheckPoint-Team mit Brand-Tokens und den Kanälen "ankuendigungen" und
"team" anlegt. Halte dich an die Feldvorgaben in CLAUDE.md.
```
**Deliverables:**
- [x] Alle Modelle vorhanden, jedes mit `team_id`
- [x] Beziehungen sauber (Einsatz↔Planungszeitraum, Zuteilung↔User/Einsatz)
- [x] Seed legt CheckPoint-Team + 2 Kanäle an
- [x] DB lässt sich anlegen, Seed läuft ohne Fehler
---
## Session 3 · Auth & Admin-Anlage 💻
**Ziel:** Login (Nutzername + Passwort, gehasht), Session, Logout, Team-Scoping aktiv,
Admin kann Nutzer anlegen. Keine Selbstregistrierung.
**Prompt:**
```
@file app/auth.py
@file app/models.py
@file CLAUDE.md
Ansatz zuerst. Baue: Login mit Nutzername+Passwort (werkzeug-Hashing), Session,
Logout. Aktiviere das Team-Scoping (aktives Team aus der Session). Admin-geschützte
Route zum Anlegen neuer Nutzer (Rolle wählbar). Geschützte Routen leiten ohne
Login zur Anmeldung um. Ein Stück nach dem anderen.
```
**Deliverables:**
- [x] Login/Logout funktioniert, Passwörter gehasht
- [x] Geschützte Routen ohne Login → Redirect
- [x] Admin kann Nutzer mit Rolle anlegen
- [x] Alle Queries laufen team-gescoped
- [ ] 🏛 Mini-Review (Mistral): Auth gegen CLAUDE.md prüfen — als Liste, nicht umschreiben
---
## Session 4 · Base-Layout, Theming & Dark-Mode-Fix 💻
**Ziel:** base.html final (Topbar, Bottom-Nav Start/Termine/Team/Profil, FAB-Slot),
Brand-Tokens pro Team in den `<head>` injizieren, Mockup-CSS auf Tokens umstellen.
**Prompt:**
```
@file app/templates/base.html
@file app/static/components.css
@file app/static/tokens.css
@file CLAUDE.md
Ansatz zuerst. 1) base.html mit Topbar (Logo, Glocke, Avatar) und Bottom-Nav
(Start, Termine, Team, Profil). 2) Brand-Tokens des aktiven Teams serverseitig
in den <head> schreiben. 3) Im components.css alle fest verdrahteten Weißwerte
(Topbar, Bottom-Nav, .cp-page-Verlauf) auf Tokens umstellen, damit der Dunkelmodus
greift. Erst die Token-Umstellung erklären, dann umsetzen.
```
**Deliverables:**
- [x] base.html mit korrekter Bottom-Nav (4 Punkte, „Termine")
- [x] Brand-Tokens kommen pro Team aus der DB in den `<head>`
- [x] Keine festen Weiß-/Schwarzwerte mehr im components.css (weiße Button-Schrift jetzt über Token `--cp-on-brand`, themeunabhängig)
- [x] Hell- und Dunkelmodus optisch sauber (System-Umschaltung testen)
---
## Session 5 · Planungszeitraum & Einsätze (Admin) 💻
**Ziel:** Admin legt Planungszeiträume an und trägt Einsätze ein (Datum, Zeit, Art).
**Prompt:**
```
@file app/routes/planung.py
@file app/models.py
@file CLAUDE.md
Ansatz zuerst. Admin-Ansichten: Planungszeitraum anlegen (Status in_planung) und
darin Einsätze hinzufügen/bearbeiten/löschen (Datum, Start/Ende, Art/Ort, je 2 Haupt-
+ 1 Springerplatz). Reine POST-Formulare, kein JS. Liste der Einsätze eines Zeitraums.
```
**Deliverables:**
- [x] Admin kann Zeitraum anlegen
- [x] Admin kann Einsätze anlegen/bearbeiten/löschen
- [x] Einsatz kennt seine Platzstruktur (2 + Springer)
- [x] Alles team-gescoped, ohne JavaScript
---
## Session 6 · Verfügbarkeit melden (Ehrenamtliche) 💻
**Ziel:** Ehrenamtliche melden pro Einsatz „kann / kann nicht", solange `in_planung`.
**Prompt:**
```
@file app/routes/planung.py
@file app/models.py
@file CLAUDE.md
Ansatz zuerst. Ansicht für Ehrenamtliche: Liste der Einsätze im laufenden Zeitraum
mit Umschaltung kann/kann_nicht pro Einsatz (POST-Formular). Änderung nur bei Status
in_planung. Übersichtlich für mobile.
```
**Deliverables:**
- [x] Ehrenamtliche sehen die Einsätze des aktuellen Zeitraums
- [x] kann/kann_nicht wird gespeichert und ist änderbar (nur in_planung)
- [x] Nach Veröffentlichung gesperrt
- [x] Mobile sauber bedienbar (44px-Ziele)
---
## Session 7 · Dienstplan bauen (Admin) 💻
**Ziel:** Admin verteilt manuell 2 Haupt + 1 Springer pro Einsatz, mit Fairness-Anzeige.
**Prompt:**
```
@file app/routes/dienste.py
@file app/models.py
@file CLAUDE.md
Ansatz zuerst. Admin-Ansicht pro Einsatz: verfügbare Personen auswählen und auf
Haupt/Springer setzen. Pro Person anzeigen: Anzahl bisheriger Einsätze und letzter
Einsatz (Fairness-Hilfe). Einsatz als „unterbesetzt" markieren, wenn zu wenige.
Keine automatische Verteilung — nur Anzeige + manuelle Auswahl.
```
**Deliverables:**
- [x] Admin kann Haupt- und Springerplätze besetzen
- [x] Fairness-Anzeige (Anzahl + letzter Einsatz) pro Person sichtbar
- [x] Unterbesetzte Einsätze klar markiert
- [ ] 🏛 Review (Mistral): Zuteilungslogik gegen CLAUDE.md prüfen
---
## Session 8 · Veröffentlichen & persönliche Terminübersicht 💻
**Ziel:** Admin veröffentlicht den Zeitraum; jede:r sieht „Meine Termine" als Liste.
**Prompt:**
```
@file app/routes/dienste.py
@file app/templates/
@file CLAUDE.md
Ansatz zuerst. 1) Admin-Aktion „Veröffentlichen" (Status veroeffentlicht, sperrt
Verfügbarkeit). 2) Ansicht „Meine Termine": Liste der eigenen zugeteilten Einsätze,
chronologisch, mit Rolle (Haupt/Springer) und Status. Keine Kalenderansicht.
```
**Deliverables:**
- [x] Veröffentlichen funktioniert und sperrt die Verfügbarkeit
- [x] „Meine Termine" zeigt eigene Einsätze als Liste
- [x] Haupt/Springer und Status erkennbar (nicht nur über Farbe)
---
## Session 9 · Absage & Übernahme 💻
**Ziel:** Absage einer Hauptperson → Springer rückt automatisch nach → Springerplatz
wird offen → „Übernehmen" für alle.
**Prompt:**
```
@file app/routes/dienste.py
@file app/models.py
@file CLAUDE.md
Ansatz zuerst. Logik laut CLAUDE.md: Sagt eine Hauptperson ab, rückt der Springer
automatisch nach (status uebernommen) und der Springerplatz wird offen. Offene
Plätze erscheinen mit „Übernehmen"-Button; wer zuerst klickt, bekommt ihn (sauber
gegen Doppel-Klicks absichern). Kein Springer → Platz direkt offen.
```
**Deliverables:**
- [x] Absage einer Hauptperson → Springer rückt automatisch nach
- [x] Frei gewordener Springerplatz wird offen zum Übernehmen
- [x] „Übernehmen" sicher gegen gleichzeitige Klicks (atomares UPDATE)
- [x] Fall „kein Springer" korrekt behandelt
- [x] Review der Übernahme-Logik (Randfälle) — mit Claude Code statt Mistral
---
## Session 10 · Dokumente & Freigabe 💻
**Ziel:** Upload (alle), Freigabe-Warteschlange (Admin), Liste Aktuell/Archiv.
**Prompt:**
```
@file app/routes/dokumente.py
@file app/models.py
@file CLAUDE.md
Ansatz zuerst. Upload (PDF/Word/Bild) mit Pflicht-Titel → Status wartet_auf_freigabe
(nur Admin + Uploader sichtbar). Admin-Ansicht „Freigaben": freigeben/ablehnen
(optional Grund). Admin-Uploads direkt freigegeben. Liste geteilt in Aktuell/Archiv,
Verschieben durch Admin. Dateigrößen-Limit und Dateityp-Prüfung serverseitig.
```
**Deliverables:**
- [x] Upload landet in wartet_auf_freigabe (nicht öffentlich sichtbar)
- [x] Admin kann freigeben/ablehnen, Admin-Uploads direkt sichtbar
- [x] Liste Aktuell + Archiv, Verschieben durch Admin
- [x] Server prüft Dateityp und -größe
---
## Session 11 · Chat (2 Kanäle, Polling) 💻
**Ziel:** Ankündigungen (nur Admin postet) + Team (alle). Nur Text. Neue Nachrichten
per `fetch`-Polling (einzige v1-JS-Ausnahme).
**Prompt:**
```
@file app/routes/chat.py
@file app/models.py
@file CLAUDE.md
Ansatz zuerst. Zwei Kanäle: ankuendigungen (nur Admin darf posten, alle lesen) und
team (alle posten). Nur Text. Nachrichten chronologisch. Dazu ein kleiner
JSON-Endpunkt „neue Nachrichten seit X" und ~30 Zeilen Vanilla-JS, das im
Hintergrund pollt und neue Nachrichten anhängt. Kein Framework, kein WebSocket.
```
**Deliverables:**
- [x] Beide Kanäle funktionieren, Rechte korrekt (Ankündigungen nur Admin)
- [x] Nachrichten werden gespeichert und angezeigt (nur Text)
- [x] Polling lädt neue Nachrichten ohne Seiten-Reload
- [x] JS ist minimal und vanilla
---
## Session 12 · Profil 💻
**Ziel:** Profilbild-Upload + Platzhalter für optionale freiwillige Angaben.
**Prompt:**
```
@file app/routes/profil.py
@file app/models.py
@file CLAUDE.md
Ansatz zuerst. Profil-Ansicht: Profilbild hochladen/ändern, Anzeigename, optionale
freiwillige Felder (vorerst ein, zwei Freitextfelder als Platzhalter — Struktur so,
dass Felder später leicht ergänzt werden). Nichts davon Pflicht.
```
**Deliverables:**
- [ ] Profilbild hochladbar
- [ ] Optionale Felder vorhanden, nichts Pflicht
- [ ] Struktur erlaubt späteres Ergänzen von Feldern
---
## Session 13 · In-App-Benachrichtigungen 💻
**Ziel:** Dezente in-App-Hinweise (neuer Dienst, offener Dienst, neue Nachricht).
**Prompt:**
```
@file app/models.py
@file app/templates/base.html
@file CLAUDE.md
Ansatz zuerst. Einfaches Benachrichtigungsmodell + Anzeige (Glocke in der Topbar mit
Zähler, Liste beim Antippen). Auslöser: neue Zuteilung, neuer offener Dienst, neue
Nachricht. Als gelesen markierbar. Kein Push, keine E-Mail.
```
**Deliverables:**
- [ ] Benachrichtigungen werden bei den drei Auslösern erzeugt
- [ ] Glocke zeigt ungelesene an, Liste antippbar
- [ ] Als gelesen markierbar
---
## Session 14 · Gesamt-Review 🏛
**Ziel:** Durchsicht des fertigen Stands gegen CLAUDE.md — Korrektheit, Konsistenz,
Barrierearmut, Dunkelmodus.
**Prompt:**
```
@file CLAUDE.md
@file app/models.py
@file app/routes/
Review den aktuellen Stand gegen CLAUDE.md. Korrektheitsprobleme zuerst, dann
Konsistenz und Wartbarkeit, dann Barrierearmut und Dunkelmodus-Lücken. Nichts
umschreiben — alles als nummerierte Liste mit Fundstelle.
```
**Deliverables:**
- [ ] Review-Liste erstellt
- [ ] Gefundene Korrektheitsprobleme in 💻-Folgeschritten behoben
- [ ] A11y- und Dark-Mode-Lücken geschlossen
---
## Session 15 · Deployment auf den VPS 🛠
**Ziel:** App läuft auf dem EU-VPS hinter einem produktiven Server.
**Prompt:**
```
Lies CLAUDE.md. Hilf mir beim Deployment auf einen EU-VPS: WSGI-Server (z. B.
gunicorn) hinter nginx, Umgebungsvariablen/Secrets, Persistenz für SQLite und
Uploads, einfaches Backup der DB, HTTPS. Gib mir die Schritte als Fish-Befehle,
wo Terminal nötig ist. Plan zuerst.
```
**Deliverables:**
- [ ] App läuft unter gunicorn hinter nginx
- [ ] HTTPS aktiv
- [ ] Secrets über Umgebungsvariablen, nicht im Code
- [ ] DB + Uploads persistent, einfaches DB-Backup eingerichtet
---
## Future Log (nicht in v1)
1:1-Chat · Push-/E-Mail-Benachrichtigungen · E-Mail+Code-Login (Magic Link) ·
automatische Fairness-Verteilung · weitere Rollen ·
Laptop-Spiegelung / Desktop-Fallback über LAN ·
komfortablere Zeit-Auswahl (Rad-/Scroll-Picker für Startzeit & Dauer statt Dropdown).
## Härtung (vor Produktiv-Deploy / Session 15)
- [x] **CSRF-Schutz für alle POST-Formulare.** Erledigt (nach Session 3): eigenes,
Session-gebundenes Token in `app/csrf.py`, ohne Flask-WTF. `csrf_token()` als
Jinja-Global, Prüfung aller unsicheren Methoden via `before_request`.
**Pflicht für alle künftigen POST-Formulare:** verstecktes Feld
`<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">` einbauen.
- [ ] **Brand-Tokens im `<head>` werden mit `|safe` ausgegeben** (`base.html`,
wegen der Anführungszeichen im Font). Quelle ist aktuell nur Admin/Seed, daher
für v1 vertretbar. **Sobald eine UI zum Bearbeiten der Brand-Felder entsteht:**
Werte serverseitig validieren/escapen (CSS-Injection / `</style>`-Ausbruch
verhindern) z. B. Hex-Farben per Regex prüfen, Font auf Whitelist begrenzen.

431
TASKS.md Normal file
View file

@ -0,0 +1,431 @@
# TASKS.md — Build-Checkliste CheckPoint Ehrenamt App
> Eine Aufgabe pro Session. Nach Abhängigkeit geordnet — von oben nach unten abarbeiten.
> Pro Session: **Tool**, **Ziel**, **Session-Prompt** (zum Einfügen), **Deliverables**.
> Nach jeder Session: alle Häkchen setzen, committen, erst dann die nächste starten.
>
> Tool-Legende: 🛠 Claude Code · 💻 Continue + Qwen · 🏛 Continue + Mistral
> Modell wechseln: `llama-switch` → [1] Architect / [2] Coder
---
## Session 1 · Scaffold & Projektgerüst 🛠
**Ziel:** Lauffähiges Flask-Grundgerüst mit App-Factory, SQLite, zwei leeren
Shell-Templates, Static-Einbindung und Team-Scoping-Stub.
**Prompt:**
```
Lies CLAUDE.md. Erstelle das Projektgerüst gemäß der vorgeschlagenen Struktur:
Flask-App-Factory, SQLAlchemy mit SQLite (instance/), requirements.txt,
zwei minimale Shell-Templates base_user.html (Klasse cp-shell-user) und
base_admin.html (Klasse cp-shell-admin), jeweils mit eingebundener tokens.css
und ihrem jeweiligen Komponenten-CSS, sowie ein leeres Team-Scoping (Helper,
der das aktive Team aus der Session liest). Zeig mir zuerst den Plan und die
Dateiliste, bevor du schreibst.
```
**Deliverables:**
- [x] App startet lokal (`flask run`) und zeigt eine leere Startseite
- [x] `base_user.html` und `base_admin.html` existieren, je mit korrekter Shell-Klasse
- [x] `tokens.css` in beiden eingebunden; `components.css` nur in `base_user.html`, `components-admin.css` nur in `base_admin.html`
- [x] SQLite initialisiert, `instance/` in `.gitignore`
- [x] Team-Scoping-Helper vorhanden (noch ohne echte Logik)
- [x] `requirements.txt` minimal gehalten
---
## Session 2 · Datenmodell & Mandanten 💻
**Ziel:** Alle Modelle aus CLAUDE.md, jedes mit `team_id`. Seed für das erste Team
(CheckPoint) inkl. Brand-Tokens und den zwei Chat-Kanälen.
**Prompt:**
```
@file app/models.py
@file CLAUDE.md
Erst den Ansatz erklären, dann implementieren — ein Modell nach dem anderen.
Baue die Modelle: team, user, planungszeitraum, einsatz, verfuegbarkeit,
zuteilung, dokument, kanal, nachricht. Jedes mit team_id. Danach ein Seed-Skript,
das das CheckPoint-Team mit Brand-Tokens und den Kanälen "ankuendigungen" und
"team" anlegt. Halte dich an die Feldvorgaben in CLAUDE.md.
```
**Deliverables:**
- [x] Alle Modelle vorhanden, jedes mit `team_id`
- [x] Beziehungen sauber (Einsatz↔Planungszeitraum, Zuteilung↔User/Einsatz)
- [x] Seed legt CheckPoint-Team + 2 Kanäle an
- [x] DB lässt sich anlegen, Seed läuft ohne Fehler
---
## Session 3 · Auth & Admin-Anlage 💻
**Ziel:** Login (Nutzername + Passwort, gehasht), Session, Logout, Team-Scoping aktiv,
Admin kann Nutzer anlegen. Keine Selbstregistrierung.
**Prompt:**
```
@file app/auth.py
@file app/models.py
@file CLAUDE.md
Ansatz zuerst. Baue: Login mit Nutzername+Passwort (werkzeug-Hashing), Session,
Logout. Aktiviere das Team-Scoping (aktives Team aus der Session). Admin-geschützte
Route zum Anlegen neuer Nutzer (Rolle wählbar). Geschützte Routen leiten ohne
Login zur Anmeldung um. Ein Stück nach dem anderen.
```
**Deliverables:**
- [x] Login/Logout funktioniert, Passwörter gehasht
- [x] Geschützte Routen ohne Login → Redirect
- [x] Admin kann Nutzer mit Rolle anlegen
- [x] Alle Queries laufen team-gescoped
- [x] 🏛 Mini-Review (Mistral): Auth gegen CLAUDE.md prüfen — als Liste, nicht umschreiben
---
## Session 4a · User-Shell: Base-Layout, Theming & Dark-Mode-Fix 💻
**Ziel:** `base_user.html` final (Topbar, Bottom-Nav Start/Termine/Team/Profil,
FAB-Slot), User-Brand-Tokens pro Team in den `<head>` injizieren, Mockup-CSS auf
`--cp-user-*`-Tokens umstellen.
**Prompt:**
```
@file app/templates/base_user.html
@file app/static/components.css
@file app/static/tokens.css
@file CLAUDE.md
Ansatz zuerst. 1) base_user.html mit Topbar (Logo, Glocke, Avatar), Body-Klasse
cp-shell-user und Bottom-Nav (Start, Termine, Team, Profil). 2) User-Brand-Tokens
(--cp-user-brand-*) des aktiven Teams serverseitig in den <head> schreiben.
3) Im components.css alle fest verdrahteten Weißwerte (Topbar, Bottom-Nav,
.cp-page-Verlauf) auf --cp-user-*-Tokens umstellen, damit der Dunkelmodus greift.
Erst die Token-Umstellung erklären, dann umsetzen.
```
**Deliverables:**
- [x] `base_user.html` mit korrekter Bottom-Nav (4 Punkte, „Termine"), Klasse `cp-shell-user`
- [x] User-Brand-Tokens kommen pro Team aus der DB in den `<head>`
- [x] Keine festen Weiß-/Schwarzwerte mehr im `components.css`
- [x] Hell- und Dunkelmodus optisch sauber (System-Umschaltung testen)
---
## Session 4b · Admin-Shell: Sidebar, Off-Canvas & Theming 💻
**Ziel:** `base_admin.html` mit Desktop-Sidebar (Start, Planung, Dienste, Freigaben,
Team, Chat-Verwaltung, Profil, Abmelden), Admin-Brand-Tokens pro Team in den `<head>`,
mobiles Off-Canvas-Menü per Hamburger-Icon und kleinem Vanilla-JS.
**Prompt:**
```
@file app/templates/base_admin.html
@file app/static/components-admin.css
@file app/static/tokens.css
@file app/static/sidebar.js
@file CLAUDE.md
Ansatz zuerst. 1) base_admin.html mit Body-Klasse cp-shell-admin: Sidebar links
(Logo oben, Navigationspunkte Start/Planung/Dienste/Freigaben/Team/Chat-Verwaltung/
Profil, Abmelden unten abgetrennt) und Hauptbereich rechts. 2) Admin-Brand-Tokens
(--cp-admin-brand-*) serverseitig in den <head> schreiben. 3) components-admin.css
ausschließlich mit --cp-admin-*-Tokens aufbauen (Cards, Tabellen, Buttons gemäß
CLAUDE.md-Regeln). 4) Unter dem Breakpoint: Sidebar kollabiert zu einer schmalen
Topbar mit Hamburger-Icon; sidebar.js togglet eine Klasse, die die Sidebar als
Off-Canvas-Overlay einblendet, schließt bei Klick auf Overlay-Fläche oder einen
Menüpunkt. Vanilla, keine Bibliothek. Erst den Ansatz fürs Off-Canvas-Verhalten
erklären, dann umsetzen.
```
**Deliverables:**
- [x] `base_admin.html` mit Sidebar (alle 7 Punkte + Abmelden), Klasse `cp-shell-admin`
- [x] Admin-Brand-Tokens kommen pro Team aus der DB in den `<head>`
- [x] `components-admin.css` nutzt ausschließlich `--cp-admin-*`-Tokens, keine Vermischung mit User-Tokens
- [x] Sidebar kollabiert unter dem Breakpoint zu Hamburger + Off-Canvas-Overlay
- [x] Hell- und Dunkelmodus der Arbeitsfläche sauber (Sidebar bleibt bewusst dunkel in beiden Modi)
- [x] Touch-Ziele auch im Admin-Bereich ≥ 44px
---
## Session 5 · Planungszeitraum & Einsätze (Admin) 💻
**Ziel:** Admin legt Planungszeiträume an und trägt Einsätze ein (Datum, Zeit, Art).
Läuft in der Admin-Shell.
**Prompt:**
```
@file app/routes/planung.py
@file app/templates/base_admin.html
@file app/models.py
@file CLAUDE.md
Ansatz zuerst. Admin-Ansichten (erweitern base_admin.html): Planungszeitraum
anlegen (Status in_planung) und darin Einsätze hinzufügen/bearbeiten/löschen
(Datum, Start/Ende, Art/Ort, je 2 Haupt- + 1 Springerplatz). Reine POST-Formulare,
kein JS. Liste der Einsätze eines Zeitraums als Cards/Tabelle im Admin-Stil.
```
**Deliverables:**
- [x] Admin kann Zeitraum anlegen
- [x] Admin kann Einsätze anlegen/bearbeiten/löschen
- [x] Einsatz kennt seine Platzstruktur (2 + Springer)
- [x] Alles team-gescoped, ohne JavaScript, in der Admin-Shell
---
## Session 6 · Verfügbarkeit melden (Ehrenamtliche) 💻
**Ziel:** Ehrenamtliche melden pro Einsatz „kann / kann nicht", solange `in_planung`.
**Prompt:**
```
@file app/routes/planung.py
@file app/models.py
@file CLAUDE.md
Ansatz zuerst. Ansicht für Ehrenamtliche: Liste der Einsätze im laufenden Zeitraum
mit Umschaltung kann/kann_nicht pro Einsatz (POST-Formular). Änderung nur bei Status
in_planung. Übersichtlich für mobile.
```
**Deliverables:**
- [x] Ehrenamtliche sehen die Einsätze des aktuellen Zeitraums
- [x] kann/kann_nicht wird gespeichert und ist änderbar (nur in_planung)
- [x] Nach Veröffentlichung gesperrt
- [x] Mobile sauber bedienbar (44px-Ziele)
---
## Session 7 · Dienstplan bauen (Admin) 💻
**Ziel:** Admin verteilt manuell 2 Haupt + 1 Springer pro Einsatz, mit Fairness-Anzeige.
Läuft in der Admin-Shell (Tabellen-Stil).
**Prompt:**
```
@file app/routes/dienste.py
@file app/templates/base_admin.html
@file app/models.py
@file CLAUDE.md
Ansatz zuerst. Admin-Ansicht pro Einsatz (in der Admin-Shell, Tabelle im Stil aus
CLAUDE.md: Navy-Tabellenkopf, Statusspalten mit Text+Farbe): verfügbare Personen
auswählen und auf Haupt/Springer setzen. Pro Person anzeigen: Anzahl bisheriger
Einsätze und letzter Einsatz (Fairness-Hilfe). Einsatz als „unterbesetzt" markieren,
wenn zu wenige. Keine automatische Verteilung — nur Anzeige + manuelle Auswahl.
```
**Deliverables:**
- [x] Admin kann Haupt- und Springerplätze besetzen
- [x] Fairness-Anzeige (Anzahl + letzter Einsatz) pro Person sichtbar
- [x] Unterbesetzte Einsätze klar markiert
- [x] Tabelle nutzt ausschließlich `--cp-admin-*`-Tokens
- [x] 🏛 Review (Mistral): Zuteilungslogik gegen CLAUDE.md prüfen
---
## Session 8 · Veröffentlichen & persönliche Terminübersicht 💻
**Ziel:** Admin veröffentlicht den Zeitraum; jede:r sieht „Meine Termine" als Liste.
**Prompt:**
```
@file app/routes/dienste.py
@file app/templates/
@file CLAUDE.md
Ansatz zuerst. 1) Admin-Aktion „Veröffentlichen" (Status veroeffentlicht, sperrt
Verfügbarkeit). 2) Ansicht „Meine Termine": Liste der eigenen zugeteilten Einsätze,
chronologisch, mit Rolle (Haupt/Springer) und Status. Keine Kalenderansicht.
```
**Deliverables:**
- [x] Veröffentlichen funktioniert und sperrt die Verfügbarkeit
- [x] „Meine Termine" zeigt eigene Einsätze als Liste
- [x] Haupt/Springer und Status erkennbar (nicht nur über Farbe)
---
## Session 9 · Absage & Übernahme 💻
**Ziel:** Absage einer Hauptperson → Springer rückt automatisch nach → Springerplatz
wird offen → „Übernehmen" für alle.
**Prompt:**
```
@file app/routes/dienste.py
@file app/models.py
@file CLAUDE.md
Ansatz zuerst. Logik laut CLAUDE.md: Sagt eine Hauptperson ab, rückt der Springer
automatisch nach (status uebernommen) und der Springerplatz wird offen. Offene
Plätze erscheinen mit „Übernehmen"-Button; wer zuerst klickt, bekommt ihn (sauber
gegen Doppel-Klicks absichern). Kein Springer → Platz direkt offen.
```
**Deliverables:**
- [x] Absage einer Hauptperson → Springer rückt automatisch nach
- [x] Frei gewordener Springerplatz wird offen zum Übernehmen
- [x] „Übernehmen" sicher gegen gleichzeitige Klicks
- [x] Fall „kein Springer" korrekt behandelt
- [x] 🏛 Review (Mistral): Übernahme-Logik prüfen (Randfälle!)
---
## Session 10 · Dokumente & Freigabe 💻
**Ziel:** Upload (alle), Freigabe-Warteschlange (Admin), Liste Aktuell/Archiv.
**Hinweis — gemischte Shell:** Die Upload-/Listenansicht für Ehrenamtliche läuft in
der User-Shell (`base_user.html`), die Freigaben-Ansicht ausschließlich in der
Admin-Shell (`base_admin.html`). Beide Templates entsprechend ansprechen, nicht
vermischen.
**Prompt:**
```
@file app/routes/dokumente.py
@file app/templates/base_user.html
@file app/templates/base_admin.html
@file app/models.py
@file CLAUDE.md
Ansatz zuerst. 1) Upload-Ansicht in der User-Shell (PDF/Word/Bild) mit Pflicht-Titel
→ Status wartet_auf_freigabe (nur Admin + Uploader sichtbar), dort auch die Liste
Aktuell/Archiv für Ehrenamtliche. 2) Freigaben-Ansicht in der Admin-Shell:
freigeben/ablehnen (optional Grund), Admin-Uploads direkt freigegeben, Verschieben
Aktuell↔Archiv. Dateigrößen-Limit und Dateityp-Prüfung serverseitig.
```
**Deliverables:**
- [x] Upload landet in wartet_auf_freigabe (nicht öffentlich sichtbar), in der User-Shell
- [x] Admin kann freigeben/ablehnen in der Admin-Shell, Admin-Uploads direkt sichtbar
- [x] Liste Aktuell + Archiv (User-Shell), Verschieben durch Admin (Admin-Shell)
- [x] Server prüft Dateityp und -größe
---
## Session 11 · Chat (2 Kanäle, Polling) 💻
**Ziel:** Ankündigungen (nur Admin postet) + Team (alle). Nur Text. Neue Nachrichten
per `fetch`-Polling (einzige v1-JS-Ausnahme).
**Prompt:**
```
@file app/routes/chat.py
@file app/models.py
@file CLAUDE.md
Ansatz zuerst. Zwei Kanäle: ankuendigungen (nur Admin darf posten, alle lesen) und
team (alle posten). Nur Text. Nachrichten chronologisch. Dazu ein kleiner
JSON-Endpunkt „neue Nachrichten seit X" und ~30 Zeilen Vanilla-JS, das im
Hintergrund pollt und neue Nachrichten anhängt. Kein Framework, kein WebSocket.
```
**Deliverables:**
- [x] Beide Kanäle funktionieren, Rechte korrekt (Ankündigungen nur Admin)
- [x] Nachrichten werden gespeichert und angezeigt (nur Text)
- [x] Polling lädt neue Nachrichten ohne Seiten-Reload
- [x] JS ist minimal und vanilla
---
## Session 12 · Profil 💻
**Ziel:** Profilbild-Upload + Platzhalter für optionale freiwillige Angaben.
**Prompt:**
```
@file app/routes/profil.py
@file app/models.py
@file CLAUDE.md
Ansatz zuerst. Profil-Ansicht: Profilbild hochladen/ändern, Anzeigename, optionale
freiwillige Felder (vorerst ein, zwei Freitextfelder als Platzhalter — Struktur so,
dass Felder später leicht ergänzt werden). Nichts davon Pflicht.
```
**Deliverables:**
- [x] Profilbild hochladbar
- [x] Optionale Felder vorhanden, nichts Pflicht
- [x] Struktur erlaubt späteres Ergänzen von Feldern
---
## Session 13 · In-App-Benachrichtigungen 💻
**Ziel:** Dezente in-App-Hinweise (neuer Dienst, offener Dienst, neue Nachricht) in
beiden Shells.
**Prompt:**
```
@file app/models.py
@file app/templates/base_user.html
@file app/templates/base_admin.html
@file CLAUDE.md
Ansatz zuerst. Einfaches Benachrichtigungsmodell + Anzeige: Glocke in der Topbar
der User-Shell mit Zähler, Liste beim Antippen; in der Admin-Shell entsprechend in
der Sidebar/Kopfbereich. Auslöser: neue Zuteilung, neuer offener Dienst, neue
Nachricht. Als gelesen markierbar. Kein Push, keine E-Mail.
```
**Deliverables:**
- [x] Benachrichtigungen werden bei den drei Auslösern erzeugt
- [x] Glocke/Hinweis zeigt ungelesene an, in beiden Shells passend platziert
- [x] Als gelesen markierbar
---
## Session 14 · Gesamt-Review 🏛
**Ziel:** Durchsicht des fertigen Stands gegen CLAUDE.md — Korrektheit, Konsistenz,
Barrierearmut, Dunkelmodus.
**Prompt:**
```
@file CLAUDE.md
@file app/models.py
@file app/routes/
Review den aktuellen Stand gegen CLAUDE.md. Korrektheitsprobleme zuerst, dann
Konsistenz und Wartbarkeit, dann Barrierearmut und Dunkelmodus-Lücken. Nichts
umschreiben — alles als nummerierte Liste mit Fundstelle.
```
**Deliverables:**
- [x] Review-Liste erstellt
- [x] Gefundene Korrektheitsprobleme behoben (Lokalzeit-Filter, Offen-Benachrichtigung
ohne bereits Besetzte; offene Struktur-Divergenzen als Entscheidungen vermerkt)
- [x] A11y-Lücke geschlossen (Body-Scroll-Lock bei offenem Off-Canvas); keine echten
Dark-Mode-Lücken gefunden
---
## Session 15 · Deployment auf den VPS 🛠
**Ziel:** App läuft auf dem EU-VPS hinter einem produktiven Server.
**Prompt:**
```
Lies CLAUDE.md. Hilf mir beim Deployment auf einen EU-VPS: WSGI-Server (z. B.
gunicorn) hinter nginx, Umgebungsvariablen/Secrets, Persistenz für SQLite und
Uploads, einfaches Backup der DB, HTTPS. Gib mir die Schritte als Fish-Befehle,
wo Terminal nötig ist. Plan zuerst.
```
**Deliverables:**
- [ ] App läuft unter gunicorn hinter nginx
- [ ] HTTPS aktiv
- [ ] Secrets über Umgebungsvariablen, nicht im Code
- [ ] DB + Uploads persistent, einfaches DB-Backup eingerichtet
---
## Future Log (nicht in v1)
1:1-Chat · Push-/E-Mail-Benachrichtigungen · E-Mail+Code-Login (Magic Link) ·
automatische Fairness-Verteilung · weitere Rollen ·
Laptop-Spiegelung / Desktop-Fallback über LAN.

124
app/__init__.py Normal file
View file

@ -0,0 +1,124 @@
"""App-Factory für die CheckPoint-Ehrenamt-App.
Baut die Flask-App zusammen, initialisiert die Datenbank (SQLite in instance/)
und registriert die Blueprints. Mandantenfähigkeit wird über das Team-Scoping
(app/team_scope.py) vorbereitet die echte Logik folgt in späteren Sessions.
"""
import os
from datetime import timezone
from zoneinfo import ZoneInfo
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import event
from sqlalchemy.engine import Engine
_BERLIN = ZoneInfo("Europe/Berlin")
@event.listens_for(Engine, "connect")
def _sqlite_pragmas(dbapi_connection, connection_record):
"""Nur für SQLite: WAL + busy_timeout machen parallele gunicorn-Worker
verträglich (statt database is locked"), Fremdschlüssel werden erzwungen.
Bei anderen Engines (z. B. späteres PostgreSQL) ein No-op."""
import sqlite3
if isinstance(dbapi_connection, sqlite3.Connection):
cur = dbapi_connection.cursor()
cur.execute("PRAGMA journal_mode=WAL")
cur.execute("PRAGMA busy_timeout=5000")
cur.execute("PRAGMA foreign_keys=ON")
cur.close()
def _lokalzeit(dt, fmt="%d.%m.%Y %H:%M"):
"""Jinja-Filter: UTC-Zeitstempel (in SQLite naiv) → Europe/Berlin formatiert."""
if dt is None:
return ""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(_BERLIN).strftime(fmt)
# Eine zentrale DB-Instanz, die die Modelle (Session 2) importieren.
db = SQLAlchemy()
def create_app(test_config=None):
# instance_relative_config: Pfade wie die SQLite-DB liegen in instance/.
app = Flask(__name__, instance_relative_config=True)
# CP_ENV=production schaltet die Härtung scharf (sichere Cookies, SECRET_KEY Pflicht).
ist_produktion = os.environ.get("CP_ENV") == "production"
secret = os.environ.get("SECRET_KEY")
if ist_produktion and not secret:
raise RuntimeError(
"SECRET_KEY muss in Produktion gesetzt sein (CP_ENV=production)."
)
app.config.from_mapping(
# In Produktion über Umgebungsvariable setzen, nie hart im Code.
SECRET_KEY=secret or "dev-only-change-me",
SQLALCHEMY_DATABASE_URI="sqlite:///"
+ os.path.join(app.instance_path, "checkpoint.sqlite"),
SQLALCHEMY_TRACK_MODIFICATIONS=False,
# Dokument-Uploads (Session 10): außerhalb von static/, max. 10 MB.
UPLOAD_FOLDER=os.path.join(app.instance_path, "uploads"),
MAX_CONTENT_LENGTH=10 * 1024 * 1024,
# Cookie-Härtung. Secure nur in Produktion (sonst bricht http-Login lokal).
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=ist_produktion,
)
if test_config is not None:
app.config.from_mapping(test_config)
# instance/ + Upload-Ordner sicherstellen (für DB + Dokumente).
os.makedirs(app.instance_path, exist_ok=True)
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
# Hinter nginx: X-Forwarded-Proto/-For/-Host auswerten (korrektes https-Schema).
if ist_produktion:
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
db.init_app(app)
# CSRF-Schutz für alle POST-Formulare.
from .csrf import init_csrf
init_csrf(app)
# Lokalzeit-Filter (einheitliche Anzeige in Europe/Berlin).
app.jinja_env.filters["lokalzeit"] = _lokalzeit
# Modelle importieren, damit sie bei create_all() bekannt sind.
# (Noch leer die Tabellen kommen in Session 2.)
from . import models # noqa: F401
with app.app_context():
db.create_all()
# Blueprints registrieren.
from .routes import main_bp
from .routes.planung import bp as planung_bp
from .routes.dienste import bp as dienste_bp
from .routes.dokumente import bp as dokumente_bp
from .routes.chat import bp as chat_bp
from .routes.profil import bp as profil_bp
from .auth import bp as auth_bp, register_cli
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(planung_bp)
app.register_blueprint(dienste_bp)
app.register_blueprint(dokumente_bp)
app.register_blueprint(chat_bp)
app.register_blueprint(profil_bp)
# CLI-Befehle (z. B. flask create-admin).
register_cli(app)
return app

215
app/auth.py Normal file
View file

@ -0,0 +1,215 @@
"""Authentifizierung & Admin-Anlage.
- Login (Nutzername + Passwort, werkzeug-Hashing), Logout, Session.
- Team-Scoping wird beim Login aktiviert (Team in die Session).
- Admin-geschützte Route zum Anlegen neuer Nutzer (Rolle wählbar).
- Keine Selbstregistrierung: den ersten Admin legt der CLI-Befehl create-admin an.
"""
import functools
import click
from flask.cli import with_appcontext
from flask import (
Blueprint,
flash,
g,
redirect,
render_template,
request,
session,
url_for,
)
from werkzeug.security import check_password_hash, generate_password_hash
from . import db
from .models import Team, User
from .team_scope import (
SESSION_USER_KEY,
clear_session,
current_team,
get_active_team_id,
set_active_session,
team_query,
)
bp = Blueprint("auth", __name__)
# -----------------------------------------------------------------------------
# Nutzer pro Request laden
# -----------------------------------------------------------------------------
@bp.before_app_request
def load_logged_in_user():
"""g.user pro Request setzen team-gescoped, damit Session und Team passen."""
user_id = session.get(SESSION_USER_KEY)
team_id = get_active_team_id()
if user_id is None or team_id is None:
g.user = None
return
# Bewusst über team_query: ein manipuliertes Session-Cookie kann so keinen
# Nutzer aus einem fremden Team laden.
g.user = team_query(User).filter_by(id=user_id).first()
if g.user is None:
# Session verweist ins Leere (z. B. Nutzer gelöscht) -> aufräumen.
clear_session()
def current_user():
return getattr(g, "user", None)
@bp.app_context_processor
def inject_user_and_team():
"""current_user und current_team in allen Templates verfügbar machen
(Topbar, Bottom-Nav, Brand-Token-Injektion in <head>)."""
return {"current_user": current_user(), "current_team": current_team()}
# -----------------------------------------------------------------------------
# Dekoratoren
# -----------------------------------------------------------------------------
def login_required(view):
@functools.wraps(view)
def wrapped(*args, **kwargs):
if current_user() is None:
return redirect(url_for("auth.login", next=request.path))
return view(*args, **kwargs)
return wrapped
def admin_required(view):
@functools.wraps(view)
def wrapped(*args, **kwargs):
user = current_user()
if user is None:
return redirect(url_for("auth.login", next=request.path))
if not user.ist_admin:
return render_template("403.html"), 403
return view(*args, **kwargs)
return wrapped
# -----------------------------------------------------------------------------
# Login / Logout
# -----------------------------------------------------------------------------
@bp.route("/login", methods=("GET", "POST"))
def login():
if current_user() is not None:
return redirect(url_for("main.index"))
# Nutzername ist nur PRO Team eindeutig → der Login muss team-scoped sein.
mehrere_teams = Team.query.count() > 1
if request.method == "POST":
nutzername = (request.form.get("nutzername") or "").strip()
passwort = request.form.get("passwort") or ""
team_kuerzel = (request.form.get("team") or "").strip().lower()
# Team bestimmen: per Kürzel; ohne Kürzel nur, wenn es genau eins gibt.
if team_kuerzel:
team = Team.query.filter_by(slug=team_kuerzel).first()
elif not mehrere_teams:
team = Team.query.first()
else:
team = None
user = None
if team is not None:
user = User.query.filter_by(team_id=team.id, nutzername=nutzername).first()
if user is None or not check_password_hash(user.passwort_hash, passwort):
# Gleiche Meldung für alle Fälle (kein Enumerieren von Team/Nutzer/Passwort).
flash("Anmeldedaten stimmen nicht.")
else:
set_active_session(user.id, user.team_id)
next_url = request.args.get("next")
# Nur lokale Pfade zulassen (kein offener Redirect).
if next_url and next_url.startswith("/"):
return redirect(next_url)
return redirect(url_for("main.index"))
return render_template("login.html", mehrere_teams=mehrere_teams)
@bp.route("/logout", methods=("POST",))
def logout():
clear_session()
return redirect(url_for("auth.login"))
# -----------------------------------------------------------------------------
# Admin: Nutzer anlegen
# -----------------------------------------------------------------------------
@bp.route("/admin/nutzer", methods=("GET", "POST"))
@admin_required
def nutzer_anlegen():
if request.method == "POST":
nutzername = (request.form.get("nutzername") or "").strip()
anzeigename = (request.form.get("anzeigename") or "").strip()
passwort = request.form.get("passwort") or ""
rolle = request.form.get("rolle") or User.ROLLE_EHRENAMT
fehler = None
if not nutzername or not anzeigename or not passwort:
fehler = "Nutzername, Anzeigename und Passwort sind nötig."
elif rolle not in (User.ROLLE_ADMIN, User.ROLLE_EHRENAMT):
fehler = "Unbekannte Rolle."
elif team_query(User).filter_by(nutzername=nutzername).first() is not None:
fehler = f"Nutzername „{nutzername}“ ist im Team schon vergeben."
if fehler:
flash(fehler)
else:
user = User(
team_id=get_active_team_id(),
rolle=rolle,
nutzername=nutzername,
anzeigename=anzeigename,
passwort_hash=generate_password_hash(passwort),
)
db.session.add(user)
db.session.commit()
flash(f"Nutzer „{anzeigename}“ angelegt.")
return redirect(url_for("auth.nutzer_anlegen"))
nutzer = team_query(User).order_by(User.anzeigename).all()
return render_template("nutzer_neu.html", nutzer=nutzer)
# -----------------------------------------------------------------------------
# CLI: ersten Admin anlegen (Bootstrap, da keine Selbstregistrierung)
# -----------------------------------------------------------------------------
@click.command("create-admin")
@click.argument("nutzername")
@click.argument("anzeigename")
@click.argument("passwort")
@click.option("--team", "team_name", default="CheckPoint", help="Teamname (Default: CheckPoint).")
@with_appcontext
def create_admin_command(nutzername, anzeigename, passwort, team_name):
"""Legt einen Admin im genannten Team an (Team muss existieren siehe seed.py)."""
team = Team.query.filter_by(name=team_name).first()
if team is None:
raise click.ClickException(
f"Team „{team_name}“ existiert nicht. Erst `python seed.py` ausführen."
)
if User.query.filter_by(team_id=team.id, nutzername=nutzername).first() is not None:
raise click.ClickException(f"Nutzername „{nutzername}“ ist in „{team_name}“ schon vergeben.")
user = User(
team_id=team.id,
rolle=User.ROLLE_ADMIN,
nutzername=nutzername,
anzeigename=anzeigename,
passwort_hash=generate_password_hash(passwort),
)
db.session.add(user)
db.session.commit()
click.echo(f"Admin „{anzeigename}“ in Team „{team_name}“ angelegt.")
def register_cli(app):
app.cli.add_command(create_admin_command)

50
app/csrf.py Normal file
View file

@ -0,0 +1,50 @@
"""CSRF-Schutz leichtgewichtig, ohne Flask-WTF (siehe CLAUDE.md: „so wenig wie möglich").
Ein Session-gebundenes Token wird in jedes POST-Formular geschrieben und bei jeder
unsicheren Anfrage geprüft. Reicht für eine interne App mit Session-Cookies.
Nutzung im Template:
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
"""
import hmac
import secrets
from flask import abort, render_template, request, session
CSRF_SESSION_KEY = "_csrf_token"
CSRF_FORM_FIELD = "csrf_token"
CSRF_HEADER = "X-CSRFToken"
SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"}
def generate_csrf():
"""Token der aktuellen Session zurückgeben (beim ersten Aufruf erzeugen)."""
if CSRF_SESSION_KEY not in session:
session[CSRF_SESSION_KEY] = secrets.token_urlsafe(32)
return session[CSRF_SESSION_KEY]
def _validate_csrf():
erwartet = session.get(CSRF_SESSION_KEY)
gesendet = request.form.get(CSRF_FORM_FIELD) or request.headers.get(CSRF_HEADER)
# compare_digest: konstante Laufzeit, verträgt aber keine None-Werte.
if not erwartet or not gesendet or not hmac.compare_digest(erwartet, gesendet):
abort(400, description="csrf")
def init_csrf(app):
@app.before_request
def csrf_protect():
if request.method not in SAFE_METHODS:
_validate_csrf()
@app.errorhandler(400)
def csrf_fehler(err):
# Nur die CSRF-Fälle bekommen die freundliche Seite; andere 400 unverändert.
if getattr(err, "description", None) == "csrf":
return render_template("400_csrf.html"), 400
return err
# Im Template als csrf_token() verfügbar machen.
app.jinja_env.globals["csrf_token"] = generate_csrf

375
app/models.py Normal file
View file

@ -0,0 +1,375 @@
"""SQLAlchemy-Modelle der CheckPoint-Ehrenamt-App.
Regeln (siehe CLAUDE.md):
- Mandantenfähig ab Tag 1: JEDES Modell trägt eine team_id. Jede Abfrage filtert
später nach dem Team des eingeloggten Nutzers.
- SQLite für v1, aber portabel halten (kein SQLite-spezifischer Typ), damit ein
späterer Umstieg auf PostgreSQL möglich bleibt.
- Status-/Typ-Felder sind Strings mit Konstanten (unten je Modell), keine DB-Enums.
"""
from datetime import date, datetime, timedelta, timezone
from . import db
def _now():
"""Zeitzonenbewusster UTC-Zeitstempel (portabel, ohne SQLite-Defaults)."""
return datetime.now(timezone.utc)
# =============================================================================
# team — der Mandant. Das CheckPoint-Team ist das erste von perspektivisch mehreren.
# =============================================================================
class Team(db.Model):
__tablename__ = "team"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
# Kurzes, eindeutiges Kürzel für den teambezogenen Login (z. B. "checkpoint").
slug = db.Column(db.String(40), nullable=False, unique=True, index=True)
# Brand-Tokens (werden pro Team in den <head> injiziert, Session 4).
brand_primary = db.Column(db.String(32), nullable=False, default="#21a8ad")
brand_primary_strong = db.Column(db.String(32), nullable=False, default="#147f84")
brand_accent = db.Column(db.String(32), nullable=False, default="#ef1823")
brand_accent_strong = db.Column(db.String(32), nullable=False, default="#bd0f18")
brand_font = db.Column(
db.String(200),
nullable=False,
default='"DM Sans", Arial, Helvetica, sans-serif',
)
logo_pfad = db.Column(db.String(255), nullable=True)
# Beziehungen (alle Kinder gehören zu genau diesem Team).
users = db.relationship("User", back_populates="team", cascade="all, delete-orphan")
planungszeitraeume = db.relationship(
"Planungszeitraum", back_populates="team", cascade="all, delete-orphan"
)
kanaele = db.relationship(
"Kanal", back_populates="team", cascade="all, delete-orphan"
)
def __repr__(self):
return f"<Team {self.id} {self.name!r}>"
# =============================================================================
# user — Admin oder Ehrenamtliche:r. Keine Selbstregistrierung (Admin legt an).
# =============================================================================
class User(db.Model):
__tablename__ = "user"
# Nutzername eindeutig pro Team, nicht global (Mandantenfähigkeit).
__table_args__ = (
db.UniqueConstraint("team_id", "nutzername", name="uq_user_team_nutzername"),
)
ROLLE_ADMIN = "admin"
ROLLE_EHRENAMT = "ehrenamt"
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
)
rolle = db.Column(db.String(16), nullable=False, default=ROLLE_EHRENAMT)
nutzername = db.Column(db.String(80), nullable=False)
passwort_hash = db.Column(db.String(255), nullable=False)
anzeigename = db.Column(db.String(120), nullable=False)
profilbild_pfad = db.Column(db.String(255), nullable=True)
# Optionale freiwillige Angaben (Platzhalter, Session 12 erweitert die Struktur).
freiwillige_angaben = db.Column(db.Text, nullable=True)
team = db.relationship("Team", back_populates="users")
verfuegbarkeiten = db.relationship(
"Verfuegbarkeit", back_populates="user", cascade="all, delete-orphan"
)
zuteilungen = db.relationship(
"Zuteilung", back_populates="user", cascade="all, delete-orphan"
)
@property
def ist_admin(self):
return self.rolle == self.ROLLE_ADMIN
def __repr__(self):
return f"<User {self.id} {self.nutzername!r} ({self.rolle})>"
# =============================================================================
# planungszeitraum — Block von mind. 2 Monaten. Enthält die kuratierten Einsätze.
# =============================================================================
class Planungszeitraum(db.Model):
__tablename__ = "planungszeitraum"
STATUS_IN_PLANUNG = "in_planung"
STATUS_VEROEFFENTLICHT = "veroeffentlicht"
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
)
name = db.Column(db.String(120), nullable=False)
start_datum = db.Column(db.Date, nullable=False)
end_datum = db.Column(db.Date, nullable=False)
status = db.Column(db.String(20), nullable=False, default=STATUS_IN_PLANUNG)
team = db.relationship("Team", back_populates="planungszeitraeume")
einsaetze = db.relationship(
"Einsatz",
back_populates="planungszeitraum",
cascade="all, delete-orphan",
order_by="Einsatz.datum, Einsatz.start_zeit",
)
@property
def ist_veroeffentlicht(self):
return self.status == self.STATUS_VEROEFFENTLICHT
def __repr__(self):
return f"<Planungszeitraum {self.id} {self.name!r} ({self.status})>"
# =============================================================================
# einsatz — gehört zu einem Planungszeitraum. Braucht 2 Haupt- + 1 Springerplatz.
# =============================================================================
class Einsatz(db.Model):
__tablename__ = "einsatz"
# Platzstruktur ist fix (CLAUDE.md): 2 Haupt + 1 Springer. Belegung über Zuteilungen.
HAUPT_PLAETZE = 2
SPRINGER_PLAETZE = 1
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
)
# Optional: ein Einsatz kann zu einem Planungszeitraum gehören ODER ein
# eigenständiger Einzeltermin sein (planungszeitraum_id = NULL).
planungszeitraum_id = db.Column(
db.Integer, db.ForeignKey("planungszeitraum.id"), nullable=True, index=True
)
datum = db.Column(db.Date, nullable=False)
start_zeit = db.Column(db.Time, nullable=False)
# Erwartete Dauer in Minuten (30er-Schritte). Optional: leer = offenes Ende.
dauer_minuten = db.Column(db.Integer, nullable=True)
art = db.Column(db.String(80), nullable=False) # z. B. Tour, Party, Sonderveranstaltung
ort = db.Column(db.String(120), nullable=True) # z. B. Altstadt
unterbesetzt = db.Column(db.Boolean, nullable=False, default=False)
planungszeitraum = db.relationship("Planungszeitraum", back_populates="einsaetze")
verfuegbarkeiten = db.relationship(
"Verfuegbarkeit", back_populates="einsatz", cascade="all, delete-orphan"
)
zuteilungen = db.relationship(
"Zuteilung", back_populates="einsatz", cascade="all, delete-orphan"
)
@property
def end_zeit(self):
"""Aus Start + erwarteter Dauer abgeleitet. None, wenn keine Dauer gesetzt."""
if self.start_zeit is None or not self.dauer_minuten:
return None
basis = datetime.combine(date.min, self.start_zeit)
return (basis + timedelta(minutes=self.dauer_minuten)).time()
@property
def dauer_text(self):
"""Menschliche Dauer, z. B. 'ca. 1,5 Std' oder 'ca. 30 Min'. None ohne Dauer."""
if not self.dauer_minuten:
return None
stunden, minuten = divmod(self.dauer_minuten, 60)
if stunden and minuten == 30:
return f"ca. {stunden},5 Std"
if stunden:
return f"ca. {stunden} Std"
return f"ca. {minuten} Min"
def __repr__(self):
return f"<Einsatz {self.id} {self.art!r} {self.datum}>"
# =============================================================================
# verfuegbarkeit — (user × einsatz) → kann | kann_nicht.
# =============================================================================
class Verfuegbarkeit(db.Model):
__tablename__ = "verfuegbarkeit"
__table_args__ = (
db.UniqueConstraint("user_id", "einsatz_id", name="uq_verfuegbarkeit_user_einsatz"),
)
WERT_KANN = "kann"
WERT_KANN_NICHT = "kann_nicht"
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
einsatz_id = db.Column(
db.Integer, db.ForeignKey("einsatz.id"), nullable=False, index=True
)
wert = db.Column(db.String(16), nullable=False)
user = db.relationship("User", back_populates="verfuegbarkeiten")
einsatz = db.relationship("Einsatz", back_populates="verfuegbarkeiten")
def __repr__(self):
return f"<Verfuegbarkeit u{self.user_id} e{self.einsatz_id} {self.wert}>"
# =============================================================================
# zuteilung — (user × einsatz) mit platztyp (haupt|springer) und status.
# =============================================================================
class Zuteilung(db.Model):
__tablename__ = "zuteilung"
PLATZ_HAUPT = "haupt"
PLATZ_SPRINGER = "springer"
STATUS_ZUGETEILT = "zugeteilt"
STATUS_ABGESAGT = "abgesagt"
STATUS_OFFEN = "offen"
STATUS_UEBERNOMMEN = "uebernommen"
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
)
einsatz_id = db.Column(
db.Integer, db.ForeignKey("einsatz.id"), nullable=False, index=True
)
# Bei offenem Platz kann (noch) keine Person zugeordnet sein.
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True, index=True)
platztyp = db.Column(db.String(16), nullable=False)
status = db.Column(db.String(16), nullable=False, default=STATUS_ZUGETEILT)
einsatz = db.relationship("Einsatz", back_populates="zuteilungen")
user = db.relationship("User", back_populates="zuteilungen")
def __repr__(self):
return f"<Zuteilung e{self.einsatz_id} u{self.user_id} {self.platztyp}/{self.status}>"
# =============================================================================
# dokument — Datei (PDF/Word/Bild) mit Freigabe-Workflow.
# =============================================================================
class Dokument(db.Model):
__tablename__ = "dokument"
STATUS_WARTET = "wartet_auf_freigabe"
STATUS_FREIGEGEBEN = "freigegeben"
STATUS_ABGELEHNT = "abgelehnt"
ABLAGE_AKTUELL = "aktuell"
ABLAGE_ARCHIV = "archiv"
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
)
uploader_id = db.Column(
db.Integer, db.ForeignKey("user.id"), nullable=False, index=True
)
titel = db.Column(db.String(200), nullable=False) # Pflicht
datei_pfad = db.Column(db.String(255), nullable=False)
status = db.Column(db.String(24), nullable=False, default=STATUS_WARTET)
ablage = db.Column(db.String(16), nullable=False, default=ABLAGE_AKTUELL)
ablehnungsgrund = db.Column(db.Text, nullable=True)
erstellt_am = db.Column(db.DateTime(timezone=True), nullable=False, default=_now)
uploader = db.relationship("User")
def __repr__(self):
return f"<Dokument {self.id} {self.titel!r} ({self.status})>"
# =============================================================================
# kanal — pro Team zwei feste: ankuendigungen (nur Admin postet), team (alle).
# =============================================================================
class Kanal(db.Model):
__tablename__ = "kanal"
__table_args__ = (
db.UniqueConstraint("team_id", "art", name="uq_kanal_team_art"),
)
ART_ANKUENDIGUNGEN = "ankuendigungen"
ART_TEAM = "team"
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
)
art = db.Column(db.String(20), nullable=False)
team = db.relationship("Team", back_populates="kanaele")
nachrichten = db.relationship(
"Nachricht",
back_populates="kanal",
cascade="all, delete-orphan",
order_by="Nachricht.erstellt_am",
)
@property
def nur_admin_darf_posten(self):
return self.art == self.ART_ANKUENDIGUNGEN
def __repr__(self):
return f"<Kanal {self.id} {self.art!r} team{self.team_id}>"
# =============================================================================
# nachricht — Kanal, Autor, Text, Zeitstempel. Nur Text.
# =============================================================================
class Nachricht(db.Model):
__tablename__ = "nachricht"
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
)
kanal_id = db.Column(db.Integer, db.ForeignKey("kanal.id"), nullable=False, index=True)
autor_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
text = db.Column(db.Text, nullable=False)
erstellt_am = db.Column(db.DateTime(timezone=True), nullable=False, default=_now)
kanal = db.relationship("Kanal", back_populates="nachrichten")
autor = db.relationship("User")
def __repr__(self):
return f"<Nachricht {self.id} kanal{self.kanal_id} u{self.autor_id}>"
# =============================================================================
# benachrichtigung — In-App-Hinweis pro Empfänger (v1: kein Push/E-Mail).
# Ausgelöst von: neuer Dienst, offener Dienst, neue Nachricht.
# =============================================================================
class Benachrichtigung(db.Model):
__tablename__ = "benachrichtigung"
TYP_DIENST = "dienst" # neue/eigene Zuteilung
TYP_OFFEN = "offener_dienst" # ein Platz ist frei geworden
TYP_NACHRICHT = "nachricht" # neue Chat-Nachricht
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
)
# Empfänger der Benachrichtigung.
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
typ = db.Column(db.String(20), nullable=False)
text = db.Column(db.String(255), nullable=False)
url = db.Column(db.String(255), nullable=True) # Ziel beim Antippen (optional)
gelesen = db.Column(db.Boolean, nullable=False, default=False, index=True)
erstellt_am = db.Column(db.DateTime(timezone=True), nullable=False, default=_now)
def __repr__(self):
return f"<Benachrichtigung {self.id} u{self.user_id} {self.typ} gelesen={self.gelesen}>"

40
app/notifications.py Normal file
View file

@ -0,0 +1,40 @@
"""In-App-Benachrichtigungen erzeugen (v1: kein Push/E-Mail, siehe CLAUDE.md).
Kleine Helfer, die Benachrichtigungs-Datensätze anlegen. Sie committen NICHT
das übernimmt der aufrufende Request, damit die Auslöser-Aktion und ihre Hinweise
in einer Transaktion landen. Immer team-gescoped.
"""
from . import db
from .models import Benachrichtigung, User
from .team_scope import get_active_team_id, team_query
def benachrichtigen(user_id, text, url=None, typ=Benachrichtigung.TYP_DIENST):
"""Einen Hinweis an genau eine Person anlegen."""
db.session.add(
Benachrichtigung(
team_id=get_active_team_id(),
user_id=user_id,
typ=typ,
text=text,
url=url,
)
)
def benachrichtigen_team(text, url=None, typ=Benachrichtigung.TYP_NACHRICHT, ausser_user_ids=()):
"""Einen Hinweis an alle Team-Mitglieder anlegen (optional einige auslassen)."""
ausgenommen = set(ausser_user_ids)
for user in team_query(User).all():
if user.id in ausgenommen:
continue
db.session.add(
Benachrichtigung(
team_id=user.team_id,
user_id=user.id,
typ=typ,
text=text,
url=url,
)
)

111
app/routes/__init__.py Normal file
View file

@ -0,0 +1,111 @@
"""Start-Blueprint.
Hält die Startseite und vorerst schlanke Platzhalter für die übrigen
Navigationsziele (Termine, Team, Profil, Benachrichtigungen). Diese Platzhalter
werden in späteren Sessions durch die echten Ansichten/Blueprints ersetzt
(planung, dienste, dokumente, chat, profil). Interne App: alles login_required.
"""
from flask import Blueprint, current_app, redirect, render_template, request, url_for
from .. import db
from ..auth import current_user, login_required
from ..models import Benachrichtigung
from ..team_scope import team_query
main_bp = Blueprint("main", __name__)
# Theme-Modi (serverseitig, ohne JS): "system" folgt der Systemeinstellung,
# "hell"/"dunkel" überschreiben sie fest. Gespeichert in einem Cookie.
THEME_MODI = ("system", "hell", "dunkel")
@main_bp.app_context_processor
def inject_theme():
"""Aktiven Theme-Modus aus dem Cookie in allen Templates verfügbar machen."""
modus = request.cookies.get("theme", "system")
return {"current_theme": modus if modus in THEME_MODI else "system"}
@main_bp.app_context_processor
def inject_shell_base():
"""Wählt die Layout-Shell je Rolle: Admins bekommen die Sidebar-Shell, alle
anderen die mobile Bottom-Nav-Shell. Templates: {% extends shell_base %}."""
user = current_user()
basis = "base_admin.html" if (user and user.ist_admin) else "base_user.html"
return {"shell_base": basis}
@main_bp.route("/")
@login_required
def index():
return render_template("index.html", user=current_user())
@main_bp.route("/theme", methods=("POST",))
def theme_setzen():
"""Theme-Modus setzen (Cookie) und zur Ausgangsseite zurück. Kein Login nötig,
damit die Einstellung überall greift; CSRF-geschützt wie alle POST-Formulare."""
modus = request.form.get("modus", "system")
if modus not in THEME_MODI:
modus = "system"
# Nur lokale Pfade als Rücksprungziel zulassen (kein Open-Redirect).
ziel = request.form.get("next", "")
if not ziel.startswith("/"):
ziel = url_for("main.index")
antwort = redirect(ziel)
if modus == "system":
antwort.delete_cookie("theme")
else:
antwort.set_cookie(
"theme", modus, max_age=60 * 60 * 24 * 365,
samesite="Lax", httponly=True,
secure=current_app.config.get("SESSION_COOKIE_SECURE", False),
)
return antwort
# --- Platzhalter, damit die Bottom-Nav schon vollständig funktioniert ---------
# „Termine" zeigt auf planung.verfuegbarkeit (Session 6).
@main_bp.route("/team")
@login_required
def team():
return render_template("team.html")
@main_bp.app_context_processor
def inject_benachrichtigungen_zahl():
"""Anzahl ungelesener Benachrichtigungen für die Glocke (0, wenn nicht eingeloggt)."""
user = current_user()
if user is None:
return {"benachrichtigungen_ungelesen": 0}
anzahl = (
team_query(Benachrichtigung)
.filter_by(user_id=user.id, gelesen=False)
.count()
)
return {"benachrichtigungen_ungelesen": anzahl}
@main_bp.route("/benachrichtigungen")
@login_required
def benachrichtigungen():
eintraege = (
team_query(Benachrichtigung)
.filter_by(user_id=current_user().id)
.order_by(Benachrichtigung.erstellt_am.desc())
.all()
)
return render_template("benachrichtigungen.html", eintraege=eintraege)
@main_bp.route("/benachrichtigungen/gelesen", methods=("POST",))
@login_required
def benachrichtigungen_gelesen():
team_query(Benachrichtigung).filter_by(
user_id=current_user().id, gelesen=False
).update({"gelesen": True}, synchronize_session=False)
db.session.commit()
return redirect(url_for("main.benachrichtigungen"))

135
app/routes/chat.py Normal file
View file

@ -0,0 +1,135 @@
"""Chat: zwei feste Kanäle pro Team (CLAUDE.md).
- ankuendigungen: nur Admin postet, alle lesen.
- team: alle posten.
Nur Text. Nachrichten chronologisch. Neue Nachrichten holt der Client per
kleinem fetch-Polling (JSON-Endpunkt /chat/<art>/seit/<id>) das ist die einzige
in v1 erlaubte JavaScript-Ausnahme. Kein WebSocket, kein Framework. Alles
team-gescoped (team_query) und CSRF-geschützt.
"""
from datetime import timezone
from zoneinfo import ZoneInfo
from flask import (
Blueprint,
abort,
flash,
jsonify,
redirect,
render_template,
request,
url_for,
)
from .. import db
from ..auth import current_user, login_required
from ..models import Benachrichtigung, Kanal, Nachricht
from ..notifications import benachrichtigen_team
from ..team_scope import get_active_team_id, team_query
bp = Blueprint("chat", __name__, url_prefix="/chat")
LABELS = {Kanal.ART_ANKUENDIGUNGEN: "Ankündigungen", Kanal.ART_TEAM: "Team"}
MAX_LAENGE = 2000
_BERLIN = ZoneInfo("Europe/Berlin")
def _zeit(dt):
"""erstellt_am (in UTC, aus SQLite meist naiv) → 'DD.MM. HH:MM' in Berliner Zeit."""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(_BERLIN).strftime("%d.%m. %H:%M")
def _kanal_holen(art):
if art not in (Kanal.ART_ANKUENDIGUNGEN, Kanal.ART_TEAM):
abort(404)
kanal = team_query(Kanal).filter_by(art=art).first()
if kanal is None:
abort(404)
return kanal
def _serialisieren(nachricht, user_id):
return {
"id": nachricht.id,
"autor": nachricht.autor.anzeigename,
"zeit": _zeit(nachricht.erstellt_am),
"text": nachricht.text,
"eigene": nachricht.autor_id == user_id,
}
@bp.route("/<art>")
@login_required
def kanal(art):
kanal = _kanal_holen(art)
nachrichten = (
team_query(Nachricht)
.filter_by(kanal_id=kanal.id)
.order_by(Nachricht.erstellt_am)
.all()
)
darf_posten = (not kanal.nur_admin_darf_posten) or current_user().ist_admin
letzte_id = nachrichten[-1].id if nachrichten else 0
return render_template(
"chat.html",
kanal=kanal,
label=LABELS[kanal.art],
nachrichten=nachrichten,
zeit=_zeit,
darf_posten=darf_posten,
letzte_id=letzte_id,
)
@bp.route("/<art>", methods=("POST",))
@login_required
def posten(art):
kanal = _kanal_holen(art)
# Ankündigungen: nur Admin darf posten (alle dürfen lesen).
if kanal.nur_admin_darf_posten and not current_user().ist_admin:
abort(403)
text = (request.form.get("text") or "").strip()
if not text:
flash("Bitte einen Text eingeben.")
return redirect(url_for("chat.kanal", art=art))
if len(text) > MAX_LAENGE:
flash(f"Nachricht zu lang (max. {MAX_LAENGE} Zeichen).")
return redirect(url_for("chat.kanal", art=art))
db.session.add(
Nachricht(
team_id=get_active_team_id(),
kanal_id=kanal.id,
autor_id=current_user().id,
text=text,
)
)
# Alle außer dem Autor über die neue Nachricht informieren.
benachrichtigen_team(
f"Neue Nachricht in „{LABELS[kanal.art]}“ von {current_user().anzeigename}",
url=url_for("chat.kanal", art=art),
typ=Benachrichtigung.TYP_NACHRICHT,
ausser_user_ids={current_user().id},
)
db.session.commit()
return redirect(url_for("chat.kanal", art=art))
@bp.route("/<art>/seit/<int:seit>")
@login_required
def neu(art, seit):
"""JSON: alle Nachrichten dieses Kanals mit id > seit (für das Polling)."""
kanal = _kanal_holen(art)
user_id = current_user().id
nachrichten = (
team_query(Nachricht)
.filter(Nachricht.kanal_id == kanal.id, Nachricht.id > seit)
.order_by(Nachricht.erstellt_am)
.all()
)
return jsonify(nachrichten=[_serialisieren(n, user_id) for n in nachrichten])

429
app/routes/dienste.py Normal file
View file

@ -0,0 +1,429 @@
"""Dienstplan bauen (Admin).
Manuelle Zuteilung: pro Einsatz 2 Haupt + 1 Springer. Keine automatische
Verteilung die App zeigt nur die verfügbaren Personen samt Fairness-Hilfe
(Anzahl bisheriger Einsätze, letzter Einsatz) an; auswählen tut der Admin.
Alles admin-geschützt und team-gescoped.
"""
from flask import (
Blueprint,
abort,
flash,
redirect,
render_template,
request,
url_for,
)
from sqlalchemy import func, or_
from .. import db
from ..auth import admin_required, current_user, login_required
from ..models import Benachrichtigung, Einsatz, Planungszeitraum, User, Verfuegbarkeit, Zuteilung
from ..notifications import benachrichtigen, benachrichtigen_team
from ..team_scope import get_active_team_id, team_query
bp = Blueprint("dienste", __name__, url_prefix="/dienste")
# Status, die einen Platz tatsächlich belegen.
AKTIVE_STATUS = (Zuteilung.STATUS_ZUGETEILT, Zuteilung.STATUS_UEBERNOMMEN)
# -----------------------------------------------------------------------------
# Hilfen
# -----------------------------------------------------------------------------
def _einsatz_holen(einsatz_id):
einsatz = team_query(Einsatz).filter_by(id=einsatz_id).first()
if einsatz is None:
abort(404)
return einsatz
def _belegung(einsatz):
"""Aktive Zuteilungen des Einsatzes, getrennt nach Platztyp."""
haupt, springer = [], []
for z in einsatz.zuteilungen:
if z.status not in AKTIVE_STATUS:
continue
if z.platztyp == Zuteilung.PLATZ_HAUPT:
haupt.append(z)
elif z.platztyp == Zuteilung.PLATZ_SPRINGER:
springer.append(z)
return haupt, springer
def _fairness_map():
"""{user_id: (anzahl_einsaetze, letztes_datum)} über das ganze Team.
Grundlage: aktive Zuteilungen. Hilft dem Admin, fair zu verteilen.
"""
rows = (
db.session.query(
Zuteilung.user_id,
func.count(Zuteilung.id),
func.max(Einsatz.datum),
)
.join(Einsatz, Zuteilung.einsatz_id == Einsatz.id)
.filter(Zuteilung.team_id == get_active_team_id())
.filter(Zuteilung.user_id.isnot(None))
.filter(Zuteilung.status.in_(AKTIVE_STATUS))
.group_by(Zuteilung.user_id)
.all()
)
return {uid: (anzahl, letztes) for uid, anzahl, letztes in rows}
def _kandidaten(einsatz, belegt_ids):
"""Personen mit „Kann" für diesen Einsatz, die noch nicht zugeteilt sind."""
q = (
team_query(User)
.join(Verfuegbarkeit, Verfuegbarkeit.user_id == User.id)
.filter(Verfuegbarkeit.einsatz_id == einsatz.id)
.filter(Verfuegbarkeit.wert == Verfuegbarkeit.WERT_KANN)
)
if belegt_ids:
q = q.filter(User.id.notin_(belegt_ids))
return q.order_by(User.anzeigename).all()
# -----------------------------------------------------------------------------
# Übersichten
# -----------------------------------------------------------------------------
@bp.route("/")
@admin_required
def liste():
zeitraeume = team_query(Planungszeitraum).order_by(
Planungszeitraum.start_datum.desc()
).all()
einzeltermine = (
team_query(Einsatz)
.filter_by(planungszeitraum_id=None)
.order_by(Einsatz.datum, Einsatz.start_zeit)
.all()
)
return render_template(
"dienste_liste.html", zeitraeume=zeitraeume, einzeltermine=einzeltermine
)
@bp.route("/zeitraum/<int:zeitraum_id>/veroeffentlichen", methods=("POST",))
@admin_required
def veroeffentlichen(zeitraum_id):
zr = team_query(Planungszeitraum).filter_by(id=zeitraum_id).first()
if zr is None:
abort(404)
if zr.ist_veroeffentlicht:
flash("Der Zeitraum ist bereits veröffentlicht.")
else:
zr.status = Planungszeitraum.STATUS_VEROEFFENTLICHT
# Jede zugeteilte Person über ihre Dienste informieren.
zuteilungen = (
team_query(Zuteilung)
.join(Einsatz, Zuteilung.einsatz_id == Einsatz.id)
.filter(Einsatz.planungszeitraum_id == zr.id)
.filter(Zuteilung.user_id.isnot(None))
.filter(Zuteilung.status.in_(AKTIVE_STATUS))
.all()
)
for z in zuteilungen:
rolle = "Haupt" if z.platztyp == Zuteilung.PLATZ_HAUPT else "Springer"
benachrichtigen(
z.user_id,
f"Neuer Dienst: {z.einsatz.art} am {z.einsatz.datum.strftime('%d.%m.%Y')} ({rolle})",
url=url_for("dienste.meine_termine"),
typ=Benachrichtigung.TYP_DIENST,
)
db.session.commit()
flash(f"{zr.name}“ veröffentlicht. Alle sehen jetzt ihren Dienstplan.")
return redirect(url_for("dienste.zeitraum", zeitraum_id=zr.id))
@bp.route("/zeitraum/<int:zeitraum_id>")
@admin_required
def zeitraum(zeitraum_id):
zr = team_query(Planungszeitraum).filter_by(id=zeitraum_id).first()
if zr is None:
abort(404)
# Besetzungsstand je Einsatz für die Übersicht.
stand = {}
for e in zr.einsaetze:
haupt, springer = _belegung(e)
stand[e.id] = (len(haupt), len(springer))
return render_template("dienste_zeitraum.html", zeitraum=zr, stand=stand)
# -----------------------------------------------------------------------------
# Bau-Ansicht pro Einsatz
# -----------------------------------------------------------------------------
@bp.route("/einsatz/<int:einsatz_id>")
@admin_required
def einsatz(einsatz_id):
e = _einsatz_holen(einsatz_id)
haupt, springer = _belegung(e)
belegt_ids = [z.user_id for z in haupt + springer]
kandidaten = _kandidaten(e, belegt_ids)
fairness = _fairness_map()
return render_template(
"dienste_einsatz.html",
einsatz=e,
haupt=haupt,
springer=springer,
haupt_frei=Einsatz.HAUPT_PLAETZE - len(haupt),
springer_frei=Einsatz.SPRINGER_PLAETZE - len(springer),
kandidaten=kandidaten,
fairness=fairness,
)
@bp.route("/einsatz/<int:einsatz_id>/zuteilen", methods=("POST",))
@admin_required
def zuteilen(einsatz_id):
e = _einsatz_holen(einsatz_id)
platztyp = request.form.get("platztyp")
user_id = request.form.get("user_id", type=int)
if platztyp not in (Zuteilung.PLATZ_HAUPT, Zuteilung.PLATZ_SPRINGER):
flash("Ungültiger Platztyp.")
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
person = team_query(User).filter_by(id=user_id).first()
if person is None:
abort(404)
# Person muss „Kann" gemeldet haben.
kann = team_query(Verfuegbarkeit).filter_by(
user_id=person.id, einsatz_id=e.id, wert=Verfuegbarkeit.WERT_KANN
).first()
if kann is None:
flash(f"{person.anzeigename} hat für diesen Einsatz kein „Kann“ gemeldet.")
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
haupt, springer = _belegung(e)
belegt_ids = [z.user_id for z in haupt + springer]
if person.id in belegt_ids:
flash(f"{person.anzeigename} ist diesem Einsatz schon zugeteilt.")
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
if platztyp == Zuteilung.PLATZ_HAUPT and len(haupt) >= Einsatz.HAUPT_PLAETZE:
flash("Die Hauptplätze sind bereits voll.")
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
if platztyp == Zuteilung.PLATZ_SPRINGER and len(springer) >= Einsatz.SPRINGER_PLAETZE:
flash("Der Springerplatz ist bereits besetzt.")
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
db.session.add(
Zuteilung(
team_id=get_active_team_id(),
einsatz_id=e.id,
user_id=person.id,
platztyp=platztyp,
status=Zuteilung.STATUS_ZUGETEILT,
)
)
db.session.commit()
flash(f"{person.anzeigename} zugeteilt.")
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
@bp.route("/einsatz/<int:einsatz_id>/entfernen", methods=("POST",))
@admin_required
def entfernen(einsatz_id):
e = _einsatz_holen(einsatz_id)
zuteilung_id = request.form.get("zuteilung_id", type=int)
z = team_query(Zuteilung).filter_by(id=zuteilung_id, einsatz_id=e.id).first()
if z is None:
abort(404)
db.session.delete(z)
db.session.commit()
flash("Zuteilung entfernt.")
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
@bp.route("/einsatz/<int:einsatz_id>/unterbesetzt", methods=("POST",))
@admin_required
def unterbesetzt(einsatz_id):
e = _einsatz_holen(einsatz_id)
e.unterbesetzt = not e.unterbesetzt
db.session.commit()
flash("Als unterbesetzt markiert." if e.unterbesetzt else "Markierung entfernt.")
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
# -----------------------------------------------------------------------------
# Sichtbarkeitsfilter: nur veröffentlichte Zeiträume oder Einzeltermine
# -----------------------------------------------------------------------------
def _sichtbar_filter(query):
return query.filter(
or_(
Einsatz.planungszeitraum_id.is_(None),
Planungszeitraum.status == Planungszeitraum.STATUS_VEROEFFENTLICHT,
)
)
# -----------------------------------------------------------------------------
# Meine Termine (alle eingeloggten Nutzer) eigene Zuteilungen + offene Dienste
# -----------------------------------------------------------------------------
@bp.route("/meine-termine")
@login_required
def meine_termine():
rows = _sichtbar_filter(
db.session.query(Zuteilung, Einsatz)
.join(Einsatz, Zuteilung.einsatz_id == Einsatz.id)
.outerjoin(Planungszeitraum, Einsatz.planungszeitraum_id == Planungszeitraum.id)
.filter(Zuteilung.team_id == get_active_team_id())
.filter(Zuteilung.user_id == current_user().id)
.filter(Zuteilung.status.in_(AKTIVE_STATUS))
).order_by(Einsatz.datum, Einsatz.start_zeit).all()
# Offene Dienste (nach einer Absage) für alle zum Übernehmen.
offene = _sichtbar_filter(
db.session.query(Zuteilung, Einsatz)
.join(Einsatz, Zuteilung.einsatz_id == Einsatz.id)
.outerjoin(Planungszeitraum, Einsatz.planungszeitraum_id == Planungszeitraum.id)
.filter(Zuteilung.team_id == get_active_team_id())
.filter(Zuteilung.status == Zuteilung.STATUS_OFFEN)
).order_by(Einsatz.datum, Einsatz.start_zeit).all()
# Einsätze, auf denen man selbst schon aktiv ist dort kein zweiter Platz.
eigene_einsatz_ids = {e.id for _, e in rows}
# Gibt es gerade etwas zu melden? (für den Link zur Verfügbarkeit)
offene_planung = (
team_query(Planungszeitraum)
.filter_by(status=Planungszeitraum.STATUS_IN_PLANUNG)
.first()
is not None
or team_query(Einsatz).filter_by(planungszeitraum_id=None).first() is not None
)
return render_template(
"meine_termine.html",
termine=rows,
offene=offene,
eigene_einsatz_ids=eigene_einsatz_ids,
offene_planung=offene_planung,
)
# -----------------------------------------------------------------------------
# Absage & Übernahme
# -----------------------------------------------------------------------------
def _offen_anlegen(einsatz, platztyp):
db.session.add(
Zuteilung(
team_id=get_active_team_id(),
einsatz_id=einsatz.id,
user_id=None,
platztyp=platztyp,
status=Zuteilung.STATUS_OFFEN,
)
)
@bp.route("/zuteilung/<int:zuteilung_id>/absagen", methods=("POST",))
@login_required
def absagen(zuteilung_id):
z = team_query(Zuteilung).filter_by(id=zuteilung_id).first()
if z is None:
abort(404)
# Nur die eigene Zuteilung absagen.
if z.user_id != current_user().id:
abort(403)
if z.status not in AKTIVE_STATUS:
flash("Dieser Dienst ist nicht (mehr) aktiv.")
return redirect(url_for("dienste.meine_termine"))
einsatz = z.einsatz
war_haupt = z.platztyp == Zuteilung.PLATZ_HAUPT
z.status = Zuteilung.STATUS_ABGESAGT
if war_haupt:
# Springer rückt nach, falls vorhanden.
springer = next(
(
s
for s in einsatz.zuteilungen
if s.platztyp == Zuteilung.PLATZ_SPRINGER
and s.status in AKTIVE_STATUS
and s.user_id is not None
),
None,
)
if springer is not None:
springer.platztyp = Zuteilung.PLATZ_HAUPT
springer.status = Zuteilung.STATUS_UEBERNOMMEN
_offen_anlegen(einsatz, Zuteilung.PLATZ_SPRINGER)
benachrichtigen(
springer.user_id,
f"Du bist nachgerückt: {einsatz.art} am {einsatz.datum.strftime('%d.%m.%Y')} (jetzt Haupt)",
url=url_for("dienste.meine_termine"),
typ=Benachrichtigung.TYP_DIENST,
)
else:
_offen_anlegen(einsatz, Zuteilung.PLATZ_HAUPT)
else:
# Springer sagt ab -> Springerplatz wird offen.
_offen_anlegen(einsatz, Zuteilung.PLATZ_SPRINGER)
# Offener Platz: alle zum Übernehmen einladen außer wer schon auf diesem
# Einsatz steht (z. B. der gerade nachgerückte Springer) und der absagenden Person.
schon_dabei = {
s.user_id
for s in einsatz.zuteilungen
if s.user_id is not None and s.status in AKTIVE_STATUS
}
schon_dabei.add(current_user().id)
benachrichtigen_team(
f"Offener Dienst: {einsatz.art} am {einsatz.datum.strftime('%d.%m.%Y')} jetzt übernehmen",
url=url_for("dienste.meine_termine"),
typ=Benachrichtigung.TYP_OFFEN,
ausser_user_ids=schon_dabei,
)
db.session.commit()
flash("Abgesagt. Der frei gewordene Platz ist jetzt offen.")
return redirect(url_for("dienste.meine_termine"))
@bp.route("/zuteilung/<int:zuteilung_id>/uebernehmen", methods=("POST",))
@login_required
def uebernehmen(zuteilung_id):
user = current_user()
# Vorprüfung: nicht zweimal auf denselben Einsatz.
z = team_query(Zuteilung).filter_by(id=zuteilung_id).first()
if z is None:
abort(404)
schon_dabei = team_query(Zuteilung).filter(
Zuteilung.einsatz_id == z.einsatz_id,
Zuteilung.user_id == user.id,
Zuteilung.status.in_(AKTIVE_STATUS),
).first()
if schon_dabei is not None:
flash("Du bist diesem Einsatz schon zugeteilt.")
return redirect(url_for("dienste.meine_termine"))
# Atomar gegen gleichzeitiges Übernehmen: nur, solange wirklich noch offen.
getroffen = (
Zuteilung.query.filter_by(
id=zuteilung_id,
team_id=get_active_team_id(),
status=Zuteilung.STATUS_OFFEN,
user_id=None,
).update(
{"user_id": user.id, "status": Zuteilung.STATUS_UEBERNOMMEN},
synchronize_session=False,
)
)
db.session.commit()
if getroffen == 1:
flash("Übernommen.")
else:
flash("Dieser Dienst wurde gerade schon übernommen.")
return redirect(url_for("dienste.meine_termine"))

236
app/routes/dokumente.py Normal file
View file

@ -0,0 +1,236 @@
"""Dokumente: Upload mit Freigabe-Workflow (CLAUDE.md).
Alle dürfen hochladen; der Upload startet als wartet_auf_freigabe und ist nur
für Admin + Uploader sichtbar. Der Admin gibt frei oder lehnt ab (optional mit
Grund); Admin-Uploads sind direkt freigegeben. Freigegebene Dokumente sehen alle
im Team, getrennt in Aktuell und Archiv (Verschieben macht der Admin).
Dateien liegen außerhalb von static/ (instance/uploads/) und werden nur über die
geschützte Route /dokumente/<id>/datei ausgeliefert. Reine POST-Formulare, kein JS.
Alles team-gescoped (team_query) und CSRF-geschützt.
"""
import os
import uuid
from flask import (
Blueprint,
abort,
current_app,
flash,
redirect,
render_template,
request,
send_from_directory,
url_for,
)
from .. import db
from ..auth import admin_required, current_user, login_required
from ..models import Dokument
from ..team_scope import get_active_team_id, team_query
bp = Blueprint("dokumente", __name__, url_prefix="/dokumente")
# Endungs-Whitelist (CLAUDE.md: PDF/Word/Bild) + Tabellen.
ERLAUBTE_ENDUNGEN = {
"pdf",
"doc",
"docx",
"xls",
"xlsx",
"png",
"jpg",
"jpeg",
"gif",
"webp",
}
def _endung(dateiname):
"""Kleingeschriebene Dateiendung ohne Punkt (oder '' ohne Endung)."""
if not dateiname or "." not in dateiname:
return ""
return dateiname.rsplit(".", 1)[1].lower()
def _darf_sehen(dok, user):
"""Sichtbarkeit eines einzelnen Dokuments für den aktuellen Nutzer."""
if dok.status == Dokument.STATUS_FREIGEGEBEN:
return True
# Wartend oder abgelehnt: nur Admin oder der Uploader.
return user.ist_admin or dok.uploader_id == user.id
# -----------------------------------------------------------------------------
# Liste (alle): freigegeben in Aktuell/Archiv + eigene wartende Uploads
# -----------------------------------------------------------------------------
@bp.route("/")
@login_required
def liste():
user = current_user()
freigegeben = (
team_query(Dokument)
.filter_by(status=Dokument.STATUS_FREIGEGEBEN)
.order_by(Dokument.erstellt_am.desc())
.all()
)
aktuell = [d for d in freigegeben if d.ablage == Dokument.ABLAGE_AKTUELL]
archiv = [d for d in freigegeben if d.ablage == Dokument.ABLAGE_ARCHIV]
# Eigene noch nicht freigegebene Uploads (nur für den Uploader sichtbar).
eigene_wartend = (
team_query(Dokument)
.filter(Dokument.uploader_id == user.id)
.filter(Dokument.status != Dokument.STATUS_FREIGEGEBEN)
.order_by(Dokument.erstellt_am.desc())
.all()
)
offene_freigaben = 0
if user.ist_admin:
offene_freigaben = (
team_query(Dokument).filter_by(status=Dokument.STATUS_WARTET).count()
)
return render_template(
"dokumente.html",
aktuell=aktuell,
archiv=archiv,
eigene_wartend=eigene_wartend,
offene_freigaben=offene_freigaben,
)
# -----------------------------------------------------------------------------
# Upload (alle): Pflicht-Titel + Datei, serverseitige Typ-/Größenprüfung
# -----------------------------------------------------------------------------
@bp.route("/upload", methods=("POST",))
@login_required
def upload():
user = current_user()
titel = (request.form.get("titel") or "").strip()
datei = request.files.get("datei")
if not titel:
flash("Bitte einen Titel angeben.")
return redirect(url_for("dokumente.liste"))
if datei is None or datei.filename == "":
flash("Bitte eine Datei auswählen.")
return redirect(url_for("dokumente.liste"))
endung = _endung(datei.filename)
if endung not in ERLAUBTE_ENDUNGEN:
flash("Dieser Dateityp ist nicht erlaubt. Erlaubt: PDF, Word, Excel, Bilder.")
return redirect(url_for("dokumente.liste"))
# Kollisionsfreier, sicherer Speichername. Der Originalname dient nur als
# Titel-Hilfe; gespeichert wird unter einem zufälligen Namen.
speichername = f"{uuid.uuid4().hex}.{endung}"
datei.save(os.path.join(current_app.config["UPLOAD_FOLDER"], speichername))
# Admin-Uploads sind direkt freigegeben, sonst Warteschlange.
status = Dokument.STATUS_FREIGEGEBEN if user.ist_admin else Dokument.STATUS_WARTET
dok = Dokument(
team_id=get_active_team_id(),
uploader_id=user.id,
titel=titel,
datei_pfad=speichername,
status=status,
)
db.session.add(dok)
db.session.commit()
if user.ist_admin:
flash("Dokument hochgeladen und freigegeben.")
else:
flash("Dokument hochgeladen. Es ist sichtbar, sobald der Admin es freigibt.")
return redirect(url_for("dokumente.liste"))
# -----------------------------------------------------------------------------
# Datei ausliefern (geschützt, mit Sichtbarkeitsprüfung)
# -----------------------------------------------------------------------------
@bp.route("/<int:dok_id>/datei")
@login_required
def datei(dok_id):
dok = team_query(Dokument).filter_by(id=dok_id).first()
if dok is None:
abort(404)
if not _darf_sehen(dok, current_user()):
abort(403)
return send_from_directory(
current_app.config["UPLOAD_FOLDER"],
dok.datei_pfad,
download_name=f"{dok.titel}.{_endung(dok.datei_pfad)}",
)
# -----------------------------------------------------------------------------
# Freigabe-Warteschlange (Admin)
# -----------------------------------------------------------------------------
@bp.route("/freigaben")
@admin_required
def freigaben():
wartend = (
team_query(Dokument)
.filter_by(status=Dokument.STATUS_WARTET)
.order_by(Dokument.erstellt_am.asc())
.all()
)
return render_template("dokumente_freigaben.html", wartend=wartend)
@bp.route("/<int:dok_id>/freigeben", methods=("POST",))
@admin_required
def freigeben(dok_id):
dok = team_query(Dokument).filter_by(id=dok_id).first()
if dok is None:
abort(404)
dok.status = Dokument.STATUS_FREIGEGEBEN
dok.ablehnungsgrund = None
db.session.commit()
flash("Dokument freigegeben.")
return redirect(url_for("dokumente.freigaben"))
@bp.route("/<int:dok_id>/ablehnen", methods=("POST",))
@admin_required
def ablehnen(dok_id):
dok = team_query(Dokument).filter_by(id=dok_id).first()
if dok is None:
abort(404)
dok.status = Dokument.STATUS_ABGELEHNT
dok.ablehnungsgrund = (request.form.get("grund") or "").strip() or None
db.session.commit()
flash("Dokument abgelehnt.")
return redirect(url_for("dokumente.freigaben"))
@bp.route("/<int:dok_id>/verschieben", methods=("POST",))
@admin_required
def verschieben(dok_id):
dok = team_query(Dokument).filter_by(id=dok_id).first()
if dok is None:
abort(404)
if dok.status != Dokument.STATUS_FREIGEGEBEN:
flash("Nur freigegebene Dokumente lassen sich verschieben.")
return redirect(url_for("dokumente.liste"))
dok.ablage = (
Dokument.ABLAGE_ARCHIV
if dok.ablage == Dokument.ABLAGE_AKTUELL
else Dokument.ABLAGE_AKTUELL
)
db.session.commit()
flash("Ins Archiv verschoben." if dok.ablage == Dokument.ABLAGE_ARCHIV else "Zurück zu Aktuell.")
return redirect(url_for("dokumente.liste"))
# -----------------------------------------------------------------------------
# Zu große Datei: freundliche Meldung statt nackter 413-Seite
# -----------------------------------------------------------------------------
@bp.app_errorhandler(413)
def datei_zu_gross(e):
flash("Die Datei ist zu groß (max. 10 MB).")
return redirect(url_for("dokumente.liste"))

339
app/routes/planung.py Normal file
View file

@ -0,0 +1,339 @@
"""Planung (Admin): Planungszeiträume anlegen und darin Einsätze pflegen.
Reine POST-Formulare, kein JavaScript. Alles admin-geschützt und team-gescoped
(team_query). Strukturelle Änderungen an Einsätzen sind nur erlaubt, solange der
Zeitraum in_planung ist ein veröffentlichter Plan bleibt unangetastet.
"""
from datetime import datetime
from flask import (
Blueprint,
abort,
flash,
redirect,
render_template,
request,
url_for,
)
from .. import db
from ..auth import admin_required, current_user, login_required
from ..models import Einsatz, Planungszeitraum, Verfuegbarkeit
from ..team_scope import get_active_team_id, team_query
bp = Blueprint("planung", __name__, url_prefix="/planung")
# Typische Einsatzarten (CLAUDE.md). art ist im Modell ein freier String,
# wir bieten hier eine kuratierte Auswahl an.
ARTEN = ["Tour", "Party", "Sonderveranstaltung"]
# -----------------------------------------------------------------------------
# Hilfen
# -----------------------------------------------------------------------------
def _parse_datum(wert):
try:
return datetime.strptime(wert, "%Y-%m-%d").date()
except (ValueError, TypeError):
return None
def _parse_zeit(wert):
try:
return datetime.strptime(wert, "%H:%M").time()
except (ValueError, TypeError):
return None
def _einsatz_gesperrt(einsatz):
"""Einsätze eines veröffentlichten Zeitraums sind gesperrt.
Einzeltermine (ohne Zeitraum) sind nie gesperrt."""
zr = einsatz.planungszeitraum
return zr is not None and zr.ist_veroeffentlicht
def _einsatz_redirect(einsatz):
"""Nach Bearbeiten/Löschen zurück zur passenden Liste."""
if einsatz.planungszeitraum_id:
return redirect(url_for("planung.detail", zeitraum_id=einsatz.planungszeitraum_id))
return redirect(url_for("planung.einzeltermine"))
def _zeitraum_holen(zeitraum_id):
"""Zeitraum des aktiven Teams oder 404."""
zr = team_query(Planungszeitraum).filter_by(id=zeitraum_id).first()
if zr is None:
abort(404)
return zr
# -----------------------------------------------------------------------------
# Planungszeiträume: Liste + Anlegen
# -----------------------------------------------------------------------------
@bp.route("/", methods=("GET", "POST"))
@admin_required
def liste():
if request.method == "POST":
name = (request.form.get("name") or "").strip()
start = _parse_datum(request.form.get("start_datum"))
ende = _parse_datum(request.form.get("end_datum"))
fehler = None
if not name:
fehler = "Bitte einen Namen angeben."
elif start is None or ende is None:
fehler = "Bitte Start- und Enddatum angeben."
elif ende <= start:
fehler = "Das Ende muss nach dem Start liegen."
if fehler:
flash(fehler)
else:
zr = Planungszeitraum(
team_id=get_active_team_id(),
name=name,
start_datum=start,
end_datum=ende,
status=Planungszeitraum.STATUS_IN_PLANUNG,
)
db.session.add(zr)
db.session.commit()
flash(f"Planungszeitraum „{name}“ angelegt.")
return redirect(url_for("planung.detail", zeitraum_id=zr.id))
zeitraeume = team_query(Planungszeitraum).order_by(
Planungszeitraum.start_datum.desc()
).all()
return render_template("planung_liste.html", zeitraeume=zeitraeume)
# -----------------------------------------------------------------------------
# Zeitraum-Detail + Einsatz anlegen
# -----------------------------------------------------------------------------
@bp.route("/<int:zeitraum_id>", methods=("GET", "POST"))
@admin_required
def detail(zeitraum_id):
zr = _zeitraum_holen(zeitraum_id)
if request.method == "POST":
if zr.ist_veroeffentlicht:
flash("Der Zeitraum ist veröffentlicht Einsätze sind gesperrt.")
return redirect(url_for("planung.detail", zeitraum_id=zr.id))
fehler, daten = _einsatz_formular_lesen()
if fehler:
flash(fehler)
else:
einsatz = Einsatz(
team_id=get_active_team_id(),
planungszeitraum_id=zr.id,
**daten,
)
db.session.add(einsatz)
db.session.commit()
flash("Einsatz angelegt.")
return redirect(url_for("planung.detail", zeitraum_id=zr.id))
return render_template(
"planung_detail.html",
zeitraum=zr,
einsaetze=zr.einsaetze,
arten=ARTEN,
)
# -----------------------------------------------------------------------------
# Einsatz bearbeiten / löschen
# -----------------------------------------------------------------------------
@bp.route("/einsatz/<int:einsatz_id>/bearbeiten", methods=("GET", "POST"))
@admin_required
def einsatz_bearbeiten(einsatz_id):
einsatz = team_query(Einsatz).filter_by(id=einsatz_id).first()
if einsatz is None:
abort(404)
if request.method == "POST":
if _einsatz_gesperrt(einsatz):
flash("Der Zeitraum ist veröffentlicht Einsätze sind gesperrt.")
return _einsatz_redirect(einsatz)
fehler, daten = _einsatz_formular_lesen()
if fehler:
flash(fehler)
else:
einsatz.datum = daten["datum"]
einsatz.start_zeit = daten["start_zeit"]
einsatz.dauer_minuten = daten["dauer_minuten"]
einsatz.art = daten["art"]
einsatz.ort = daten["ort"]
db.session.commit()
flash("Einsatz aktualisiert.")
return _einsatz_redirect(einsatz)
return render_template(
"einsatz_bearbeiten.html", zeitraum=einsatz.planungszeitraum, einsatz=einsatz, arten=ARTEN
)
@bp.route("/einsatz/<int:einsatz_id>/loeschen", methods=("POST",))
@admin_required
def einsatz_loeschen(einsatz_id):
einsatz = team_query(Einsatz).filter_by(id=einsatz_id).first()
if einsatz is None:
abort(404)
if _einsatz_gesperrt(einsatz):
flash("Der Zeitraum ist veröffentlicht Einsätze sind gesperrt.")
return _einsatz_redirect(einsatz)
ziel = _einsatz_redirect(einsatz)
db.session.delete(einsatz)
db.session.commit()
flash("Einsatz gelöscht.")
return ziel
# -----------------------------------------------------------------------------
# Einzeltermine (Admin): eigenständige Einsätze ohne Planungszeitraum
# -----------------------------------------------------------------------------
@bp.route("/einzeltermine", methods=("GET", "POST"))
@admin_required
def einzeltermine():
if request.method == "POST":
fehler, daten = _einsatz_formular_lesen()
if fehler:
flash(fehler)
else:
einsatz = Einsatz(
team_id=get_active_team_id(),
planungszeitraum_id=None,
**daten,
)
db.session.add(einsatz)
db.session.commit()
flash("Einzeltermin angelegt.")
return redirect(url_for("planung.einzeltermine"))
termine = (
team_query(Einsatz)
.filter_by(planungszeitraum_id=None)
.order_by(Einsatz.datum, Einsatz.start_zeit)
.all()
)
return render_template("einzeltermine.html", termine=termine, arten=ARTEN)
# -----------------------------------------------------------------------------
# Verfügbarkeit melden (Ehrenamtliche) kann / kann_nicht je Einsatz
# -----------------------------------------------------------------------------
def _laufender_zeitraum():
"""Aktueller Zeitraum in Planung (neuester nach Startdatum) oder None."""
return (
team_query(Planungszeitraum)
.filter_by(status=Planungszeitraum.STATUS_IN_PLANUNG)
.order_by(Planungszeitraum.start_datum.desc())
.first()
)
@bp.route("/verfuegbarkeit")
@login_required
def verfuegbarkeit():
zr = _laufender_zeitraum()
# Meldbar sind: Einzeltermine (ohne Zeitraum) + die Einsätze des laufenden Zeitraums.
einzeltermine = (
team_query(Einsatz)
.filter_by(planungszeitraum_id=None)
.order_by(Einsatz.datum, Einsatz.start_zeit)
.all()
)
einsaetze = list(einzeltermine) + (list(zr.einsaetze) if zr else [])
eigene = {}
if einsaetze:
ids = [e.id for e in einsaetze]
rows = (
team_query(Verfuegbarkeit)
.filter_by(user_id=current_user().id)
.filter(Verfuegbarkeit.einsatz_id.in_(ids))
.all()
)
eigene = {r.einsatz_id: r.wert for r in rows}
return render_template(
"verfuegbarkeit.html",
zeitraum=zr,
einsaetze=einsaetze,
eigene=eigene,
)
@bp.route("/verfuegbarkeit/<int:einsatz_id>", methods=("POST",))
@login_required
def verfuegbarkeit_setzen(einsatz_id):
einsatz = team_query(Einsatz).filter_by(id=einsatz_id).first()
if einsatz is None:
abort(404)
if _einsatz_gesperrt(einsatz):
flash("Der Zeitraum ist veröffentlicht Verfügbarkeit ist gesperrt.")
return redirect(url_for("planung.verfuegbarkeit"))
wert = request.form.get("wert")
if wert not in (Verfuegbarkeit.WERT_KANN, Verfuegbarkeit.WERT_KANN_NICHT):
flash("Ungültige Auswahl.")
return redirect(url_for("planung.verfuegbarkeit"))
eintrag = team_query(Verfuegbarkeit).filter_by(
user_id=current_user().id, einsatz_id=einsatz.id
).first()
if eintrag is None:
eintrag = Verfuegbarkeit(
team_id=get_active_team_id(),
user_id=current_user().id,
einsatz_id=einsatz.id,
wert=wert,
)
db.session.add(eintrag)
else:
eintrag.wert = wert
db.session.commit()
return redirect(url_for("planung.verfuegbarkeit"))
# -----------------------------------------------------------------------------
# Gemeinsames Einlesen + Validieren des Einsatz-Formulars
# -----------------------------------------------------------------------------
def _einsatz_formular_lesen():
"""Liest die Einsatz-Felder aus request.form.
Rückgabe: (fehler_text_oder_None, daten_dict).
"""
datum = _parse_datum(request.form.get("datum"))
start_zeit = _parse_zeit(request.form.get("start_zeit"))
dauer_roh = (request.form.get("dauer_minuten") or "").strip()
art = (request.form.get("art") or "").strip()
ort = (request.form.get("ort") or "").strip() or None
if datum is None:
return "Bitte ein gültiges Datum angeben.", None
if start_zeit is None:
return "Bitte eine Startzeit angeben.", None
dauer_minuten = None
if dauer_roh:
if not dauer_roh.isdigit() or int(dauer_roh) <= 0:
return "Bitte eine gültige Dauer wählen.", None
dauer_minuten = int(dauer_roh)
if art not in ARTEN:
return "Bitte eine Art auswählen.", None
return None, {
"datum": datum,
"start_zeit": start_zeit,
"dauer_minuten": dauer_minuten,
"art": art,
"ort": ort,
}

110
app/routes/profil.py Normal file
View file

@ -0,0 +1,110 @@
"""Profil: Anzeigename, Profilbild und optionale freiwillige Angaben.
Nichts außer dem (ohnehin vorhandenen) Anzeigenamen ist Pflicht. Die freiwilligen
Felder sind über die Liste FREIWILLIGE_FELDER definiert und werden als JSON in
user.freiwillige_angaben abgelegt ein neues Feld ist damit eine Zeile mehr in
der Liste, ohne DB-Migration. Läuft in beiden Shells (shell_base je Rolle).
"""
import json
import os
import uuid
from flask import (
Blueprint,
abort,
current_app,
flash,
redirect,
render_template,
request,
send_from_directory,
url_for,
)
from .. import db
from ..auth import current_user, login_required
from ..models import User
from ..team_scope import team_query
bp = Blueprint("profil", __name__, url_prefix="/profil")
# Erlaubte Bildtypen fürs Profilbild (serverseitig geprüft).
BILD_ENDUNGEN = {"png", "jpg", "jpeg", "gif", "webp"}
# Optionale freiwillige Angaben. Neues Feld = ein Eintrag mehr; "mehrzeilig" für
# Textarea. Werte landen als JSON-Objekt in user.freiwillige_angaben.
FREIWILLIGE_FELDER = [
{"key": "pronomen", "label": "Pronomen", "placeholder": "z. B. sie/ihr, er/ihm"},
{"key": "ueber_mich", "label": "Über mich", "placeholder": "Ein, zwei Sätze ganz freiwillig", "mehrzeilig": True},
]
_GUELTIGE_KEYS = {f["key"] for f in FREIWILLIGE_FELDER}
def _endung(dateiname):
if not dateiname or "." not in dateiname:
return ""
return dateiname.rsplit(".", 1)[1].lower()
def _angaben_lesen(user):
"""freiwillige_angaben als Dict (robust gegen leer/kaputt)."""
if not user.freiwillige_angaben:
return {}
try:
daten = json.loads(user.freiwillige_angaben)
return daten if isinstance(daten, dict) else {}
except (ValueError, TypeError):
return {}
@bp.route("/", methods=("GET", "POST"))
@login_required
def index():
user = current_user()
if request.method == "POST":
anzeigename = (request.form.get("anzeigename") or "").strip()
if not anzeigename:
flash("Bitte einen Anzeigenamen angeben.")
return redirect(url_for("profil.index"))
user.anzeigename = anzeigename
# Freiwillige Felder: nur bekannte Keys, leere Werte fallen raus.
angaben = {}
for feld in FREIWILLIGE_FELDER:
wert = (request.form.get(feld["key"]) or "").strip()
if wert:
angaben[feld["key"]] = wert
user.freiwillige_angaben = json.dumps(angaben, ensure_ascii=False) if angaben else None
# Profilbild optional. Nur bei tatsächlichem Upload anfassen.
datei = request.files.get("profilbild")
if datei and datei.filename:
endung = _endung(datei.filename)
if endung not in BILD_ENDUNGEN:
flash("Dieses Bildformat ist nicht erlaubt. Erlaubt: PNG, JPG, GIF, WebP.")
return redirect(url_for("profil.index"))
name = f"profil_{uuid.uuid4().hex}.{endung}"
datei.save(os.path.join(current_app.config["UPLOAD_FOLDER"], name))
user.profilbild_pfad = name
db.session.commit()
flash("Profil gespeichert.")
return redirect(url_for("profil.index"))
return render_template(
"profil.html",
felder=FREIWILLIGE_FELDER,
angaben=_angaben_lesen(user),
)
@bp.route("/bild/<int:user_id>")
@login_required
def bild(user_id):
"""Profilbild eines Team-Mitglieds ausliefern (team-gescoped)."""
user = team_query(User).filter_by(id=user_id).first()
if user is None or not user.profilbild_pfad:
abort(404)
return send_from_directory(current_app.config["UPLOAD_FOLDER"], user.profilbild_pfad)

View file

@ -0,0 +1,397 @@
/* =============================================================================
CheckPoint Ehrenamt · components-admin.css
Komponenten der ADMIN-Shell (Sidebar links, mobil Off-Canvas). Nutzt
AUSSCHLIESSLICH --cp-admin-*-Tokens aus tokens.css niemals --cp-user-*.
Geteilte Klassennamen mit der User-Shell (.cp-card, .cp-btn, ), aber eigenes
Aussehen: jede Shell lädt nur ihr Komponenten-CSS, daher keine Kollision.
Desktop-first; unter 900px kollabiert die Sidebar zum Off-Canvas-Overlay.
============================================================================= */
*,
*::before,
*::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
body {
margin: 0;
min-height: 100vh;
background: var(--cp-admin-bg);
color: var(--cp-admin-text);
font-family: var(--cp-admin-font-ui);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
:focus-visible {
outline: 2px solid var(--cp-admin-teal);
outline-offset: 2px;
}
/* =============================================================================
Layout: Sidebar + Hauptbereich
============================================================================= */
.cp-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: var(--cp-admin-sidebar-width);
z-index: 30;
display: flex;
flex-direction: column;
gap: var(--cp-admin-space-2);
padding: var(--cp-admin-space-4);
overflow-y: auto;
background: var(--cp-admin-sidebar-bg);
color: var(--cp-admin-sidebar-text);
}
.cp-sidebar__brand {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--cp-admin-space-2);
font-family: var(--cp-admin-font-display);
font-size: 1.3rem;
color: var(--cp-admin-on-brand);
padding: var(--cp-admin-space-3) var(--cp-admin-space-2) var(--cp-admin-space-5);
}
.cp-sidebar__bell { color: var(--cp-admin-sidebar-text); flex: none; }
.cp-admin-topbar__bell { margin-left: auto; color: var(--cp-admin-on-brand); }
.cp-sidebar__nav {
display: flex;
flex-direction: column;
gap: var(--cp-admin-space-1);
}
.cp-navlink {
display: flex;
align-items: center;
gap: var(--cp-admin-space-3);
width: 100%;
min-height: 44px;
padding: 0 var(--cp-admin-space-3);
border: 0;
border-radius: var(--cp-admin-radius-md);
background: none;
color: var(--cp-admin-sidebar-text-muted);
font: inherit;
font-weight: 500;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.cp-navlink__icon { flex: none; color: var(--cp-admin-sidebar-icon); }
.cp-navlink:hover {
background: var(--cp-admin-sidebar-hover);
color: var(--cp-admin-sidebar-text);
}
.cp-navlink[aria-current="page"] {
background: var(--cp-admin-sidebar-active-bg);
color: var(--cp-admin-sidebar-active-text);
font-weight: 700;
}
.cp-sidebar__footer {
margin-top: auto;
display: flex;
flex-direction: column;
gap: var(--cp-admin-space-1);
padding-top: var(--cp-admin-space-4);
border-top: 1px solid var(--cp-admin-brand-navy-soft);
}
.cp-sidebar__footer form { margin: 0; }
/* Theme-Umschalter im (dunklen) Sidebar-Fuß. */
.cp-iconbtn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: 0;
border-radius: var(--cp-admin-radius-md);
background: none;
color: var(--cp-admin-sidebar-text);
font: inherit;
cursor: pointer;
text-decoration: none;
}
.cp-topbar__theme { margin: 0; display: inline-flex; }
/* Zähler-Badge auf der Glocke (ungelesene Benachrichtigungen). */
.cp-badge {
position: absolute;
top: 4px;
right: 4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: var(--cp-admin-red);
color: var(--cp-admin-on-brand);
font-size: 0.65rem;
font-weight: 700;
line-height: 1;
display: grid;
place-items: center;
}
/* Ungelesene Benachrichtigung: Akzentstreifen (zusätzlich zum „Neu"-Tag). */
.cp-card--ungelesen { border-left: 3px solid var(--cp-admin-teal); }
/* Mobile-Topbar (nur unter dem Breakpoint sichtbar). */
.cp-admin-topbar {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
height: 56px;
z-index: 20;
align-items: center;
gap: var(--cp-admin-space-3);
padding: 0 var(--cp-admin-space-4);
background: var(--cp-admin-sidebar-bg);
color: var(--cp-admin-on-brand);
}
.cp-admin-topbar__title {
font-family: var(--cp-admin-font-display);
font-size: 1.1rem;
}
.cp-hamburger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: 0;
background: none;
color: var(--cp-admin-on-brand);
cursor: pointer;
}
.cp-overlay {
position: fixed;
inset: 0;
z-index: 25;
background: rgba(0, 0, 0, 0.5);
}
.cp-overlay[hidden] { display: none; }
.cp-admin-main {
margin-left: var(--cp-admin-sidebar-width);
min-height: 100vh;
}
.cp-page {
width: 100%;
max-width: 1080px;
margin-inline: auto;
padding: var(--cp-admin-space-6);
}
/* =============================================================================
Off-Canvas unter dem Breakpoint
============================================================================= */
@media (max-width: 899px) {
.cp-admin-topbar { display: flex; }
.cp-sidebar {
width: min(86vw, 320px);
transform: translateX(-100%);
transition: transform var(--cp-admin-transition);
box-shadow: var(--cp-admin-shadow-soft);
}
body.cp-sidebar-open .cp-sidebar { transform: translateX(0); }
/* Hintergrund nicht mitscrollen, solange das Overlay offen ist. */
body.cp-sidebar-open { overflow: hidden; }
.cp-admin-main {
margin-left: 0;
padding-top: 56px;
}
.cp-page { padding: var(--cp-admin-space-4); }
}
/* =============================================================================
Typografie
============================================================================= */
h1 {
margin: 0 0 var(--cp-admin-space-4);
font-family: var(--cp-admin-font-display);
font-weight: 400;
font-size: 1.7rem;
line-height: 1.15;
color: var(--cp-admin-heading);
}
h2 {
margin: var(--cp-admin-space-5) 0 var(--cp-admin-space-3);
font-weight: 700;
font-size: 1.2rem;
color: var(--cp-admin-heading);
}
.cp-muted { color: var(--cp-admin-muted); }
/* Profilbild auf der Profilseite. */
.cp-profilbild {
width: 96px;
height: 96px;
border-radius: 50%;
object-fit: cover;
}
.cp-profilbild--leer {
display: grid;
place-items: center;
background: var(--cp-admin-teal-soft);
color: var(--cp-admin-teal-dark);
font-weight: 700;
font-size: 1.6rem;
}
/* =============================================================================
Formulare
============================================================================= */
.cp-form {
display: flex;
flex-direction: column;
gap: var(--cp-admin-space-4);
margin-block: var(--cp-admin-space-5);
max-width: 560px;
}
.cp-field { display: flex; flex-direction: column; gap: var(--cp-admin-space-2); }
.cp-field label { font-weight: 600; color: var(--cp-admin-text); }
.cp-field input,
.cp-field select,
.cp-field textarea {
min-height: 44px;
padding: 0 var(--cp-admin-space-3);
border: 1px solid var(--cp-admin-line);
border-radius: var(--cp-admin-radius-sm);
background: var(--cp-admin-surface);
color: var(--cp-admin-text);
font: inherit;
}
.cp-field textarea { padding: var(--cp-admin-space-3); min-height: 88px; resize: vertical; }
/* =============================================================================
Buttons
============================================================================= */
.cp-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0 var(--cp-admin-space-5);
border: 1px solid transparent;
border-radius: var(--cp-admin-radius-md);
font: inherit;
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.cp-btn--primary { background: var(--cp-admin-teal-ui); color: var(--cp-admin-on-brand); box-shadow: var(--cp-admin-shadow-action); }
.cp-btn--urgent { background: var(--cp-admin-red); color: var(--cp-admin-on-brand); }
.cp-btn--ghost { background: var(--cp-admin-surface); color: var(--cp-admin-text); border-color: var(--cp-admin-line); }
/* =============================================================================
Flash, Liste, Tags, Karten, Aktionen
============================================================================= */
.cp-flash {
list-style: none;
margin: 0 0 var(--cp-admin-space-4);
padding: var(--cp-admin-space-3) var(--cp-admin-space-4);
border: 1px solid var(--cp-admin-line);
border-radius: var(--cp-admin-radius-md);
background: var(--cp-admin-surface-soft);
color: var(--cp-admin-text);
}
.cp-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--cp-admin-space-3);
}
.cp-tag {
display: inline-block;
margin-left: var(--cp-admin-space-2);
padding: 2px var(--cp-admin-space-2);
border-radius: var(--cp-admin-radius-sm);
background: var(--cp-admin-status-neutral-bg);
color: var(--cp-admin-status-neutral-text);
font-size: 0.82rem;
font-weight: 600;
}
.cp-tag--urgent { background: var(--cp-admin-status-error-bg); color: var(--cp-admin-status-error-text); margin-left: 0; }
.cp-card {
display: flex;
flex-direction: column;
gap: var(--cp-admin-space-2);
padding: var(--cp-admin-space-5);
border: 1px solid var(--cp-admin-line);
border-radius: var(--cp-admin-radius-lg);
background: var(--cp-admin-surface);
box-shadow: var(--cp-admin-shadow-soft);
}
.cp-card--leer { border-style: dashed; background: var(--cp-admin-surface-soft); box-shadow: none; }
.cp-card a { color: var(--cp-admin-teal-dark); text-decoration: none; font-weight: 600; }
.cp-actions { display: flex; flex-wrap: wrap; gap: var(--cp-admin-space-2); margin-top: var(--cp-admin-space-2); }
.cp-actions form { margin: 0; }
/* =============================================================================
Tabellen (Kopf in Navy, Statusspalten mit Text nie nur Farbe)
============================================================================= */
.cp-table { width: 100%; border-collapse: collapse; background: var(--cp-admin-surface); border-radius: var(--cp-admin-radius-md); overflow: hidden; }
.cp-table th { padding: var(--cp-admin-space-3); background: var(--cp-admin-brand-navy); color: var(--cp-admin-on-brand); text-align: left; font-weight: 700; }
.cp-table td { padding: var(--cp-admin-space-3); border-top: 1px solid var(--cp-admin-line-soft); }
/* =============================================================================
Zwei-Knopf-Umschalter (kann / kann_nicht)
============================================================================= */
.cp-toggle { display: flex; gap: var(--cp-admin-space-2); margin: var(--cp-admin-space-2) 0 0; }
.cp-toggle__btn {
flex: 1;
min-height: 44px;
padding: 0 var(--cp-admin-space-3);
border: 1px solid var(--cp-admin-line);
border-radius: var(--cp-admin-radius-sm);
background: var(--cp-admin-surface);
color: var(--cp-admin-text);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.cp-toggle__btn.is-active {
background: var(--cp-admin-teal-soft);
border-color: var(--cp-admin-teal);
color: var(--cp-admin-teal-dark);
font-weight: 700;
}
/* =============================================================================
Chat
============================================================================= */
.cp-chat { list-style: none; margin: 0 0 var(--cp-admin-space-4); padding: 0; display: flex; flex-direction: column; gap: var(--cp-admin-space-2); }
.cp-msg { padding: var(--cp-admin-space-2) var(--cp-admin-space-3); border: 1px solid var(--cp-admin-line); border-radius: var(--cp-admin-radius-md); background: var(--cp-admin-surface); }
.cp-msg--eigen { background: var(--cp-admin-teal-soft); border-color: transparent; align-self: flex-end; }
.cp-msg__meta { font-size: 0.78rem; color: var(--cp-admin-muted); margin-bottom: 2px; }
.cp-msg__text { white-space: pre-wrap; word-break: break-word; }

417
app/static/components.css Normal file
View file

@ -0,0 +1,417 @@
/* =============================================================================
CheckPoint Ehrenamt · components.css
Komponenten- und Grund-CSS. Baut ausschließlich auf den Tokens aus tokens.css
auf nirgends feste Weiß-/Schwarzwerte, damit der Dunkelmodus greift.
Session 1: nur Reset + Grundtypografie. Topbar, Bottom-Nav, Karten und der
.cp-page-Verlauf folgen in Session 4 (dort konsequent auf Tokens umgestellt).
============================================================================= */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
background: var(--cp-user-bg);
color: var(--cp-user-text);
font-family: var(--cp-user-font);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* Sichtbarer Fokus ist Pflicht (Barrierearmut, siehe CLAUDE.md). */
:focus-visible {
outline: 2px solid var(--cp-user-teal-dark);
outline-offset: 2px;
}
/* Mobile-first Seitencontainer (App-Breite 360460px, viel Weißraum). */
.cp-page {
width: 100%;
max-width: 460px;
margin-inline: auto;
padding: var(--cp-user-space-4);
}
/* Wenn Topbar + Bottom-Nav da sind: Inhalt darunter/darüber freihalten. */
.cp-has-nav .cp-page {
padding-top: calc(56px + var(--cp-user-space-4));
padding-bottom: calc(64px + var(--cp-user-space-5));
}
/* Mobile-first bleibt der Standard; auf Tablet/Desktop wächst die zentrierte
Spalte (inkl. der fixierten Topbar/Bottom-Nav) auf eine angenehme Breite. */
@media (min-width: 768px) {
.cp-page,
.cp-topbar,
.cp-bottomnav {
max-width: 720px;
}
}
/* =============================================================================
Topbar (fix oben) nutzt Tokens, kein festes Weiß (Dunkelmodus greift).
============================================================================= */
.cp-topbar {
position: fixed;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 460px;
height: 56px;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding-inline: var(--cp-user-space-4);
background: var(--cp-user-surface);
border-bottom: 1px solid var(--cp-user-line);
}
.cp-topbar__logo {
font-weight: 800;
font-size: 1.1rem;
color: var(--cp-user-text);
text-decoration: none;
}
.cp-topbar__actions {
display: flex;
align-items: center;
gap: var(--cp-user-space-2);
}
.cp-iconbtn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--cp-user-radius-pill);
color: var(--cp-user-text);
text-decoration: none;
/* Auch als <button> nutzbar (Theme-Umschalter): Default-Chrome entfernen. */
background: none;
border: 0;
font: inherit;
cursor: pointer;
}
/* Zähler-Badge auf der Glocke (ungelesene Benachrichtigungen). */
.cp-badge {
position: absolute;
top: 4px;
right: 4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: var(--cp-user-radius-pill);
background: var(--cp-user-red);
color: var(--cp-user-on-brand);
font-size: 0.65rem;
font-weight: 700;
line-height: 1;
display: grid;
place-items: center;
}
/* Ungelesene Benachrichtigung: Akzentstreifen (zusätzlich zum „Neu"-Tag). */
.cp-card--ungelesen { border-left: 3px solid var(--cp-user-teal); }
/* Theme-Umschalter ist ein Formular in der Topbar kein Eigenabstand. */
.cp-topbar__theme {
margin: 0;
display: inline-flex;
}
.cp-avatar {
display: inline-grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: var(--cp-user-radius-pill);
background: var(--cp-user-teal-soft);
color: var(--cp-user-teal-dark);
font-weight: 700;
font-size: 0.85rem;
text-decoration: none;
overflow: hidden;
}
.cp-avatar__img { width: 100%; height: 100%; object-fit: cover; }
/* Profilbild auf der Profilseite. */
.cp-profilbild {
width: 96px;
height: 96px;
border-radius: var(--cp-user-radius-pill);
object-fit: cover;
}
.cp-profilbild--leer {
display: grid;
place-items: center;
background: var(--cp-user-teal-soft);
color: var(--cp-user-teal-dark);
font-weight: 700;
font-size: 1.6rem;
}
/* =============================================================================
Bottom-Navigation (fix unten) 4 Punkte, Tokens statt festem Weiß.
============================================================================= */
.cp-bottomnav {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 460px;
z-index: 10;
display: flex;
background: var(--cp-user-surface);
border-top: 1px solid var(--cp-user-line);
padding-bottom: env(safe-area-inset-bottom);
}
.cp-navitem {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-height: 56px;
padding: var(--cp-user-space-2) 0;
color: var(--cp-user-muted);
font-size: 0.72rem;
font-weight: 600;
text-decoration: none;
}
/* Aktiver Punkt: nicht nur über Farbe auch fett + Markenton. */
.cp-navitem[aria-current="page"] {
color: var(--cp-user-teal-dark);
font-weight: 700;
}
h1 {
margin: 0 0 var(--cp-user-space-3);
font-size: 1.5rem;
font-weight: 700;
}
.cp-muted {
color: var(--cp-user-muted);
}
/* --- Formulare (Labels immer sichtbar, Felder >= 44px) -------------------- */
.cp-form {
display: flex;
flex-direction: column;
gap: var(--cp-user-space-4);
margin-block: var(--cp-user-space-5);
}
.cp-field {
display: flex;
flex-direction: column;
gap: var(--cp-user-space-2);
}
.cp-field label {
font-weight: 600;
}
.cp-field input,
.cp-field select,
.cp-field textarea {
min-height: 44px;
padding: 0 var(--cp-user-space-3);
border: 1px solid var(--cp-user-line);
border-radius: var(--cp-user-radius-md);
background: var(--cp-user-surface);
color: var(--cp-user-text);
font: inherit;
}
.cp-field textarea {
padding: var(--cp-user-space-3);
min-height: 88px;
resize: vertical;
}
/* --- Buttons (Primär = Türkis/weiß · Ghost = Fläche mit feinem Rahmen) ---- */
.cp-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0 var(--cp-user-space-5);
border: 1px solid transparent;
border-radius: var(--cp-user-radius-md);
font: inherit;
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.cp-btn--primary {
background: var(--cp-user-teal);
color: var(--cp-user-on-brand);
}
.cp-btn--urgent {
background: var(--cp-user-red);
color: var(--cp-user-on-brand);
}
.cp-btn--ghost {
background: var(--cp-user-surface);
color: var(--cp-user-text);
border-color: var(--cp-user-line);
}
/* --- Flash-Hinweise ------------------------------------------------------ */
.cp-flash {
list-style: none;
margin: 0 0 var(--cp-user-space-4);
padding: var(--cp-user-space-3) var(--cp-user-space-4);
border-radius: var(--cp-user-radius-md);
background: var(--cp-user-surface-alt);
color: var(--cp-user-text);
}
/* --- Liste & Tag --------------------------------------------------------- */
.cp-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--cp-user-space-2);
}
.cp-tag {
display: inline-block;
margin-left: var(--cp-user-space-2);
padding: 0 var(--cp-user-space-2);
border-radius: var(--cp-user-radius-pill);
background: var(--cp-user-status-neutral-bg);
color: var(--cp-user-status-neutral-text);
font-size: 0.85rem;
}
/* --- Karte (Radius 12px, Innenabstand 16px, keine Verschachtelung) ------- */
.cp-card {
display: flex;
flex-direction: column;
gap: var(--cp-user-space-1);
padding: var(--cp-user-space-4);
border: 1px solid var(--cp-user-line);
border-radius: var(--cp-user-radius-lg);
background: var(--cp-user-surface);
}
/* Leerer Platz (noch nicht besetzt): gestrichelt, abgesetzt. */
.cp-card--leer {
border-style: dashed;
background: var(--cp-user-surface-alt);
}
/* Dringender Status (Vertretung/unterbesetzt) Rot statt Neutral. */
.cp-tag--urgent {
background: var(--cp-user-status-urgent-bg);
color: var(--cp-user-status-urgent-text);
margin-left: 0;
}
.cp-card a {
color: var(--cp-user-text);
text-decoration: none;
}
/* --- Aktionsreihe (Buttons/Formulare nebeneinander) ---------------------- */
.cp-actions {
display: flex;
flex-wrap: wrap;
gap: var(--cp-user-space-2);
margin-top: var(--cp-user-space-2);
}
.cp-actions form {
margin: 0;
}
/* --- Zwei-Knopf-Umschalter (kann / kann_nicht) --------------------------- */
.cp-toggle {
display: flex;
gap: var(--cp-user-space-2);
margin: var(--cp-user-space-2) 0 0;
}
.cp-toggle__btn {
flex: 1;
min-height: 44px;
padding: 0 var(--cp-user-space-3);
border: 1px solid var(--cp-user-line);
border-radius: var(--cp-user-radius-md);
background: var(--cp-user-surface);
color: var(--cp-user-text);
font: inherit;
font-weight: 600;
cursor: pointer;
}
/* Aktiver Zustand: Markenton + fett + „✓" im Text (nicht nur Farbe). */
.cp-toggle__btn.is-active {
background: var(--cp-user-teal-soft);
border-color: var(--cp-user-teal-dark);
color: var(--cp-user-teal-dark);
font-weight: 700;
}
/* --- Chat (zwei Kanäle, Text-only) --------------------------------------- */
.cp-chat {
list-style: none;
margin: 0 0 var(--cp-user-space-4);
padding: 0;
display: flex;
flex-direction: column;
gap: var(--cp-user-space-2);
}
.cp-msg {
padding: var(--cp-user-space-2) var(--cp-user-space-3);
border: 1px solid var(--cp-user-line);
border-radius: var(--cp-user-radius-md);
background: var(--cp-user-surface);
}
/* Eigene Nachrichten abgesetzt (nicht nur Farbe: auch rechtsbündig). */
.cp-msg--eigen {
background: var(--cp-user-teal-soft);
border-color: transparent;
align-self: flex-end;
}
.cp-msg__meta {
font-size: 0.78rem;
color: var(--cp-user-muted);
margin-bottom: 2px;
}
.cp-msg__text {
white-space: pre-wrap;
word-break: break-word;
}

93
app/static/fonts.css Normal file
View file

@ -0,0 +1,93 @@
/* CheckPoint Ehrenamt · fonts.css
Selbst gehostete Schriften (SIL Open Font License) kein externer
Request an Google (DSGVO). Subset: latin (deckt deutsche Umlaute/ß ab).
Erzeugt aus den Google-Fonts-CSS2-Antworten, src auf lokale Dateien umgebogen. */
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(fonts/dm-sans-400.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(fonts/dm-sans-500.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(fonts/dm-sans-600.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(fonts/dm-sans-700.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url(fonts/dm-sans-800.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Roboto Condensed';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(fonts/roboto-condensed-400.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Roboto Condensed';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(fonts/roboto-condensed-500.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Roboto Condensed';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(fonts/roboto-condensed-700.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Roboto Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(fonts/roboto-mono-400.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Roboto Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(fonts/roboto-mono-500.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Alfa Slab One';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(fonts/alfa-slab-one-400.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

24
app/static/sidebar.js Normal file
View file

@ -0,0 +1,24 @@
/* Admin-Sidebar: mobiles Off-Canvas-Verhalten (zweite v1-JS-Ausnahme, CLAUDE.md).
Togglet die Klasse cp-sidebar-open am <body>; das CSS schiebt die Sidebar ein.
Schließt bei Klick auf das Overlay oder auf einen Menüpunkt. Kein Framework. */
(function () {
var oeffner = document.querySelector("[data-sidebar-open]");
var overlay = document.querySelector("[data-sidebar-close]");
var sidebar = document.getElementById("cp-sidebar");
if (!oeffner || !sidebar) return;
function setOffen(offen) {
document.body.classList.toggle("cp-sidebar-open", offen);
oeffner.setAttribute("aria-expanded", offen ? "true" : "false");
if (overlay) overlay.hidden = !offen;
}
oeffner.addEventListener("click", function () { setOffen(true); });
if (overlay) overlay.addEventListener("click", function () { setOffen(false); });
sidebar.addEventListener("click", function (e) {
if (e.target.closest("a")) setOffen(false);
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") setOffen(false);
});
})();

340
app/static/tokens.css Normal file
View file

@ -0,0 +1,340 @@
/* =============================================================================
CheckPoint Ehrenamt · tokens.css
Mandantenfähiges Token-System mit Hell- und Dunkelmodus.
ZWEI ROLLEN-THEMES IN EINER DATEI: User (Mobile, Bottom-Nav) und
Admin (Desktop-Sidebar, mobil per Off-Canvas). Jedes Theme ist für sich
mandantenfähig ein neues Team tauscht beide Brand-Blöcke.
AUFBAU (pro Theme dieselben drei Schichten):
1) BRAND pro Team austauschbar. Ein zweites Team ändert NUR diese Werte.
2) BASE theme-unabhängig: Abstände, Radien, Schrift, Transitions.
3) SEMANTIK hell + dunkel in einem, gelöst über die CSS-Funktion light-dark().
NAMENSRAUM:
--cp-user-* Token für die Ehrenamtlichen-Oberfläche (Bottom-Nav-Shell)
--cp-admin-* Token für die Admin-Oberfläche (Sidebar-Shell)
Komponenten-CSS wählt je Shell den passenden Namensraum. Es gibt keine
gemeinsamen Farb-Tokens mehr ohne Präfix das verhindert versehentliches
Mischen der beiden Looks.
THEME-STEUERUNG (kein JavaScript nötig):
- Standard folgt der Systemeinstellung (prefers-color-scheme) automatisch.
- Optionaler manueller Override später: <html data-theme="dark"> bzw. "light".
Hinweis: Diese Datei definiert nur Variablen. Komponenten-CSS muss diese
Tokens verwenden (z. B. background: var(--cp-user-surface)), damit der
Dunkelmodus wirklich greift nirgends feste Weiß-/Schwarzwerte verdrahten.
============================================================================= */
/* Schriften selbst gehostet (DSGVO: kein externer Request an Google).
@font-face-Regeln in fonts.css, Dateien unter static/fonts/. */
@import url("fonts.css");
/* #############################################################################
TEIL A · USER-THEME (Ehrenamtliche, Mobile, Bottom-Nav)
############################################################################# */
/* =============================================================================
A.1 · BRAND (pro Mandant überschreiben User-Theme)
----------------------------------------------------------------------------
Für ein weiteres Team genügt es, diesen Block zu ersetzen am besten
serverseitig pro Team eingespielt, z. B.:
<style>
:root {
--cp-user-brand-primary: #21a8ad;
--cp-user-brand-primary-strong: #147f84;
--cp-user-brand-accent: #ef1823;
--cp-user-brand-accent-strong: #bd0f18;
--cp-user-brand-font: "DM Sans", Arial, Helvetica, sans-serif;
}
</style>
Alle getönten Flächen, Fokus-Ringe und Status-Farben werden aus diesen
wenigen Werten abgeleitet das Team muss nichts weiter pflegen.
============================================================================= */
:root {
--cp-user-brand-primary: #21a8ad; /* Türkis Hauptfarbe */
--cp-user-brand-primary-strong: #147f84; /* Türkis dunkel Links, starke Akzente */
--cp-user-brand-accent: #ef1823; /* Rot dringende Aktionen, Warnungen */
--cp-user-brand-accent-strong: #bd0f18; /* Rot dunkel Hover, starke Akzente */
--cp-user-brand-font: "Roboto Condensed", Arial, Helvetica, sans-serif;
--cp-user-on-brand: #fff; /* Schrift auf Voll-Marken-Flächen,
themeunabhängig (Türkis/Rot in beiden
Modi gleich). Pro Team überschreibbar. */
}
/* =============================================================================
A.2 · BASE (theme-unabhängig User-Theme)
============================================================================= */
:root {
--cp-user-font: var(--cp-user-brand-font);
--cp-user-radius-sm: 6px;
--cp-user-radius-md: 8px;
--cp-user-radius-lg: 12px;
--cp-user-radius-xl: 16px;
--cp-user-radius-pill: 999px;
--cp-user-space-1: 0.25rem;
--cp-user-space-2: 0.5rem;
--cp-user-space-3: 0.75rem;
--cp-user-space-4: 1rem;
--cp-user-space-5: 1.25rem;
--cp-user-space-6: 1.5rem;
--cp-user-space-8: 2rem;
--cp-user-transition: 160ms ease;
}
/* =============================================================================
A.3 · SEMANTIK (hell + dunkel via light-dark User-Theme)
----------------------------------------------------------------------------
light-dark(HELL, DUNKEL) liefert automatisch den passenden Wert, sobald
color-scheme aktiv ist. Reihenfolge der Argumente: immer HELL zuerst.
============================================================================= */
:root {
/* --- Flächen & Hintergründe ------------------------------------------- */
--cp-user-bg: light-dark(#f5f7f8, #0f1518); /* Seitenhintergrund */
--cp-user-surface: light-dark(#ffffff, #161f22); /* Karten, Header, Formulare */
--cp-user-surface-alt: light-dark(#f0f5f6, #1d272b); /* neutrale Chips, abgesetzt */
/* --- Text -------------------------------------------------------------- */
--cp-user-text: light-dark(#253033, #e8eef0); /* Haupttext */
--cp-user-muted: light-dark(#66777c, #9fb1b5); /* Zusatz-/Meta-Text */
--cp-user-faint: light-dark(#9aabad, #6b7c80); /* inaktiv, sehr leise */
/* --- Linien ------------------------------------------------------------ */
--cp-user-line: light-dark(rgba(37, 48, 51, 0.13), rgba(232, 238, 240, 0.14));
/* --- Markenfarben (Buttons, aktive Zustände) -------------------------- */
--cp-user-teal: var(--cp-user-brand-primary);
--cp-user-red: var(--cp-user-brand-accent);
/* "dark"-Varianten = starke Akzent-/Textfarbe. Im Dunkelmodus heller,
damit sie auf dunklem Grund lesbar bleibt. */
--cp-user-teal-dark: light-dark(var(--cp-user-brand-primary-strong), #5fd0d4);
--cp-user-red-dark: light-dark(var(--cp-user-brand-accent-strong), #ff6b73);
/* --- Getönte Flächen (aus der Markenfarbe abgeleitet) ----------------- */
--cp-user-teal-soft: light-dark(
color-mix(in srgb, var(--cp-user-brand-primary) 14%, white),
color-mix(in srgb, var(--cp-user-brand-primary) 26%, transparent)
);
--cp-user-red-soft: light-dark(
color-mix(in srgb, var(--cp-user-brand-accent) 12%, white),
color-mix(in srgb, var(--cp-user-brand-accent) 26%, transparent)
);
/* --- Info (sekundäre Infoflächen) -------------------------------------- */
--cp-user-info: light-dark(#2b86c5, #6fb7e6);
--cp-user-blue-soft: light-dark(#dff4fb, color-mix(in srgb, #4aa3dd 22%, transparent));
/* --- Fokus-Ring (aus der Markenfarbe abgeleitet) ---------------------- */
--cp-user-focus: light-dark(
color-mix(in srgb, var(--cp-user-brand-primary) 34%, transparent),
color-mix(in srgb, var(--cp-user-brand-primary) 55%, transparent)
);
/* --- Schatten --------------------------------------------------------- */
--cp-user-shadow-color: light-dark(rgba(20, 52, 58, 0.12), rgba(0, 0, 0, 0.55));
--cp-user-shadow-sm: 0 1px 3px var(--cp-user-shadow-color);
--cp-user-shadow-md: 0 10px 28px var(--cp-user-shadow-color);
/* --- Status-Mapping (semantisch, für Badges/Hinweise) ----------------- */
/* Türkis = offen/bestätigt/verfügbar · Rot = dringend/Vertretung ·
Neutral = Info/intern/erledigt. */
--cp-user-status-ok-bg: var(--cp-user-teal-soft);
--cp-user-status-ok-text: var(--cp-user-teal-dark);
--cp-user-status-urgent-bg: var(--cp-user-red-soft);
--cp-user-status-urgent-text: var(--cp-user-red-dark);
--cp-user-status-neutral-bg: var(--cp-user-surface-alt);
--cp-user-status-neutral-text: var(--cp-user-muted);
}
/* #############################################################################
TEIL B · ADMIN-THEME (Sidebar-Verwaltungsoberfläche, Desktop + mobiles
Off-Canvas-Menü). Eigener, vollständig getrennter Tokensatz bewusst kein
Vermischen mit den User-Tokens, damit ein Team beide Looks unabhängig
pflegen oder austauschen kann.
############################################################################# */
/* =============================================================================
B.1 · BRAND (pro Mandant überschreiben Admin-Theme)
----------------------------------------------------------------------------
Quelle: "Checkpoint Koeln UI Corporate Design". Ein zweites Team ersetzt
nur diesen Block plus die Schrift-Imports oben in der Datei.
============================================================================= */
:root {
--cp-admin-brand-navy: #1b1f30; /* Sidebar, Tabellenkopf */
--cp-admin-brand-navy-soft: #252a3d; /* Sidebar-Trenner, dunkler Hover */
--cp-admin-brand-teal: #01b2bb; /* Hauptmarkenfarbe, aktive Nav */
--cp-admin-brand-teal-ui: #11b6c9; /* Primärbuttons, Floating Actions */
--cp-admin-brand-teal-dark: #139ead; /* Hover, aktive Buttons */
--cp-admin-brand-yellow: #fbef1f; /* Sidebar-Icons, Markenhighlight */
--cp-admin-brand-pink: #e6037f; /* Logo-/Markenakzent */
--cp-admin-brand-red: #e62143; /* Fehler, Abbruch, Warnung */
--cp-admin-brand-red-dark: #c80f3d; /* Danger-Buttons, Hover */
--cp-admin-brand-orange: #f59e18; /* sekundäre Hinweise */
--cp-admin-brand-green: #168a2d; /* Erfolg, verfügbar, bestätigt */
--cp-admin-brand-font-display: "Alfa Slab One", "Roboto Slab", Georgia, serif;
--cp-admin-brand-font-ui: "Roboto Condensed", "Arial Narrow", Arial, sans-serif;
--cp-admin-on-brand: #fff; /* Schrift auf Volltonflächen (Buttons,
Tabellenkopf, Sidebar-Brand) */
}
/* =============================================================================
B.2 · BASE (theme-unabhängig Admin-Theme)
============================================================================= */
:root {
--cp-admin-font-display: var(--cp-admin-brand-font-display);
--cp-admin-font-ui: var(--cp-admin-brand-font-ui);
--cp-admin-font-mono: "Roboto Mono", Consolas, monospace;
--cp-admin-radius-sm: 6px;
--cp-admin-radius-md: 10px;
--cp-admin-radius-lg: 14px;
--cp-admin-space-1: 4px;
--cp-admin-space-2: 8px;
--cp-admin-space-3: 12px;
--cp-admin-space-4: 16px;
--cp-admin-space-5: 24px;
--cp-admin-space-6: 32px;
--cp-admin-sidebar-width: 260px;
--cp-admin-transition: 160ms ease;
}
/* =============================================================================
B.3 · SEMANTIK (hell + dunkel via light-dark Admin-Theme)
----------------------------------------------------------------------------
Das Quelldokument beschreibt nur einen hellen Look (helle Arbeitsfläche +
dunkle Sidebar). Die Sidebar bleibt darum in BEIDEN Modi dunkel (Navy)
das ist so gewollt, sie ist ja schon das "dunkle Element" im Hellmodus.
Im Dunkelmodus wird nur die Arbeitsfläche dunkler, die Sidebar bleibt das
immer gleiche Marken-Navy. So muss am Logo/Sidebar-Look nichts wechseln.
============================================================================= */
:root {
/* --- Sidebar (in beiden Themes gleich dunkel = Markenelement) ---------- */
--cp-admin-sidebar-bg: var(--cp-admin-brand-navy);
--cp-admin-sidebar-hover: var(--cp-admin-brand-navy-soft);
--cp-admin-sidebar-text: light-dark(rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.92));
--cp-admin-sidebar-text-muted: light-dark(rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.6));
--cp-admin-sidebar-icon: var(--cp-admin-brand-yellow);
--cp-admin-sidebar-active-bg: var(--cp-admin-brand-teal);
--cp-admin-sidebar-active-text: #ffffff;
/* --- Arbeitsfläche & Flächen -------------------------------------------- */
--cp-admin-bg: light-dark(#f5f7fa, #0e1320); /* Seitenhintergrund */
--cp-admin-bg-cool: light-dark(#f5fafb, #0c1722); /* kühlerer Arbeitsbereich */
--cp-admin-surface: light-dark(#ffffff, #161c2b); /* Karten, Panels, Formulare */
--cp-admin-surface-soft: light-dark(#eef9fb, #16242c); /* Panel-Header, Akkordeons */
/* --- Text ---------------------------------------------------------------- */
--cp-admin-text: light-dark(#1f2b37, #e6ecf1); /* Haupttext */
--cp-admin-heading: light-dark(#090b10, #f5f8fa); /* Seitentitel, starke Headlines */
--cp-admin-muted: light-dark(#6e8190, #93a3b0); /* Labels, Meta-Informationen */
--cp-admin-muted-2: light-dark(#9ba7b1, #6b7a85); /* inaktive Icons, sehr leise */
/* --- Linien --------------------------------------------------------------- */
--cp-admin-line: light-dark(#d6e6ec, rgba(214, 230, 236, 0.18)); /* Rahmen, Input-Borders */
--cp-admin-line-soft: light-dark(#e8eef2, rgba(214, 230, 236, 0.1)); /* Tabellenlinien */
/* --- Markenfarben (Buttons, aktive Zustände, in beiden Themes gleich) --- */
--cp-admin-teal: var(--cp-admin-brand-teal);
--cp-admin-teal-ui: var(--cp-admin-brand-teal-ui);
--cp-admin-teal-dark: light-dark(var(--cp-admin-brand-teal-dark), #5fd6e0);
--cp-admin-teal-soft: light-dark(
color-mix(in srgb, var(--cp-admin-brand-teal) 10%, white),
color-mix(in srgb, var(--cp-admin-brand-teal) 24%, transparent)
);
--cp-admin-pink: var(--cp-admin-brand-pink);
--cp-admin-red: var(--cp-admin-brand-red);
--cp-admin-red-dark: light-dark(var(--cp-admin-brand-red-dark), #ff7a93);
--cp-admin-red-soft: light-dark(
color-mix(in srgb, var(--cp-admin-brand-red) 10%, white),
color-mix(in srgb, var(--cp-admin-brand-red) 24%, transparent)
);
--cp-admin-orange: var(--cp-admin-brand-orange);
--cp-admin-orange-soft: light-dark(
color-mix(in srgb, var(--cp-admin-brand-orange) 12%, white),
color-mix(in srgb, var(--cp-admin-brand-orange) 24%, transparent)
);
--cp-admin-green: var(--cp-admin-brand-green);
--cp-admin-green-soft: light-dark(#e5f5e6, color-mix(in srgb, var(--cp-admin-brand-green) 24%, transparent));
/* --- Fokus-Ring ------------------------------------------------------------ */
--cp-admin-focus: light-dark(
color-mix(in srgb, var(--cp-admin-brand-teal) 34%, transparent),
color-mix(in srgb, var(--cp-admin-brand-teal) 55%, transparent)
);
/* --- Schatten ---------------------------------------------------------- */
--cp-admin-shadow-color: light-dark(rgba(27, 31, 48, 0.06), rgba(0, 0, 0, 0.5));
--cp-admin-shadow-soft: 0 2px 8px var(--cp-admin-shadow-color);
--cp-admin-shadow-action: light-dark(0 4px 10px rgba(1, 178, 187, 0.25), 0 4px 10px rgba(1, 178, 187, 0.35));
/* --- Chip/Badge-Status (Text immer mitgeben, nie nur Farbe) ------------- */
--cp-admin-status-neutral-bg: light-dark(#e9eef2, color-mix(in srgb, var(--cp-admin-muted-2) 22%, transparent));
--cp-admin-status-neutral-text: var(--cp-admin-text);
--cp-admin-status-success-bg: var(--cp-admin-green-soft);
--cp-admin-status-success-text: var(--cp-admin-green);
--cp-admin-status-info-bg: var(--cp-admin-teal-soft);
--cp-admin-status-info-text: var(--cp-admin-teal-dark);
--cp-admin-status-warning-bg: var(--cp-admin-orange-soft);
--cp-admin-status-warning-text: light-dark(#8a5a0a, #ffc169);
--cp-admin-status-error-bg: var(--cp-admin-red-soft);
--cp-admin-status-error-text: var(--cp-admin-red-dark);
}
/* #############################################################################
TEIL C · GLOBALE STEUERUNG (gilt für beide Themes)
############################################################################# */
/* =============================================================================
C.1 · Hell/Dunkel aktivieren + manueller Override
----------------------------------------------------------------------------
Setzt das Attribut data-theme die color-scheme fest, "gewinnt" diese gegen
die Systemeinstellung ganz ohne doppelte Token-Definitionen.
Der Topbar-Umschalter setzt data-theme serverseitig (theme-Cookie <html ...>),
also ohne JavaScript; "System" entfernt das Attribut wieder.
============================================================================= */
:root {
color-scheme: light dark;
}
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
/* =============================================================================
C.2 · Shell-Auswahl
----------------------------------------------------------------------------
Jede Shell-Vorlage setzt auf ihrem Wurzelelement eine der beiden Klassen.
So lesen Komponenten in jeder Shell automatisch die richtigen Tokens, ohne
dass irgendwo "user" oder "admin" hartkodiert werden muss:
<body class="cp-shell-user"> in base_user.html
<body class="cp-shell-admin"> in base_admin.html
Innerhalb von components.css kann dadurch z. B. .cp-shell-user .cp-card
bzw. .cp-shell-admin .cp-card jeweils ihre eigenen Tokens nutzen, falls
geteilte Klassennamen je Shell unterschiedlich aussehen sollen.
============================================================================= */
.cp-shell-user {
background: var(--cp-user-bg);
color: var(--cp-user-text);
font-family: var(--cp-user-font);
}
.cp-shell-admin {
background: var(--cp-admin-bg);
color: var(--cp-admin-text);
font-family: var(--cp-admin-font-ui);
}
html {
color-scheme: inherit;
}

51
app/team_scope.py Normal file
View file

@ -0,0 +1,51 @@
"""Team-Scoping (Mandantenfähigkeit).
Jeder Datensatz gehört zu genau einem Team; jede Abfrage filtert nach dem Team
des eingeloggten Nutzers (siehe CLAUDE.md). Das aktive Team wird beim Login in
die Session geschrieben und hier zentral ausgelesen.
Faustregel für alle Routen: NIE Model.query direkt verwenden, sondern
team_query(Model) so kann keine Abfrage versehentlich Teamgrenzen überschreiten.
"""
from flask import session
SESSION_TEAM_KEY = "team_id"
SESSION_USER_KEY = "user_id"
def get_active_team_id():
"""team_id des aktiven Teams aus der Session (oder None)."""
return session.get(SESSION_TEAM_KEY)
def current_team():
"""Das aktive Team-Objekt (oder None), abgeleitet aus der Session."""
from .models import Team
team_id = get_active_team_id()
if team_id is None:
return None
return Team.query.get(team_id)
def team_query(model):
"""Query auf ein Modell, fest auf das aktive Team eingegrenzt.
Wirft ohne aktives Team bewusst, damit team-vergessende Aufrufe früh auffallen.
"""
team_id = get_active_team_id()
if team_id is None:
raise RuntimeError("Kein aktives Team in der Session Login erforderlich.")
return model.query.filter_by(team_id=team_id)
def set_active_session(user_id, team_id):
"""Beim Login: Nutzer und sein Team in die Session schreiben."""
session[SESSION_USER_KEY] = user_id
session[SESSION_TEAM_KEY] = team_id
def clear_session():
"""Beim Logout: Session leeren."""
session.clear()

View file

@ -0,0 +1,10 @@
{% extends shell_base %}
{% block title %}Sitzung abgelaufen · CheckPoint Ehrenamt{% endblock %}
{% block content %}
<h1>Sitzung abgelaufen</h1>
<p class="cp-muted">Das Formular war zu lange offen. Lade die Seite neu und
sende es noch einmal ab.</p>
<a class="cp-btn cp-btn--ghost" href="{{ url_for('main.index') }}">Zur Startseite</a>
{% endblock %}

9
app/templates/403.html Normal file
View file

@ -0,0 +1,9 @@
{% extends shell_base %}
{% block title %}Kein Zugriff · CheckPoint Ehrenamt{% endblock %}
{% block content %}
<h1>Kein Zugriff</h1>
<p class="cp-muted">Diese Seite ist nur für Admins. Geh zurück zur Startseite.</p>
<a class="cp-btn cp-btn--ghost" href="{{ url_for('main.index') }}">Zur Startseite</a>
{% endblock %}

View file

@ -0,0 +1,50 @@
{# Gemeinsame Einsatz-Felder für Anlegen und Bearbeiten.
Optional vorbelegt aus `einsatz` (im Bearbeiten-Fall). #}
{% set e = einsatz if einsatz is defined else None %}
<div class="cp-field">
<label for="datum">Datum</label>
<input type="date" id="datum" name="datum"
value="{{ e.datum.strftime('%Y-%m-%d') if e else '' }}" required>
</div>
<div class="cp-field">
<label for="start_zeit">Startzeit</label>
<select id="start_zeit" name="start_zeit" required>
<option value="" disabled {{ 'selected' if not e else '' }}>Bitte wählen</option>
{% for m in range(0, 1440, 30) %}
{% set hhmm = '%02d:%02d'|format(m // 60, m % 60) %}
<option value="{{ hhmm }}"
{{ 'selected' if e and e.start_zeit.strftime('%H:%M') == hhmm else '' }}>{{ hhmm }}</option>
{% endfor %}
</select>
</div>
<div class="cp-field">
<label for="dauer_minuten">Erwartete Dauer <span class="cp-muted">(optional)</span></label>
<select id="dauer_minuten" name="dauer_minuten">
<option value="" {{ 'selected' if not e or not e.dauer_minuten else '' }}>Offen / unklar</option>
{% for d in range(30, 481, 30) %}
{% set h = d // 60 %}{% set mm = d % 60 %}
<option value="{{ d }}" {{ 'selected' if e and e.dauer_minuten == d else '' }}>
{%- if h and mm %}{{ h }},5 Std{% elif h %}{{ h }} Std{% else %}{{ mm }} Min{% endif -%}
</option>
{% endfor %}
</select>
</div>
<div class="cp-field">
<label for="art">Art</label>
<select id="art" name="art" required>
<option value="" disabled {{ 'selected' if not e else '' }}>Bitte wählen</option>
{% for a in arten %}
<option value="{{ a }}" {{ 'selected' if e and e.art == a else '' }}>{{ a }}</option>
{% endfor %}
</select>
</div>
<div class="cp-field">
<label for="ort">Ort <span class="cp-muted">(optional)</span></label>
<input type="text" id="ort" name="ort" value="{{ e.ort or '' if e else '' }}"
placeholder="z. B. Altstadt">
</div>

View file

@ -0,0 +1,31 @@
{# Theme-Umschalter: ein Tippen schaltet System → Hell → Dunkel weiter.
Serverseitig per POST (kein JS), Icon zeigt den aktuellen Modus.
Shell-unabhängig nutzt .cp-iconbtn aus dem jeweils geladenen Komponenten-CSS. #}
{%- set theme_next = {'system': 'hell', 'hell': 'dunkel', 'dunkel': 'system'} -%}
{%- set theme_label = {'system': 'System', 'hell': 'Hell', 'dunkel': 'Dunkel'} -%}
<form class="cp-topbar__theme" method="post" action="{{ url_for('main.theme_setzen') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="modus" value="{{ theme_next[current_theme] }}">
<input type="hidden" name="next" value="{{ request.path }}">
<button type="submit" class="cp-iconbtn"
aria-label="Darstellung: {{ theme_label[current_theme] }}. Tippen für {{ theme_label[theme_next[current_theme]] }}.">
{% if current_theme == 'hell' %}
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"></path>
</svg>
{% elif current_theme == 'dunkel' %}
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
</svg>
{% else %}
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"></circle>
<path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"></path>
</svg>
{% endif %}
</button>
</form>

6
app/templates/_zeit.html Normal file
View file

@ -0,0 +1,6 @@
{# Einheitliche Zeit-Anzeige: Start, optional Ende, optional (ca. Dauer).
Beispiele: "18:00 (ca. 2 Std)" wird zu "18:0020:00 (ca. 2 Std)";
ohne Dauer einfach "18:00". #}
{%- macro zeitspanne(e) -%}
{{ e.start_zeit.strftime('%H:%M') }}{% if e.end_zeit %}{{ e.end_zeit.strftime('%H:%M') }}{% endif %}{% if e.dauer_text %} ({{ e.dauer_text }}){% endif %}
{%- endmacro -%}

View file

@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="de"{% if current_theme == 'hell' %} data-theme="light"{% elif current_theme == 'dunkel' %} data-theme="dark"{% endif %}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}CheckPoint Ehrenamt{% endblock %}</title>
{# tokens.css zuerst (Variablen), dann das Admin-Komponenten-CSS. #}
<link rel="stylesheet" href="{{ url_for('static', filename='tokens.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='components-admin.css') }}">
{# Admin-Brand: v1 nutzt die festen Checkpoint-Defaults aus tokens.css
(das Team-Modell hat keine Admin-Brand-Felder). Ein zweites Team würde hier
analog zur User-Shell --cp-admin-brand-* serverseitig überschreiben. #}
{% block head_brand %}{% endblock %}
</head>
<body class="cp-shell-admin">
{% if current_user %}
{# Mobile: schmale Topbar mit Hamburger; Sidebar wird zum Off-Canvas-Overlay. #}
<header class="cp-admin-topbar">
<button type="button" class="cp-hamburger" data-sidebar-open
aria-label="Menü öffnen" aria-controls="cp-sidebar" aria-expanded="false">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M3 6h18M3 12h18M3 18h18"></path>
</svg>
</button>
<span class="cp-admin-topbar__title">{{ current_team.name if current_team else 'CheckPoint' }}</span>
<a class="cp-iconbtn cp-admin-topbar__bell" href="{{ url_for('main.benachrichtigungen') }}"
aria-label="Benachrichtigungen{% if benachrichtigungen_ungelesen %} ({{ benachrichtigungen_ungelesen }} ungelesen){% endif %}">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.7 21a2 2 0 0 1-3.4 0"></path>
</svg>
{% if benachrichtigungen_ungelesen %}<span class="cp-badge">{{ benachrichtigungen_ungelesen }}</span>{% endif %}
</a>
</header>
<div class="cp-overlay" data-sidebar-close hidden></div>
<aside class="cp-sidebar" id="cp-sidebar">
<div class="cp-sidebar__brand">
<span>{{ current_team.name if current_team else 'CheckPoint' }}</span>
<a class="cp-iconbtn cp-sidebar__bell" href="{{ url_for('main.benachrichtigungen') }}"
aria-label="Benachrichtigungen{% if benachrichtigungen_ungelesen %} ({{ benachrichtigungen_ungelesen }} ungelesen){% endif %}">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.7 21a2 2 0 0 1-3.4 0"></path>
</svg>
{% if benachrichtigungen_ungelesen %}<span class="cp-badge">{{ benachrichtigungen_ungelesen }}</span>{% endif %}
</a>
</div>
{% set bp = request.blueprint %}
{% set ep = request.endpoint %}
<nav class="cp-sidebar__nav" aria-label="Verwaltung">
<a class="cp-navlink" href="{{ url_for('main.index') }}"
{% if ep == 'main.index' %}aria-current="page"{% endif %}>
<svg class="cp-navlink__icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 11l9-8 9 8"></path><path d="M5 10v10h14V10"></path>
</svg><span>Start</span>
</a>
<a class="cp-navlink" href="{{ url_for('planung.liste') }}"
{% if bp == 'planung' %}aria-current="page"{% endif %}>
<svg class="cp-navlink__icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="2"></rect><path d="M16 2v4M8 2v4M3 10h18"></path>
</svg><span>Planung</span>
</a>
<a class="cp-navlink" href="{{ url_for('dienste.liste') }}"
{% if bp == 'dienste' and ep != 'dienste.meine_termine' %}aria-current="page"{% endif %}>
<svg class="cp-navlink__icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 11l3 3L22 4"></path><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg><span>Dienste</span>
</a>
<a class="cp-navlink" href="{{ url_for('dokumente.freigaben') }}"
{% if ep in ['dokumente.freigaben', 'dokumente.liste'] %}aria-current="page"{% endif %}>
<svg class="cp-navlink__icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><path d="M14 2v6h6M9 15l2 2 4-4"></path>
</svg><span>Freigaben</span>
</a>
<a class="cp-navlink" href="{{ url_for('main.team') }}"
{% if ep == 'main.team' %}aria-current="page"{% endif %}>
<svg class="cp-navlink__icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13A4 4 0 0 1 16 11"></path>
</svg><span>Team</span>
</a>
<a class="cp-navlink" href="{{ url_for('chat.kanal', art='ankuendigungen') }}"
{% if bp == 'chat' %}aria-current="page"{% endif %}>
<svg class="cp-navlink__icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg><span>Chat-Verwaltung</span>
</a>
<a class="cp-navlink" href="{{ url_for('profil.index') }}"
{% if ep == 'profil.index' %}aria-current="page"{% endif %}>
<svg class="cp-navlink__icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle>
</svg><span>Profil</span>
</a>
</nav>
<div class="cp-sidebar__footer">
{% include "_theme_toggle.html" %}
<form method="post" action="{{ url_for('auth.logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="cp-navlink cp-navlink--logout">
<svg class="cp-navlink__icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"></path>
</svg><span>Abmelden</span>
</button>
</form>
</div>
</aside>
{% endif %}
<main class="cp-admin-main">
<div class="cp-page">
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul class="cp-flash" role="status">
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</main>
{% if current_user %}
<script src="{{ url_for('static', filename='sidebar.js') }}" defer></script>
{% endif %}
</body>
</html>

View file

@ -0,0 +1,117 @@
<!DOCTYPE html>
<html lang="de"{% if current_theme == 'hell' %} data-theme="light"{% elif current_theme == 'dunkel' %} data-theme="dark"{% endif %}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}CheckPoint Ehrenamt{% endblock %}</title>
{# tokens.css zuerst (Variablen), dann das User-Komponenten-CSS. #}
<link rel="stylesheet" href="{{ url_for('static', filename='tokens.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='components.css') }}">
{# User-Brand-Tokens des aktiven Teams serverseitig überschreiben. |safe wegen
der Anführungszeichen im Font Quelle ist Admin/Seed (kein Nutzer-Input). #}
{% block head_brand %}
{% if current_team %}
<style>
:root {
--cp-user-brand-primary: {{ current_team.brand_primary }};
--cp-user-brand-primary-strong: {{ current_team.brand_primary_strong }};
--cp-user-brand-accent: {{ current_team.brand_accent }};
--cp-user-brand-accent-strong: {{ current_team.brand_accent_strong }};
--cp-user-brand-font: {{ current_team.brand_font | safe }};
}
</style>
{% endif %}
{% endblock %}
</head>
<body class="cp-shell-user{% if current_user %} cp-has-nav{% endif %}">
{% if current_user %}
<header class="cp-topbar">
<a class="cp-topbar__logo" href="{{ url_for('main.index') }}">
{{ current_team.name if current_team else 'CheckPoint' }}
</a>
<div class="cp-topbar__actions">
{% include "_theme_toggle.html" %}
<a class="cp-iconbtn" href="{{ url_for('main.benachrichtigungen') }}"
aria-label="Benachrichtigungen{% if benachrichtigungen_ungelesen %} ({{ benachrichtigungen_ungelesen }} ungelesen){% endif %}">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true">
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.7 21a2 2 0 0 1-3.4 0"></path>
</svg>
{% if benachrichtigungen_ungelesen %}<span class="cp-badge">{{ benachrichtigungen_ungelesen }}</span>{% endif %}
</a>
<a class="cp-avatar" href="{{ url_for('profil.index') }}"
aria-label="Profil von {{ current_user.anzeigename }}">
{% if current_user.profilbild_pfad %}
<img class="cp-avatar__img" src="{{ url_for('profil.bild', user_id=current_user.id) }}" alt="">
{%- else -%}
{%- set teile = current_user.anzeigename.split() -%}
{{ (teile[0][:1] ~ (teile[1][:1] if teile|length > 1 else ''))|upper }}
{% endif %}
</a>
</div>
</header>
{% endif %}
<main class="cp-page">
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul class="cp-flash" role="status">
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
{% if current_user %}
<nav class="cp-bottomnav" aria-label="Hauptnavigation">
{% set ep = request.endpoint %}
<a class="cp-navitem" href="{{ url_for('main.index') }}"
{% if ep == 'main.index' %}aria-current="page"{% endif %}>
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 11l9-8 9 8"></path><path d="M5 10v10h14V10"></path>
</svg>
<span>Start</span>
</a>
<a class="cp-navitem" href="{{ url_for('dienste.meine_termine') }}"
{% if ep in ['dienste.meine_termine', 'planung.verfuegbarkeit'] %}aria-current="page"{% endif %}>
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="2"></rect>
<path d="M16 2v4M8 2v4M3 10h18"></path>
</svg>
<span>Termine</span>
</a>
<a class="cp-navitem" href="{{ url_for('main.team') }}"
{% if ep == 'main.team' %}aria-current="page"{% endif %}>
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13A4 4 0 0 1 16 11"></path>
</svg>
<span>Team</span>
</a>
<a class="cp-navitem" href="{{ url_for('profil.index') }}"
{% if ep == 'profil.index' %}aria-current="page"{% endif %}>
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
<span>Profil</span>
</a>
</nav>
{% endif %}
</body>
</html>

View file

@ -0,0 +1,31 @@
{% extends shell_base %}
{% block title %}Benachrichtigungen · CheckPoint Ehrenamt{% endblock %}
{% block content %}
<h1>Benachrichtigungen</h1>
{% if eintraege %}
<form method="post" action="{{ url_for('main.benachrichtigungen_gelesen') }}" class="cp-actions">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="cp-btn cp-btn--ghost">Alle als gelesen markieren</button>
</form>
<ul class="cp-list">
{% for b in eintraege %}
<li class="cp-card{{ ' cp-card--ungelesen' if not b.gelesen else '' }}">
{% if not b.gelesen %}<span class="cp-tag">Neu</span>{% endif %}
{% if b.url %}
<a href="{{ b.url }}"><strong>{{ b.text }}</strong></a>
{% else %}
<strong>{{ b.text }}</strong>
{% endif %}
<div class="cp-muted">{{ b.erstellt_am | lokalzeit }}</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="cp-muted">Keine Benachrichtigungen. Sobald ein Dienst, ein offener
Platz oder eine Nachricht dazukommt, erscheint sie hier.</p>
{% endif %}
{% endblock %}

82
app/templates/chat.html Normal file
View file

@ -0,0 +1,82 @@
{% extends shell_base %}
{% block title %}{{ label }} · CheckPoint Ehrenamt{% endblock %}
{% block content %}
<p><a class="cp-muted" href="{{ url_for('main.team') }}">← Team</a></p>
<h1>{{ label }}</h1>
{% if kanal.nur_admin_darf_posten %}
<p class="cp-muted">Nur das Team-Management postet hier. Alle lesen mit.</p>
{% endif %}
{# data-* steuern das Polling (siehe Skript unten). neu-base + id = JSON-Endpunkt. #}
<ul class="cp-chat" id="cp-chat-liste"
data-neu-base="{{ url_for('chat.kanal', art=kanal.art) }}/seit/"
data-letzte="{{ letzte_id }}"
data-meine="{{ current_user.id }}">
{% for n in nachrichten %}
<li class="cp-msg{{ ' cp-msg--eigen' if n.autor_id == current_user.id else '' }}">
<div class="cp-msg__meta">{{ n.autor.anzeigename }} · {{ zeit(n.erstellt_am) }}</div>
<div class="cp-msg__text">{{ n.text }}</div>
</li>
{% else %}
<li class="cp-muted" id="cp-chat-leer">Noch keine Nachrichten.</li>
{% endfor %}
</ul>
{% if darf_posten %}
<form method="post" action="{{ url_for('chat.posten', art=kanal.art) }}" class="cp-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="cp-field">
<label for="text">Nachricht</label>
<textarea id="text" name="text" rows="3" maxlength="2000" required
placeholder="Schreib etwas …"></textarea>
</div>
<button type="submit" class="cp-btn cp-btn--primary">Senden</button>
</form>
{% endif %}
{# Einzige v1-JS-Ausnahme: kleines fetch-Polling für neue Nachrichten.
Einfügen ausschließlich per textContent → kein XSS über Nachrichtentext. #}
<script>
(function () {
const liste = document.getElementById("cp-chat-liste");
if (!liste) return;
const basis = liste.dataset.neuBase;
const meine = Number(liste.dataset.meine || 0);
let letzte = Number(liste.dataset.letzte || 0);
function anhaengen(n) {
const leer = document.getElementById("cp-chat-leer");
if (leer) leer.remove();
const li = document.createElement("li");
li.className = "cp-msg" + (n.autor_id === meine || n.eigene ? " cp-msg--eigen" : "");
const meta = document.createElement("div");
meta.className = "cp-msg__meta";
meta.textContent = n.autor + " · " + n.zeit;
const txt = document.createElement("div");
txt.className = "cp-msg__text";
txt.textContent = n.text;
li.appendChild(meta);
li.appendChild(txt);
liste.appendChild(li);
}
async function holen() {
try {
const r = await fetch(basis + letzte, { headers: { "X-Requested-With": "fetch" } });
if (!r.ok) return;
const daten = await r.json();
if (!daten.nachrichten.length) return;
for (const n of daten.nachrichten) {
anhaengen(n);
if (n.id > letzte) letzte = n.id;
}
window.scrollTo(0, document.body.scrollHeight);
} catch (e) { /* stiller Wiederholversuch beim nächsten Intervall */ }
}
setInterval(holen, 5000);
})();
</script>
{% endblock %}

View file

@ -0,0 +1,95 @@
{% extends shell_base %}
{% block title %}Einsatz besetzen · CheckPoint Ehrenamt{% endblock %}
{% from "_zeit.html" import zeitspanne %}
{% macro besetzte_plaetze(zuteilungen, frei, leer_text) %}
{% for z in zuteilungen %}
<li class="cp-card">
<strong>{{ z.user.anzeigename }}</strong>
<form method="post" action="{{ url_for('dienste.entfernen', einsatz_id=einsatz.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="zuteilung_id" value="{{ z.id }}">
<button type="submit" class="cp-btn cp-btn--ghost">Entfernen</button>
</form>
</li>
{% endfor %}
{% for _ in range(frei) %}
<li class="cp-card cp-card--leer cp-muted">{{ leer_text }}</li>
{% endfor %}
{% endmacro %}
{% block content %}
{% if einsatz.planungszeitraum_id %}
<p><a class="cp-muted" href="{{ url_for('dienste.zeitraum', zeitraum_id=einsatz.planungszeitraum_id) }}">← Zurück</a></p>
{% else %}
<p><a class="cp-muted" href="{{ url_for('dienste.liste') }}">← Dienstplan</a></p>
{% endif %}
<h1>{{ einsatz.art }}{% if einsatz.ort %} · {{ einsatz.ort }}{% endif %}</h1>
<div class="cp-muted">
{{ einsatz.datum.strftime('%a, %d.%m.%Y') }} ·
{{ zeitspanne(einsatz) }}
</div>
<div class="cp-muted">
{{ haupt|length }}/{{ einsatz.HAUPT_PLAETZE }} Haupt ·
{{ springer|length }}/{{ einsatz.SPRINGER_PLAETZE }} Springer
</div>
{# Unterbesetzt-Markierung (manuell) #}
<form method="post" action="{{ url_for('dienste.unterbesetzt', einsatz_id=einsatz.id) }}" class="cp-actions">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="cp-btn {{ 'cp-btn--urgent' if einsatz.unterbesetzt else 'cp-btn--ghost' }}">
{{ "Unterbesetzt aufheben" if einsatz.unterbesetzt else "Als unterbesetzt markieren" }}
</button>
</form>
{% if einsatz.unterbesetzt %}
<p class="cp-tag cp-tag--urgent">Unterbesetzt</p>
{% endif %}
<h2>Hauptplätze</h2>
<ul class="cp-list">
{{ besetzte_plaetze(haupt, haupt_frei, "Frei") }}
</ul>
<h2>Springerplatz</h2>
<ul class="cp-list">
{{ besetzte_plaetze(springer, springer_frei, "Frei") }}
</ul>
<h2>Verfügbare Personen</h2>
<p class="cp-muted">Nur Personen, die „Kann" gemeldet haben. Zahlen helfen beim
fairen Verteilen.</p>
<ul class="cp-list">
{% for p in kandidaten %}
{% set f = fairness.get(p.id) %}
<li class="cp-card">
<strong>{{ p.anzeigename }}</strong>
<div class="cp-muted">
{{ (f[0] if f else 0) }} bisherige Einsätze ·
zuletzt: {{ f[1].strftime('%d.%m.%Y') if f and f[1] else '—' }}
</div>
<div class="cp-actions">
{% if haupt_frei > 0 %}
<form method="post" action="{{ url_for('dienste.zuteilen', einsatz_id=einsatz.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="user_id" value="{{ p.id }}">
<input type="hidden" name="platztyp" value="haupt">
<button type="submit" class="cp-btn cp-btn--primary">Als Haupt</button>
</form>
{% endif %}
{% if springer_frei > 0 %}
<form method="post" action="{{ url_for('dienste.zuteilen', einsatz_id=einsatz.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="user_id" value="{{ p.id }}">
<input type="hidden" name="platztyp" value="springer">
<button type="submit" class="cp-btn cp-btn--ghost">Als Springer</button>
</form>
{% endif %}
</div>
</li>
{% else %}
<li class="cp-muted">Niemand hat „Kann" gemeldet (oder alle sind schon zugeteilt).</li>
{% endfor %}
</ul>
{% endblock %}

View file

@ -0,0 +1,47 @@
{% extends shell_base %}
{% from "_zeit.html" import zeitspanne %}
{% block title %}Dienstplan · CheckPoint Ehrenamt{% endblock %}
{% block content %}
<h1>Dienstplan</h1>
<p class="cp-muted">Wähle einen Zeitraum oder Einzeltermin, um Haupt- und
Springerplätze zu besetzen.</p>
<h2>Zeiträume</h2>
<ul class="cp-list">
{% for zr in zeitraeume %}
<li class="cp-card">
<a href="{{ url_for('dienste.zeitraum', zeitraum_id=zr.id) }}">
<strong>{{ zr.name }}</strong>
</a>
<div class="cp-muted">
{{ zr.start_datum.strftime('%d.%m.%Y') }} {{ zr.end_datum.strftime('%d.%m.%Y') }}
</div>
<span class="cp-tag">
{{ "Veröffentlicht" if zr.ist_veroeffentlicht else "In Planung" }}
</span>
</li>
{% else %}
<li class="cp-muted">Noch keine Planungszeiträume.</li>
{% endfor %}
</ul>
<h2>Einzeltermine</h2>
<ul class="cp-list">
{% for e in einzeltermine %}
<li class="cp-card">
<a href="{{ url_for('dienste.einsatz', einsatz_id=e.id) }}">
<strong>{{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %}</strong>
</a>
<div class="cp-muted">
{{ e.datum.strftime('%a, %d.%m.%Y') }} ·
{{ zeitspanne(e) }}
</div>
{% if e.unterbesetzt %}<span class="cp-tag cp-tag--urgent">Unterbesetzt</span>{% endif %}
</li>
{% else %}
<li class="cp-muted">Keine Einzeltermine.</li>
{% endfor %}
</ul>
{% endblock %}

View file

@ -0,0 +1,47 @@
{% extends shell_base %}
{% from "_zeit.html" import zeitspanne %}
{% block title %}{{ zeitraum.name }} · Dienstplan{% endblock %}
{% block content %}
<p><a class="cp-muted" href="{{ url_for('dienste.liste') }}">← Dienstplan</a></p>
<h1>{{ zeitraum.name }}</h1>
<div class="cp-muted">
{{ zeitraum.start_datum.strftime('%d.%m.%Y') }} {{ zeitraum.end_datum.strftime('%d.%m.%Y') }}
</div>
{% if zeitraum.ist_veroeffentlicht %}
<p class="cp-tag">Veröffentlicht · Verfügbarkeit gesperrt</p>
{% else %}
<form method="post"
action="{{ url_for('dienste.veroeffentlichen', zeitraum_id=zeitraum.id) }}"
class="cp-actions"
onsubmit="return confirm('Zeitraum veröffentlichen? Danach ist die Verfügbarkeit gesperrt.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="cp-btn cp-btn--primary">Veröffentlichen</button>
</form>
{% endif %}
<h2>Einsätze</h2>
<ul class="cp-list">
{% for e in zeitraum.einsaetze %}
{% set s = stand[e.id] %}
<li class="cp-card">
<a href="{{ url_for('dienste.einsatz', einsatz_id=e.id) }}">
<strong>{{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %}</strong>
</a>
<div class="cp-muted">
{{ e.datum.strftime('%a, %d.%m.%Y') }} ·
{{ zeitspanne(e) }}
</div>
<div class="cp-muted">
{{ s[0] }}/{{ e.HAUPT_PLAETZE }} Haupt · {{ s[1] }}/{{ e.SPRINGER_PLAETZE }} Springer
</div>
{% if e.unterbesetzt %}<span class="cp-tag cp-tag--urgent">Unterbesetzt</span>{% endif %}
</li>
{% else %}
<li class="cp-muted">Dieser Zeitraum hat noch keine Einsätze.</li>
{% endfor %}
</ul>
{% endblock %}

View file

@ -0,0 +1,99 @@
{% extends shell_base %}
{% block title %}Dokumente · CheckPoint Ehrenamt{% endblock %}
{% macro dokument_karte(d, mit_verschieben=False) %}
<li class="cp-card">
<a href="{{ url_for('dokumente.datei', dok_id=d.id) }}">
<strong>{{ d.titel }}</strong>
</a>
<div class="cp-muted">
{{ d.uploader.anzeigename }} · {{ d.erstellt_am.strftime('%d.%m.%Y') }}
</div>
{% if mit_verschieben and current_user.ist_admin %}
<div class="cp-actions">
<form method="post" action="{{ url_for('dokumente.verschieben', dok_id=d.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="cp-btn cp-btn--ghost">
{{ "Ins Archiv" if d.ablage == "aktuell" else "Zurück zu Aktuell" }}
</button>
</form>
</div>
{% endif %}
</li>
{% endmacro %}
{% block content %}
<p><a class="cp-muted" href="{{ url_for('main.team') }}">← Team</a></p>
<h1>Dokumente</h1>
{% if current_user.ist_admin and offene_freigaben %}
<div class="cp-actions">
<a class="cp-btn cp-btn--primary" href="{{ url_for('dokumente.freigaben') }}">
Freigaben ({{ offene_freigaben }})
</a>
</div>
{% elif current_user.ist_admin %}
<div class="cp-actions">
<a class="cp-btn cp-btn--ghost" href="{{ url_for('dokumente.freigaben') }}">Freigaben</a>
</div>
{% endif %}
<h2>Aktuell</h2>
<ul class="cp-list">
{% for d in aktuell %}
{{ dokument_karte(d, mit_verschieben=True) }}
{% else %}
<li class="cp-muted">Noch keine freigegebenen Dokumente.</li>
{% endfor %}
</ul>
{% if archiv %}
<h2>Archiv</h2>
<ul class="cp-list">
{% for d in archiv %}
{{ dokument_karte(d, mit_verschieben=True) }}
{% endfor %}
</ul>
{% endif %}
{% if eigene_wartend %}
<h2>Meine Uploads in Prüfung</h2>
<p class="cp-muted">Nur du und der Admin sehen diese, bis sie freigegeben sind.</p>
<ul class="cp-list">
{% for d in eigene_wartend %}
<li class="cp-card">
<strong>{{ d.titel }}</strong>
<div class="cp-muted">{{ d.erstellt_am.strftime('%d.%m.%Y') }}</div>
{% if d.status == "abgelehnt" %}
<span class="cp-tag cp-tag--urgent">Abgelehnt</span>
{% if d.ablehnungsgrund %}
<div class="cp-muted">Grund: {{ d.ablehnungsgrund }}</div>
{% endif %}
{% else %}
<span class="cp-tag">Wartet auf Freigabe</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
<h2>Dokument hochladen</h2>
<p class="cp-muted">PDF, Word, Excel oder Bild, max. 10 MB. Fotos nur ohne
erkennbare Dritte bzw. mit deren Einverständnis.</p>
<form method="post" action="{{ url_for('dokumente.upload') }}"
enctype="multipart/form-data" class="cp-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="cp-field">
<label for="titel">Titel</label>
<input type="text" id="titel" name="titel" required
placeholder="z. B. Hygienekonzept 2026">
</div>
<div class="cp-field">
<label for="datei">Datei</label>
<input type="file" id="datei" name="datei" required
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.gif,.webp">
</div>
<button type="submit" class="cp-btn cp-btn--primary">Hochladen</button>
</form>
{% endblock %}

View file

@ -0,0 +1,42 @@
{% extends shell_base %}
{% block title %}Freigaben · CheckPoint Ehrenamt{% endblock %}
{% block content %}
<p><a class="cp-muted" href="{{ url_for('dokumente.liste') }}">← Dokumente</a></p>
<h1>Freigaben</h1>
<p class="cp-muted">Hochgeladene Dokumente, die auf deine Entscheidung warten.</p>
<ul class="cp-list">
{% for d in wartend %}
<li class="cp-card">
<a href="{{ url_for('dokumente.datei', dok_id=d.id) }}">
<strong>{{ d.titel }}</strong>
</a>
<div class="cp-muted">
{{ d.uploader.anzeigename }} · {{ d.erstellt_am.strftime('%d.%m.%Y') }}
</div>
<div class="cp-actions">
<form method="post" action="{{ url_for('dokumente.freigeben', dok_id=d.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="cp-btn cp-btn--primary">Freigeben</button>
</form>
</div>
<form method="post" action="{{ url_for('dokumente.ablehnen', dok_id=d.id) }}"
class="cp-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="cp-field">
<label for="grund-{{ d.id }}">Grund <span class="cp-muted">(optional)</span></label>
<input type="text" id="grund-{{ d.id }}" name="grund"
placeholder="z. B. unscharf, falsche Version">
</div>
<button type="submit" class="cp-btn cp-btn--urgent">Ablehnen</button>
</form>
</li>
{% else %}
<li class="cp-muted">Zurzeit nichts zu prüfen.</li>
{% endfor %}
</ul>
{% endblock %}

View file

@ -0,0 +1,23 @@
{% extends shell_base %}
{% block title %}Einsatz bearbeiten · CheckPoint Ehrenamt{% endblock %}
{% block content %}
<p>
{% if zeitraum %}
<a class="cp-muted" href="{{ url_for('planung.detail', zeitraum_id=zeitraum.id) }}">← {{ zeitraum.name }}</a>
{% else %}
<a class="cp-muted" href="{{ url_for('planung.einzeltermine') }}">← Einzeltermine</a>
{% endif %}
</p>
<h1>Einsatz bearbeiten</h1>
<form method="post"
action="{{ url_for('planung.einsatz_bearbeiten', einsatz_id=einsatz.id) }}"
class="cp-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% include "_einsatz_felder.html" with context %}
<button type="submit" class="cp-btn cp-btn--primary">Speichern</button>
</form>
{% endblock %}

View file

@ -0,0 +1,45 @@
{% extends shell_base %}
{% from "_zeit.html" import zeitspanne %}
{% block title %}Einzeltermine · CheckPoint Ehrenamt{% endblock %}
{% block content %}
<p><a class="cp-muted" href="{{ url_for('planung.liste') }}">← Planung</a></p>
<h1>Einzeltermine</h1>
<p class="cp-muted">Einzelne Termine ohne Planungszeitraum direkt zum Besetzen.</p>
<h2>Termine</h2>
<ul class="cp-list">
{% for e in termine %}
<li class="cp-card">
<strong>{{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %}</strong>
<div class="cp-muted">
{{ e.datum.strftime('%a, %d.%m.%Y') }} ·
{{ zeitspanne(e) }}
</div>
<div class="cp-muted">{{ e.HAUPT_PLAETZE }} Haupt + {{ e.SPRINGER_PLAETZE }} Springer</div>
<div class="cp-actions">
<a class="cp-btn cp-btn--ghost"
href="{{ url_for('planung.einsatz_bearbeiten', einsatz_id=e.id) }}">Bearbeiten</a>
<form method="post"
action="{{ url_for('planung.einsatz_loeschen', einsatz_id=e.id) }}"
onsubmit="return confirm('Diesen Einzeltermin löschen?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="cp-btn cp-btn--ghost">Löschen</button>
</form>
</div>
</li>
{% else %}
<li class="cp-muted">Noch keine Einzeltermine.</li>
{% endfor %}
</ul>
<h2>Einzeltermin hinzufügen</h2>
<form method="post" action="{{ url_for('planung.einzeltermine') }}" class="cp-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% include "_einsatz_felder.html" with context %}
<button type="submit" class="cp-btn cp-btn--primary">Einzeltermin hinzufügen</button>
</form>
{% endblock %}

21
app/templates/index.html Normal file
View file

@ -0,0 +1,21 @@
{% extends shell_base %}
{% block title %}Start · CheckPoint Ehrenamt{% endblock %}
{% block content %}
<h1>Hallo {{ user.anzeigename }}</h1>
{% if user.ist_admin %}
{# Admin-Shell: die Sidebar trägt die Navigation, hier keine Kachel-Buttons. #}
<p class="cp-muted">Willkommen im Verwaltungsbereich. Wähle links eine Aufgabe
Planung, Dienste, Freigaben, Team oder Chat-Verwaltung. Nutzer anlegen findest
du unter „Team".</p>
{% else %}
<p class="cp-muted">Schön, dass du da bist. Deine Einsätze findest du unter
„Termine", Team und Dokumente unter „Team".</p>
<form method="post" action="{{ url_for('auth.logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="cp-btn cp-btn--ghost">Abmelden</button>
</form>
{% endif %}
{% endblock %}

32
app/templates/login.html Normal file
View file

@ -0,0 +1,32 @@
{% extends shell_base %}
{% block title %}Anmelden · CheckPoint Ehrenamt{% endblock %}
{% block content %}
<h1>Anmelden</h1>
<form method="post" action="{{ url_for('auth.login') }}" class="cp-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if mehrere_teams %}
<div class="cp-field">
<label for="team">Team-Kürzel</label>
<input type="text" id="team" name="team"
autocapitalize="none" autocomplete="off" required
placeholder="z. B. checkpoint">
</div>
{% endif %}
<div class="cp-field">
<label for="nutzername">Nutzername</label>
<input type="text" id="nutzername" name="nutzername"
autocomplete="username" autocapitalize="none" required autofocus>
</div>
<div class="cp-field">
<label for="passwort">Passwort</label>
<input type="password" id="passwort" name="passwort"
autocomplete="current-password" required>
</div>
<button type="submit" class="cp-btn cp-btn--primary">Anmelden</button>
</form>
{% endblock %}

View file

@ -0,0 +1,71 @@
{% extends shell_base %}
{% from "_zeit.html" import zeitspanne %}
{% block title %}Meine Termine · CheckPoint Ehrenamt{% endblock %}
{% block content %}
<h1>Meine Termine</h1>
{% if offene_planung %}
<div class="cp-actions">
<a class="cp-btn cp-btn--ghost" href="{{ url_for('planung.verfuegbarkeit') }}">Verfügbarkeit melden</a>
</div>
{% endif %}
<ul class="cp-list">
{% for z, e in termine %}
<li class="cp-card">
<strong>{{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %}</strong>
<div class="cp-muted">
{{ e.datum.strftime('%a, %d.%m.%Y') }} ·
{{ zeitspanne(e) }}
</div>
<div>
<span class="cp-tag">{{ "Haupt" if z.platztyp == "haupt" else "Springer" }}</span>
<span class="cp-tag">{{ "Übernommen" if z.status == "uebernommen" else "Zugeteilt" }}</span>
</div>
<div class="cp-actions">
<form method="post" action="{{ url_for('dienste.absagen', zuteilung_id=z.id) }}"
onsubmit="return confirm('Diesen Dienst absagen?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="cp-btn cp-btn--urgent">Absagen</button>
</form>
</div>
</li>
{% else %}
<li class="cp-muted">Du hast aktuell keine zugeteilten Termine. Sobald ein
Dienstplan veröffentlicht ist, erscheinen deine Einsätze hier.</li>
{% endfor %}
</ul>
{% if offene %}
<h2>Offene Dienste</h2>
<p class="cp-muted">Hier ist jemand abgesprungen. Wer zuerst übernimmt, bekommt
den Platz.</p>
<ul class="cp-list">
{% for z, e in offene %}
<li class="cp-card">
<strong>{{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %}</strong>
<div class="cp-muted">
{{ e.datum.strftime('%a, %d.%m.%Y') }} ·
{{ zeitspanne(e) }}
</div>
<div>
<span class="cp-tag cp-tag--urgent">Offen</span>
<span class="cp-tag">{{ "Haupt" if z.platztyp == "haupt" else "Springer" }}</span>
</div>
{% if e.id in eigene_einsatz_ids %}
<div class="cp-muted">Du bist diesem Einsatz bereits zugeteilt.</div>
{% else %}
<div class="cp-actions">
<form method="post" action="{{ url_for('dienste.uebernehmen', zuteilung_id=z.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="cp-btn cp-btn--primary">Übernehmen</button>
</form>
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,50 @@
{% extends shell_base %}
{% block title %}Nutzer · CheckPoint Ehrenamt{% endblock %}
{% block content %}
<h1>Nutzer anlegen</h1>
<form method="post" action="{{ url_for('auth.nutzer_anlegen') }}" class="cp-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="cp-field">
<label for="anzeigename">Anzeigename</label>
<input type="text" id="anzeigename" name="anzeigename" required>
</div>
<div class="cp-field">
<label for="nutzername">Nutzername</label>
<input type="text" id="nutzername" name="nutzername"
autocapitalize="none" required>
</div>
<div class="cp-field">
<label for="passwort">Passwort</label>
<input type="password" id="passwort" name="passwort"
autocomplete="new-password" required>
</div>
<div class="cp-field">
<label for="rolle">Rolle</label>
<select id="rolle" name="rolle">
<option value="ehrenamt">Ehrenamtliche:r</option>
<option value="admin">Admin</option>
</select>
</div>
<button type="submit" class="cp-btn cp-btn--primary">Anlegen</button>
</form>
<h2>Team</h2>
<ul class="cp-list">
{% for u in nutzer %}
<li>
<strong>{{ u.anzeigename }}</strong>
<span class="cp-muted">· {{ u.nutzername }}</span>
<span class="cp-tag">{{ "Admin" if u.ist_admin else "Ehrenamt" }}</span>
</li>
{% else %}
<li class="cp-muted">Noch keine Nutzer.</li>
{% endfor %}
</ul>
{% endblock %}

View file

@ -0,0 +1,56 @@
{% extends shell_base %}
{% from "_zeit.html" import zeitspanne %}
{% block title %}{{ zeitraum.name }} · CheckPoint Ehrenamt{% endblock %}
{% block content %}
<p><a class="cp-muted" href="{{ url_for('planung.liste') }}">← Planung</a></p>
<h1>{{ zeitraum.name }}</h1>
<div class="cp-muted">
{{ zeitraum.start_datum.strftime('%d.%m.%Y') }} {{ zeitraum.end_datum.strftime('%d.%m.%Y') }}
</div>
<span class="cp-tag">
{{ "Veröffentlicht" if zeitraum.ist_veroeffentlicht else "In Planung" }}
</span>
<h2>Einsätze</h2>
<ul class="cp-list">
{% for e in einsaetze %}
<li class="cp-card">
<strong>{{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %}</strong>
<div class="cp-muted">
{{ e.datum.strftime('%a, %d.%m.%Y') }} ·
{{ zeitspanne(e) }}
</div>
<div class="cp-muted">{{ e.HAUPT_PLAETZE }} Haupt + {{ e.SPRINGER_PLAETZE }} Springer</div>
{% if not zeitraum.ist_veroeffentlicht %}
<div class="cp-actions">
<a class="cp-btn cp-btn--ghost"
href="{{ url_for('planung.einsatz_bearbeiten', einsatz_id=e.id) }}">
Bearbeiten
</a>
<form method="post"
action="{{ url_for('planung.einsatz_loeschen', einsatz_id=e.id) }}"
onsubmit="return confirm('Diesen Einsatz löschen?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="cp-btn cp-btn--ghost">Löschen</button>
</form>
</div>
{% endif %}
</li>
{% else %}
<li class="cp-muted">Noch keine Einsätze.</li>
{% endfor %}
</ul>
{% if not zeitraum.ist_veroeffentlicht %}
<h2>Einsatz hinzufügen</h2>
<form method="post" action="{{ url_for('planung.detail', zeitraum_id=zeitraum.id) }}" class="cp-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% include "_einsatz_felder.html" with context %}
<button type="submit" class="cp-btn cp-btn--primary">Einsatz hinzufügen</button>
</form>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,52 @@
{% extends shell_base %}
{% block title %}Planung · CheckPoint Ehrenamt{% endblock %}
{% block content %}
<h1>Planung</h1>
<div class="cp-actions">
<a class="cp-btn cp-btn--ghost" href="{{ url_for('planung.einzeltermine') }}">Einzeltermine</a>
</div>
<form method="post" action="{{ url_for('planung.liste') }}" class="cp-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="cp-field">
<label for="name">Name des Zeitraums</label>
<input type="text" id="name" name="name" placeholder="z. B. Herbst 2026" required>
</div>
<div class="cp-field">
<label for="start_datum">Start</label>
<input type="date" id="start_datum" name="start_datum" required>
</div>
<div class="cp-field">
<label for="end_datum">Ende</label>
<input type="date" id="end_datum" name="end_datum" required>
<small class="cp-muted">Mindestens 2 Monate.</small>
</div>
<button type="submit" class="cp-btn cp-btn--primary">Zeitraum anlegen</button>
</form>
<h2>Zeiträume</h2>
<ul class="cp-list">
{% for zr in zeitraeume %}
<li class="cp-card">
<a href="{{ url_for('planung.detail', zeitraum_id=zr.id) }}">
<strong>{{ zr.name }}</strong>
</a>
<div class="cp-muted">
{{ zr.start_datum.strftime('%d.%m.%Y') }} {{ zr.end_datum.strftime('%d.%m.%Y') }}
</div>
<span class="cp-tag">
{{ "Veröffentlicht" if zr.ist_veroeffentlicht else "In Planung" }}
</span>
</li>
{% else %}
<li class="cp-muted">Noch keine Planungszeiträume.</li>
{% endfor %}
</ul>
{% endblock %}

View file

@ -0,0 +1,8 @@
{% extends shell_base %}
{% block title %}{{ titel }} · CheckPoint Ehrenamt{% endblock %}
{% block content %}
<h1>{{ titel }}</h1>
<p class="cp-muted">Diese Ansicht kommt in einer der nächsten Sessions.</p>
{% endblock %}

55
app/templates/profil.html Normal file
View file

@ -0,0 +1,55 @@
{% extends shell_base %}
{% block title %}Profil · CheckPoint Ehrenamt{% endblock %}
{% block content %}
<h1>Profil</h1>
<div class="cp-card">
{% if current_user.profilbild_pfad %}
<img class="cp-profilbild" src="{{ url_for('profil.bild', user_id=current_user.id) }}"
alt="Profilbild von {{ current_user.anzeigename }}">
{% else %}
<div class="cp-profilbild cp-profilbild--leer" aria-hidden="true">
{%- set teile = current_user.anzeigename.split() -%}
{{ (teile[0][:1] ~ (teile[1][:1] if teile|length > 1 else ''))|upper }}
</div>
{% endif %}
<strong>{{ current_user.anzeigename }}</strong>
<div class="cp-muted">{{ "Admin" if current_user.ist_admin else "Ehrenamtlich" }}
· {{ current_user.nutzername }}</div>
</div>
<form method="post" action="{{ url_for('profil.index') }}"
enctype="multipart/form-data" class="cp-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="cp-field">
<label for="anzeigename">Anzeigename</label>
<input type="text" id="anzeigename" name="anzeigename" required
value="{{ current_user.anzeigename }}">
</div>
<div class="cp-field">
<label for="profilbild">Profilbild <span class="cp-muted">(optional)</span></label>
<input type="file" id="profilbild" name="profilbild"
accept=".png,.jpg,.jpeg,.gif,.webp">
<div class="cp-muted">PNG, JPG, GIF oder WebP. Ersetzt das bisherige Bild.</div>
</div>
{% for feld in felder %}
<div class="cp-field">
<label for="{{ feld.key }}">{{ feld.label }} <span class="cp-muted">(optional)</span></label>
{% if feld.mehrzeilig %}
<textarea id="{{ feld.key }}" name="{{ feld.key }}" rows="3"
placeholder="{{ feld.placeholder }}">{{ angaben.get(feld.key, '') }}</textarea>
{% else %}
<input type="text" id="{{ feld.key }}" name="{{ feld.key }}"
placeholder="{{ feld.placeholder }}" value="{{ angaben.get(feld.key, '') }}">
{% endif %}
</div>
{% endfor %}
<button type="submit" class="cp-btn cp-btn--primary">Speichern</button>
</form>
{% endblock %}

29
app/templates/team.html Normal file
View file

@ -0,0 +1,29 @@
{% extends shell_base %}
{% block title %}Team · CheckPoint Ehrenamt{% endblock %}
{% block content %}
<h1>Team</h1>
<ul class="cp-list">
<li class="cp-card">
<a href="{{ url_for('chat.kanal', art='ankuendigungen') }}"><strong>Ankündigungen</strong></a>
<div class="cp-muted">Infos vom Team-Management. Alle lesen mit.</div>
</li>
<li class="cp-card">
<a href="{{ url_for('chat.kanal', art='team') }}"><strong>Team-Chat</strong></a>
<div class="cp-muted">Austausch der Gruppe alle schreiben mit.</div>
</li>
<li class="cp-card">
<a href="{{ url_for('dokumente.liste') }}"><strong>Dokumente</strong></a>
<div class="cp-muted">Geteilte Dateien und Fotos der Gruppe.</div>
</li>
{% if current_user.ist_admin %}
<li class="cp-card">
<a href="{{ url_for('auth.nutzer_anlegen') }}"><strong>Nutzer anlegen</strong></a>
<div class="cp-muted">Neuen Zugang für das Team einrichten (nur Admin).</div>
</li>
{% endif %}
</ul>
{% endblock %}

View file

@ -0,0 +1,57 @@
{% extends shell_base %}
{% from "_zeit.html" import zeitspanne %}
{% block title %}Termine · CheckPoint Ehrenamt{% endblock %}
{% block content %}
<h1>Verfügbarkeit</h1>
{% if not einsaetze %}
<p class="cp-muted">Zurzeit gibt es nichts zu melden. Sobald die Planung
startet oder ein Einzeltermin ansteht, kannst du hier pro Einsatz „Kann"
oder „Kann nicht" melden.</p>
{% else %}
{% if zeitraum %}
<p class="cp-muted">{{ zeitraum.name }} ·
{{ zeitraum.start_datum.strftime('%d.%m.%Y') }}{{ zeitraum.end_datum.strftime('%d.%m.%Y') }}</p>
{% endif %}
<p class="cp-muted">Melde pro Einsatz, ob du kannst. Änderbar, solange geplant wird.</p>
<ul class="cp-list">
{% for e in einsaetze %}
{% set wert = eigene.get(e.id) %}
<li class="cp-card">
<strong>{{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %}</strong>
<div class="cp-muted">
{{ e.datum.strftime('%a, %d.%m.%Y') }} ·
{{ zeitspanne(e) }}
</div>
<form method="post"
action="{{ url_for('planung.verfuegbarkeit_setzen', einsatz_id=e.id) }}"
class="cp-toggle" role="group" aria-label="Verfügbarkeit für {{ e.art }} am {{ e.datum.strftime('%d.%m.%Y') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" name="wert" value="kann"
class="cp-toggle__btn {{ 'is-active' if wert == 'kann' else '' }}"
aria-pressed="{{ 'true' if wert == 'kann' else 'false' }}">
{{ '✓ ' if wert == 'kann' else '' }}Kann
</button>
<button type="submit" name="wert" value="kann_nicht"
class="cp-toggle__btn {{ 'is-active' if wert == 'kann_nicht' else '' }}"
aria-pressed="{{ 'true' if wert == 'kann_nicht' else 'false' }}">
{{ '✓ ' if wert == 'kann_nicht' else '' }}Kann nicht
</button>
</form>
<div class="cp-muted" aria-live="polite">
{% if wert == 'kann' %}Gemeldet: Kann
{% elif wert == 'kann_nicht' %}Gemeldet: Kann nicht
{% else %}Noch nicht gemeldet{% endif %}
</div>
</li>
{% else %}
<li class="cp-muted">In diesem Zeitraum gibt es noch keine Einsätze.</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

18
deploy/backup-db.sh Normal file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Einfaches SQLite-Backup für CheckPoint Ehrenamt.
# Nutzt das Online-Backup (.backup) konsistent auch im laufenden Betrieb (WAL).
# Per Cron einmal täglich, siehe DEPLOY.md.
set -euo pipefail
DB="/opt/checkpoint/instance/checkpoint.sqlite"
ZIEL="/opt/checkpoint/backups"
AUFBEWAHRUNG_TAGE=14
mkdir -p "$ZIEL"
STAMP="$(date +%Y%m%d-%H%M%S)"
sqlite3 "$DB" ".backup '$ZIEL/checkpoint-$STAMP.sqlite'"
# Alte Backups aufräumen.
find "$ZIEL" -name 'checkpoint-*.sqlite' -mtime "+$AUFBEWAHRUNG_TAGE" -delete
echo "Backup: $ZIEL/checkpoint-$STAMP.sqlite"

23
deploy/checkpoint.service Normal file
View file

@ -0,0 +1,23 @@
[Unit]
Description=CheckPoint Ehrenamt (gunicorn)
After=network.target
[Service]
# Eigener, nicht privilegierter Nutzer (siehe DEPLOY.md).
User=checkpoint
Group=checkpoint
WorkingDirectory=/opt/checkpoint
# Geheimnisse + CP_ENV kommen aus der Datei (nicht im Git): SECRET_KEY, CP_ENV.
EnvironmentFile=/opt/checkpoint/.env
ExecStart=/opt/checkpoint/.venv/bin/gunicorn -c gunicorn.conf.py wsgi:app
Restart=on-failure
RestartSec=3
# Etwas Härtung des Dienstes.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/opt/checkpoint/instance
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,31 @@
# nginx-Serverblock für CheckPoint Ehrenamt (Reverse Proxy vor gunicorn).
# Ablage: /etc/nginx/sites-available/checkpoint → nach sites-enabled verlinken.
# certbot ergänzt den 443-Block + Zertifikate automatisch (siehe DEPLOY.md).
# checkpoint.example.org überall durch die echte Domain ersetzen.
server {
listen 80;
listen [::]:80;
server_name checkpoint.example.org;
# Muss >= MAX_CONTENT_LENGTH der App (10 MB) sein, sonst lehnt nginx Uploads
# vor der App mit 413 ab.
client_max_body_size 12m;
# Statische Dateien (CSS, JS, Schriften) direkt von nginx ausliefern.
# Uploads/Dokumente NICHT hier ausliefern die laufen zugriffsgeschützt
# über die App (instance/uploads ist bewusst nicht öffentlich).
location /static/ {
alias /opt/checkpoint/app/static/;
expires 30d;
access_log off;
}
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

19
gunicorn.conf.py Normal file
View file

@ -0,0 +1,19 @@
"""gunicorn-Konfiguration für CheckPoint Ehrenamt.
Start (über systemd, siehe deploy/checkpoint.service):
gunicorn -c gunicorn.conf.py wsgi:app
"""
# Nur lokal lauschen nginx ist der öffentliche Eingang (Reverse Proxy).
bind = "127.0.0.1:8000"
# Klein halten: 17 Nutzer, ein VPS. Mehrere Worker sind dank SQLite-WAL ok.
workers = 3
threads = 2
worker_class = "gthread"
timeout = 60
# Logs an stdout/stderr → systemd/journald fängt sie auf.
accesslog = "-"
errorlog = "-"
loglevel = "info"

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
Flask
Flask-SQLAlchemy
gunicorn

11
run.py Normal file
View file

@ -0,0 +1,11 @@
"""Entrypoint. Startet die App über die Factory.
Lokal: flask --app run run (oder) python run.py
"""
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run(debug=True)

57
seed.py Normal file
View file

@ -0,0 +1,57 @@
"""Seed-Skript.
Legt das erste Team (CheckPoint) mit seinen Brand-Tokens und den zwei festen
Kanälen (ankuendigungen, team) an. Idempotent: mehrfaches Ausführen erzeugt
keine Duplikate.
Aufruf (pyenv-Umgebung):
python seed.py
"""
from app import create_app, db
from app.models import Kanal, Team
CHECKPOINT_NAME = "CheckPoint"
CHECKPOINT_SLUG = "checkpoint"
def seed():
app = create_app()
with app.app_context():
db.create_all()
team = Team.query.filter_by(name=CHECKPOINT_NAME).first()
if team is None:
team = Team(
name=CHECKPOINT_NAME,
slug=CHECKPOINT_SLUG,
brand_primary="#21a8ad",
brand_primary_strong="#147f84",
brand_accent="#ef1823",
brand_accent_strong="#bd0f18",
brand_font='"DM Sans", Arial, Helvetica, sans-serif',
)
db.session.add(team)
db.session.flush() # team.id verfügbar machen
print(f"Team angelegt: {team.name} (id {team.id})")
else:
if not team.slug:
team.slug = CHECKPOINT_SLUG
print(f"Team-Kürzel nachgetragen: {team.slug}")
print(f"Team existiert bereits: {team.name} (id {team.id})")
# Die zwei festen Kanäle sicherstellen.
for art in (Kanal.ART_ANKUENDIGUNGEN, Kanal.ART_TEAM):
vorhanden = Kanal.query.filter_by(team_id=team.id, art=art).first()
if vorhanden is None:
db.session.add(Kanal(team_id=team.id, art=art))
print(f"Kanal angelegt: {art}")
else:
print(f"Kanal existiert bereits: {art}")
db.session.commit()
print("Seed fertig.")
if __name__ == "__main__":
seed()

334
tokens.css Normal file
View file

@ -0,0 +1,334 @@
/* =============================================================================
CheckPoint Ehrenamt · tokens.css
Mandantenfähiges Token-System mit Hell- und Dunkelmodus.
ZWEI ROLLEN-THEMES IN EINER DATEI: User (Mobile, Bottom-Nav) und
Admin (Desktop-Sidebar, mobil per Off-Canvas). Jedes Theme ist für sich
mandantenfähig ein neues Team tauscht beide Brand-Blöcke.
AUFBAU (pro Theme dieselben drei Schichten):
1) BRAND pro Team austauschbar. Ein zweites Team ändert NUR diese Werte.
2) BASE theme-unabhängig: Abstände, Radien, Schrift, Transitions.
3) SEMANTIK hell + dunkel in einem, gelöst über die CSS-Funktion light-dark().
NAMENSRAUM:
--cp-user-* Token für die Ehrenamtlichen-Oberfläche (Bottom-Nav-Shell)
--cp-admin-* Token für die Admin-Oberfläche (Sidebar-Shell)
Komponenten-CSS wählt je Shell den passenden Namensraum. Es gibt keine
gemeinsamen Farb-Tokens mehr ohne Präfix das verhindert versehentliches
Mischen der beiden Looks.
THEME-STEUERUNG (kein JavaScript nötig):
- Standard folgt der Systemeinstellung (prefers-color-scheme) automatisch.
- Optionaler manueller Override später: <html data-theme="dark"> bzw. "light".
Hinweis: Diese Datei definiert nur Variablen. Komponenten-CSS muss diese
Tokens verwenden (z. B. background: var(--cp-user-surface)), damit der
Dunkelmodus wirklich greift nirgends feste Weiß-/Schwarzwerte verdrahten.
============================================================================= */
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;500;700&family=Roboto+Mono:wght@400;500&family=Alfa+Slab+One&display=swap");
/* #############################################################################
TEIL A · USER-THEME (Ehrenamtliche, Mobile, Bottom-Nav)
############################################################################# */
/* =============================================================================
A.1 · BRAND (pro Mandant überschreiben User-Theme)
----------------------------------------------------------------------------
Für ein weiteres Team genügt es, diesen Block zu ersetzen am besten
serverseitig pro Team eingespielt, z. B.:
<style>
:root {
--cp-user-brand-primary: #21a8ad;
--cp-user-brand-primary-strong: #147f84;
--cp-user-brand-accent: #ef1823;
--cp-user-brand-accent-strong: #bd0f18;
--cp-user-brand-font: "DM Sans", Arial, Helvetica, sans-serif;
}
</style>
Alle getönten Flächen, Fokus-Ringe und Status-Farben werden aus diesen
wenigen Werten abgeleitet das Team muss nichts weiter pflegen.
============================================================================= */
:root {
--cp-user-brand-primary: #21a8ad; /* Türkis Hauptfarbe */
--cp-user-brand-primary-strong: #147f84; /* Türkis dunkel Links, starke Akzente */
--cp-user-brand-accent: #ef1823; /* Rot dringende Aktionen, Warnungen */
--cp-user-brand-accent-strong: #bd0f18; /* Rot dunkel Hover, starke Akzente */
--cp-user-brand-font: "DM Sans", Arial, Helvetica, sans-serif;
}
/* =============================================================================
A.2 · BASE (theme-unabhängig User-Theme)
============================================================================= */
:root {
--cp-user-font: var(--cp-user-brand-font);
--cp-user-radius-sm: 6px;
--cp-user-radius-md: 8px;
--cp-user-radius-lg: 12px;
--cp-user-radius-xl: 16px;
--cp-user-radius-pill: 999px;
--cp-user-space-1: 0.25rem;
--cp-user-space-2: 0.5rem;
--cp-user-space-3: 0.75rem;
--cp-user-space-4: 1rem;
--cp-user-space-5: 1.25rem;
--cp-user-space-6: 1.5rem;
--cp-user-space-8: 2rem;
--cp-user-transition: 160ms ease;
}
/* =============================================================================
A.3 · SEMANTIK (hell + dunkel via light-dark User-Theme)
----------------------------------------------------------------------------
light-dark(HELL, DUNKEL) liefert automatisch den passenden Wert, sobald
color-scheme aktiv ist. Reihenfolge der Argumente: immer HELL zuerst.
============================================================================= */
:root {
/* --- Flächen & Hintergründe ------------------------------------------- */
--cp-user-bg: light-dark(#f5f7f8, #0f1518); /* Seitenhintergrund */
--cp-user-surface: light-dark(#ffffff, #161f22); /* Karten, Header, Formulare */
--cp-user-surface-alt: light-dark(#f0f5f6, #1d272b); /* neutrale Chips, abgesetzt */
/* --- Text -------------------------------------------------------------- */
--cp-user-text: light-dark(#253033, #e8eef0); /* Haupttext */
--cp-user-muted: light-dark(#66777c, #9fb1b5); /* Zusatz-/Meta-Text */
--cp-user-faint: light-dark(#9aabad, #6b7c80); /* inaktiv, sehr leise */
/* --- Linien ------------------------------------------------------------ */
--cp-user-line: light-dark(rgba(37, 48, 51, 0.13), rgba(232, 238, 240, 0.14));
/* --- Markenfarben (Buttons, aktive Zustände) -------------------------- */
--cp-user-teal: var(--cp-user-brand-primary);
--cp-user-red: var(--cp-user-brand-accent);
/* "dark"-Varianten = starke Akzent-/Textfarbe. Im Dunkelmodus heller,
damit sie auf dunklem Grund lesbar bleibt. */
--cp-user-teal-dark: light-dark(var(--cp-user-brand-primary-strong), #5fd0d4);
--cp-user-red-dark: light-dark(var(--cp-user-brand-accent-strong), #ff6b73);
/* --- Getönte Flächen (aus der Markenfarbe abgeleitet) ----------------- */
--cp-user-teal-soft: light-dark(
color-mix(in srgb, var(--cp-user-brand-primary) 14%, white),
color-mix(in srgb, var(--cp-user-brand-primary) 26%, transparent)
);
--cp-user-red-soft: light-dark(
color-mix(in srgb, var(--cp-user-brand-accent) 12%, white),
color-mix(in srgb, var(--cp-user-brand-accent) 26%, transparent)
);
/* --- Info (sekundäre Infoflächen) -------------------------------------- */
--cp-user-info: light-dark(#2b86c5, #6fb7e6);
--cp-user-blue-soft: light-dark(#dff4fb, color-mix(in srgb, #4aa3dd 22%, transparent));
/* --- Fokus-Ring (aus der Markenfarbe abgeleitet) ---------------------- */
--cp-user-focus: light-dark(
color-mix(in srgb, var(--cp-user-brand-primary) 34%, transparent),
color-mix(in srgb, var(--cp-user-brand-primary) 55%, transparent)
);
/* --- Schatten --------------------------------------------------------- */
--cp-user-shadow-color: light-dark(rgba(20, 52, 58, 0.12), rgba(0, 0, 0, 0.55));
--cp-user-shadow-sm: 0 1px 3px var(--cp-user-shadow-color);
--cp-user-shadow-md: 0 10px 28px var(--cp-user-shadow-color);
/* --- Status-Mapping (semantisch, für Badges/Hinweise) ----------------- */
/* Türkis = offen/bestätigt/verfügbar · Rot = dringend/Vertretung ·
Neutral = Info/intern/erledigt. */
--cp-user-status-ok-bg: var(--cp-user-teal-soft);
--cp-user-status-ok-text: var(--cp-user-teal-dark);
--cp-user-status-urgent-bg: var(--cp-user-red-soft);
--cp-user-status-urgent-text: var(--cp-user-red-dark);
--cp-user-status-neutral-bg: var(--cp-user-surface-alt);
--cp-user-status-neutral-text: var(--cp-user-muted);
}
/* #############################################################################
TEIL B · ADMIN-THEME (Sidebar-Verwaltungsoberfläche, Desktop + mobiles
Off-Canvas-Menü). Eigener, vollständig getrennter Tokensatz bewusst kein
Vermischen mit den User-Tokens, damit ein Team beide Looks unabhängig
pflegen oder austauschen kann.
############################################################################# */
/* =============================================================================
B.1 · BRAND (pro Mandant überschreiben Admin-Theme)
----------------------------------------------------------------------------
Quelle: "Checkpoint Koeln UI Corporate Design". Ein zweites Team ersetzt
nur diesen Block plus die Schrift-Imports oben in der Datei.
============================================================================= */
:root {
--cp-admin-brand-navy: #1b1f30; /* Sidebar, Tabellenkopf */
--cp-admin-brand-navy-soft: #252a3d; /* Sidebar-Trenner, dunkler Hover */
--cp-admin-brand-teal: #01b2bb; /* Hauptmarkenfarbe, aktive Nav */
--cp-admin-brand-teal-ui: #11b6c9; /* Primärbuttons, Floating Actions */
--cp-admin-brand-teal-dark: #139ead; /* Hover, aktive Buttons */
--cp-admin-brand-yellow: #fbef1f; /* Sidebar-Icons, Markenhighlight */
--cp-admin-brand-pink: #e6037f; /* Logo-/Markenakzent */
--cp-admin-brand-red: #e62143; /* Fehler, Abbruch, Warnung */
--cp-admin-brand-red-dark: #c80f3d; /* Danger-Buttons, Hover */
--cp-admin-brand-orange: #f59e18; /* sekundäre Hinweise */
--cp-admin-brand-green: #168a2d; /* Erfolg, verfügbar, bestätigt */
--cp-admin-brand-font-display: "Alfa Slab One", "Roboto Slab", Georgia, serif;
--cp-admin-brand-font-ui: "Roboto Condensed", "Arial Narrow", Arial, sans-serif;
}
/* =============================================================================
B.2 · BASE (theme-unabhängig Admin-Theme)
============================================================================= */
:root {
--cp-admin-font-display: var(--cp-admin-brand-font-display);
--cp-admin-font-ui: var(--cp-admin-brand-font-ui);
--cp-admin-font-mono: "Roboto Mono", Consolas, monospace;
--cp-admin-radius-sm: 6px;
--cp-admin-radius-md: 10px;
--cp-admin-radius-lg: 14px;
--cp-admin-space-1: 4px;
--cp-admin-space-2: 8px;
--cp-admin-space-3: 12px;
--cp-admin-space-4: 16px;
--cp-admin-space-5: 24px;
--cp-admin-space-6: 32px;
--cp-admin-sidebar-width: 260px;
--cp-admin-transition: 160ms ease;
}
/* =============================================================================
B.3 · SEMANTIK (hell + dunkel via light-dark Admin-Theme)
----------------------------------------------------------------------------
Das Quelldokument beschreibt nur einen hellen Look (helle Arbeitsfläche +
dunkle Sidebar). Die Sidebar bleibt darum in BEIDEN Modi dunkel (Navy)
das ist so gewollt, sie ist ja schon das "dunkle Element" im Hellmodus.
Im Dunkelmodus wird nur die Arbeitsfläche dunkler, die Sidebar bleibt das
immer gleiche Marken-Navy. So muss am Logo/Sidebar-Look nichts wechseln.
============================================================================= */
:root {
/* --- Sidebar (in beiden Themes gleich dunkel = Markenelement) ---------- */
--cp-admin-sidebar-bg: var(--cp-admin-brand-navy);
--cp-admin-sidebar-hover: var(--cp-admin-brand-navy-soft);
--cp-admin-sidebar-text: light-dark(rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.92));
--cp-admin-sidebar-text-muted: light-dark(rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.6));
--cp-admin-sidebar-icon: var(--cp-admin-brand-yellow);
--cp-admin-sidebar-active-bg: var(--cp-admin-brand-teal);
--cp-admin-sidebar-active-text: #ffffff;
/* --- Arbeitsfläche & Flächen -------------------------------------------- */
--cp-admin-bg: light-dark(#f5f7fa, #0e1320); /* Seitenhintergrund */
--cp-admin-bg-cool: light-dark(#f5fafb, #0c1722); /* kühlerer Arbeitsbereich */
--cp-admin-surface: light-dark(#ffffff, #161c2b); /* Karten, Panels, Formulare */
--cp-admin-surface-soft: light-dark(#eef9fb, #16242c); /* Panel-Header, Akkordeons */
/* --- Text ---------------------------------------------------------------- */
--cp-admin-text: light-dark(#1f2b37, #e6ecf1); /* Haupttext */
--cp-admin-heading: light-dark(#090b10, #f5f8fa); /* Seitentitel, starke Headlines */
--cp-admin-muted: light-dark(#6e8190, #93a3b0); /* Labels, Meta-Informationen */
--cp-admin-muted-2: light-dark(#9ba7b1, #6b7a85); /* inaktive Icons, sehr leise */
/* --- Linien --------------------------------------------------------------- */
--cp-admin-line: light-dark(#d6e6ec, rgba(214, 230, 236, 0.18)); /* Rahmen, Input-Borders */
--cp-admin-line-soft: light-dark(#e8eef2, rgba(214, 230, 236, 0.1)); /* Tabellenlinien */
/* --- Markenfarben (Buttons, aktive Zustände, in beiden Themes gleich) --- */
--cp-admin-teal: var(--cp-admin-brand-teal);
--cp-admin-teal-ui: var(--cp-admin-brand-teal-ui);
--cp-admin-teal-dark: light-dark(var(--cp-admin-brand-teal-dark), #5fd6e0);
--cp-admin-teal-soft: light-dark(
color-mix(in srgb, var(--cp-admin-brand-teal) 10%, white),
color-mix(in srgb, var(--cp-admin-brand-teal) 24%, transparent)
);
--cp-admin-pink: var(--cp-admin-brand-pink);
--cp-admin-red: var(--cp-admin-brand-red);
--cp-admin-red-dark: light-dark(var(--cp-admin-brand-red-dark), #ff7a93);
--cp-admin-red-soft: light-dark(
color-mix(in srgb, var(--cp-admin-brand-red) 10%, white),
color-mix(in srgb, var(--cp-admin-brand-red) 24%, transparent)
);
--cp-admin-orange: var(--cp-admin-brand-orange);
--cp-admin-orange-soft: light-dark(
color-mix(in srgb, var(--cp-admin-brand-orange) 12%, white),
color-mix(in srgb, var(--cp-admin-brand-orange) 24%, transparent)
);
--cp-admin-green: var(--cp-admin-brand-green);
--cp-admin-green-soft: light-dark(#e5f5e6, color-mix(in srgb, var(--cp-admin-brand-green) 24%, transparent));
/* --- Fokus-Ring ------------------------------------------------------------ */
--cp-admin-focus: light-dark(
color-mix(in srgb, var(--cp-admin-brand-teal) 34%, transparent),
color-mix(in srgb, var(--cp-admin-brand-teal) 55%, transparent)
);
/* --- Schatten ---------------------------------------------------------- */
--cp-admin-shadow-color: light-dark(rgba(27, 31, 48, 0.06), rgba(0, 0, 0, 0.5));
--cp-admin-shadow-soft: 0 2px 8px var(--cp-admin-shadow-color);
--cp-admin-shadow-action: light-dark(0 4px 10px rgba(1, 178, 187, 0.25), 0 4px 10px rgba(1, 178, 187, 0.35));
/* --- Chip/Badge-Status (Text immer mitgeben, nie nur Farbe) ------------- */
--cp-admin-status-neutral-bg: light-dark(#e9eef2, color-mix(in srgb, var(--cp-admin-muted-2) 22%, transparent));
--cp-admin-status-neutral-text: var(--cp-admin-text);
--cp-admin-status-success-bg: var(--cp-admin-green-soft);
--cp-admin-status-success-text: var(--cp-admin-green);
--cp-admin-status-info-bg: var(--cp-admin-teal-soft);
--cp-admin-status-info-text: var(--cp-admin-teal-dark);
--cp-admin-status-warning-bg: var(--cp-admin-orange-soft);
--cp-admin-status-warning-text: light-dark(#8a5a0a, #ffc169);
--cp-admin-status-error-bg: var(--cp-admin-red-soft);
--cp-admin-status-error-text: var(--cp-admin-red-dark);
}
/* #############################################################################
TEIL C · GLOBALE STEUERUNG (gilt für beide Themes)
############################################################################# */
/* =============================================================================
C.1 · Hell/Dunkel aktivieren + manueller Override
----------------------------------------------------------------------------
Setzt das Attribut data-theme die color-scheme fest, "gewinnt" diese gegen
die Systemeinstellung ganz ohne doppelte Token-Definitionen.
Ein späterer Umschalter müsste nur document.documentElement.dataset.theme
setzen (winziges Vanilla-JS, Future Log).
============================================================================= */
:root {
color-scheme: light dark;
}
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
/* =============================================================================
C.2 · Shell-Auswahl
----------------------------------------------------------------------------
Jede Shell-Vorlage setzt auf ihrem Wurzelelement eine der beiden Klassen.
So lesen Komponenten in jeder Shell automatisch die richtigen Tokens, ohne
dass irgendwo "user" oder "admin" hartkodiert werden muss:
<body class="cp-shell-user"> in base_user.html
<body class="cp-shell-admin"> in base_admin.html
Innerhalb von components.css kann dadurch z. B. .cp-shell-user .cp-card
bzw. .cp-shell-admin .cp-card jeweils ihre eigenen Tokens nutzen, falls
geteilte Klassennamen je Shell unterschiedlich aussehen sollen.
============================================================================= */
.cp-shell-user {
background: var(--cp-user-bg);
color: var(--cp-user-text);
font-family: var(--cp-user-font);
}
.cp-shell-admin {
background: var(--cp-admin-bg);
color: var(--cp-admin-text);
font-family: var(--cp-admin-font-ui);
}
html {
color-scheme: inherit;
}

9
wsgi.py Normal file
View file

@ -0,0 +1,9 @@
"""WSGI-Entrypoint für die Produktion (gunicorn wsgi:app).
Kein debug, keine Reloader-Logik das macht der Prozess-Manager (systemd).
Geheimnisse kommen aus der Umgebung (CP_ENV=production, SECRET_KEY), siehe DEPLOY.md.
"""
from app import create_app
app = create_app()