diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..37559ea --- /dev/null +++ b/.env.example @@ -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 diff --git a/CLAUDE-backup.md b/CLAUDE-backup.md new file mode 100644 index 0000000..4349596 --- /dev/null +++ b/CLAUDE-backup.md @@ -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 360–460px. +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 `` 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 `` (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 360–460px 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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..649e73f --- /dev/null +++ b/CLAUDE.md @@ -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 360–460px. +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 `` 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 360–460px) | 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 `` (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 360–460px, 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 8–12px (max. 14px), Innenabstand 20–30px, 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. diff --git a/CheckPoint-Ehrenamt.png b/CheckPoint-Ehrenamt.png new file mode 100644 index 0000000..ae81ca7 Binary files /dev/null and b/CheckPoint-Ehrenamt.png differ diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..b16eb1f --- /dev/null +++ b/DEPLOY.md @@ -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 /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=`. + +## 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 7–8 +- [ ] 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. +``` diff --git a/Konzept-CheckPoint-Ehrenamt.md b/Konzept-CheckPoint-Ehrenamt.md new file mode 100644 index 0000000..6102b28 --- /dev/null +++ b/Konzept-CheckPoint-Ehrenamt.md @@ -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. 360–460px, 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. diff --git a/Stakeholder-CheckPoint-Ehrenamt.md b/Stakeholder-CheckPoint-Ehrenamt.md new file mode 100644 index 0000000..d65b491 --- /dev/null +++ b/Stakeholder-CheckPoint-Ehrenamt.md @@ -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. diff --git a/TASKS-backup.md b/TASKS-backup.md new file mode 100644 index 0000000..cc9520e --- /dev/null +++ b/TASKS-backup.md @@ -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 `` 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 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 `` +- [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 + `` einbauen. +- [ ] **Brand-Tokens im `` 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 / ``-Ausbruch + verhindern) – z. B. Hex-Farben per Regex prüfen, Font auf Whitelist begrenzen. diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..8918a1e --- /dev/null +++ b/TASKS.md @@ -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 `` 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 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 `` +- [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 ``, +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 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 `` +- [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. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..6eed55c --- /dev/null +++ b/app/__init__.py @@ -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 diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..fd135b3 --- /dev/null +++ b/app/auth.py @@ -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 ).""" + 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) diff --git a/app/csrf.py b/app/csrf.py new file mode 100644 index 0000000..1c99b89 --- /dev/null +++ b/app/csrf.py @@ -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: + +""" + +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 diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..925260f --- /dev/null +++ b/app/models.py @@ -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 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"" + + +# ============================================================================= +# 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"" + + +# ============================================================================= +# 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"" + + +# ============================================================================= +# 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"" + + +# ============================================================================= +# 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"" + + +# ============================================================================= +# 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"" + + +# ============================================================================= +# 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"" + + +# ============================================================================= +# 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"" + + +# ============================================================================= +# 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"" + + +# ============================================================================= +# 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"" diff --git a/app/notifications.py b/app/notifications.py new file mode 100644 index 0000000..8966825 --- /dev/null +++ b/app/notifications.py @@ -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, + ) + ) diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..0452539 --- /dev/null +++ b/app/routes/__init__.py @@ -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")) diff --git a/app/routes/chat.py b/app/routes/chat.py new file mode 100644 index 0000000..5318e14 --- /dev/null +++ b/app/routes/chat.py @@ -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//seit/) – 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("/") +@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("/", 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("//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]) diff --git a/app/routes/dienste.py b/app/routes/dienste.py new file mode 100644 index 0000000..bb29c0b --- /dev/null +++ b/app/routes/dienste.py @@ -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//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/") +@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/") +@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//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//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//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//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//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")) diff --git a/app/routes/dokumente.py b/app/routes/dokumente.py new file mode 100644 index 0000000..2b1ec14 --- /dev/null +++ b/app/routes/dokumente.py @@ -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//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("//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("//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("//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("//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")) diff --git a/app/routes/planung.py b/app/routes/planung.py new file mode 100644 index 0000000..3a34d6d --- /dev/null +++ b/app/routes/planung.py @@ -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("/", 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//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//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/", 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, + } diff --git a/app/routes/profil.py b/app/routes/profil.py new file mode 100644 index 0000000..584bdf0 --- /dev/null +++ b/app/routes/profil.py @@ -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/") +@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) diff --git a/app/static/components-admin.css b/app/static/components-admin.css new file mode 100644 index 0000000..ae56e9a --- /dev/null +++ b/app/static/components-admin.css @@ -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; } diff --git a/app/static/components.css b/app/static/components.css new file mode 100644 index 0000000..3472d40 --- /dev/null +++ b/app/static/components.css @@ -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 360–460px, 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 + diff --git a/app/templates/_zeit.html b/app/templates/_zeit.html new file mode 100644 index 0000000..b1a63dc --- /dev/null +++ b/app/templates/_zeit.html @@ -0,0 +1,6 @@ +{# Einheitliche Zeit-Anzeige: Start, optional –Ende, optional (ca. Dauer). + Beispiele: "18:00 (ca. 2 Std)" wird zu "18:00–20: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 -%} diff --git a/app/templates/base_admin.html b/app/templates/base_admin.html new file mode 100644 index 0000000..c2154bc --- /dev/null +++ b/app/templates/base_admin.html @@ -0,0 +1,144 @@ + + + + + + {% block title %}CheckPoint Ehrenamt{% endblock %} + + {# tokens.css zuerst (Variablen), dann das Admin-Komponenten-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 %} + + + + {% if current_user %} + {# Mobile: schmale Topbar mit Hamburger; Sidebar wird zum Off-Canvas-Overlay. #} +
+ + {{ current_team.name if current_team else 'CheckPoint' }} + + + {% if benachrichtigungen_ungelesen %}{{ benachrichtigungen_ungelesen }}{% endif %} + +
+ + + + + {% endif %} + +
+
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+
+ + {% if current_user %} + + {% endif %} + + diff --git a/app/templates/base_user.html b/app/templates/base_user.html new file mode 100644 index 0000000..d758b7d --- /dev/null +++ b/app/templates/base_user.html @@ -0,0 +1,117 @@ + + + + + + {% block title %}CheckPoint Ehrenamt{% endblock %} + + {# tokens.css zuerst (Variablen), dann das User-Komponenten-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 %} + + {% endif %} + {% endblock %} + + + + {% if current_user %} +
+ + +
+ {% endif %} + +
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + {% if current_user %} + + {% endif %} + + + diff --git a/app/templates/benachrichtigungen.html b/app/templates/benachrichtigungen.html new file mode 100644 index 0000000..96a3f96 --- /dev/null +++ b/app/templates/benachrichtigungen.html @@ -0,0 +1,31 @@ +{% extends shell_base %} + +{% block title %}Benachrichtigungen · CheckPoint Ehrenamt{% endblock %} + +{% block content %} +

Benachrichtigungen

+ + {% if eintraege %} +
+ + +
+ +
    + {% for b in eintraege %} +
  • + {% if not b.gelesen %}Neu{% endif %} + {% if b.url %} + {{ b.text }} + {% else %} + {{ b.text }} + {% endif %} +
    {{ b.erstellt_am | lokalzeit }}
    +
  • + {% endfor %} +
+ {% else %} +

Keine Benachrichtigungen. Sobald ein Dienst, ein offener + Platz oder eine Nachricht dazukommt, erscheint sie hier.

+ {% endif %} +{% endblock %} diff --git a/app/templates/chat.html b/app/templates/chat.html new file mode 100644 index 0000000..f3f6005 --- /dev/null +++ b/app/templates/chat.html @@ -0,0 +1,82 @@ +{% extends shell_base %} + +{% block title %}{{ label }} · CheckPoint Ehrenamt{% endblock %} + +{% block content %} +

← Team

+

{{ label }}

+ {% if kanal.nur_admin_darf_posten %} +

Nur das Team-Management postet hier. Alle lesen mit.

+ {% endif %} + + {# data-* steuern das Polling (siehe Skript unten). neu-base + id = JSON-Endpunkt. #} +
    + {% for n in nachrichten %} +
  • +
    {{ n.autor.anzeigename }} · {{ zeit(n.erstellt_am) }}
    +
    {{ n.text }}
    +
  • + {% else %} +
  • Noch keine Nachrichten.
  • + {% endfor %} +
+ + {% if darf_posten %} +
+ +
+ + +
+ +
+ {% endif %} + + {# Einzige v1-JS-Ausnahme: kleines fetch-Polling für neue Nachrichten. + Einfügen ausschließlich per textContent → kein XSS über Nachrichtentext. #} + +{% endblock %} diff --git a/app/templates/dienste_einsatz.html b/app/templates/dienste_einsatz.html new file mode 100644 index 0000000..92e85cc --- /dev/null +++ b/app/templates/dienste_einsatz.html @@ -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 %} +
  • + {{ z.user.anzeigename }} +
    + + + +
    +
  • + {% endfor %} + {% for _ in range(frei) %} +
  • {{ leer_text }}
  • + {% endfor %} +{% endmacro %} + +{% block content %} + {% if einsatz.planungszeitraum_id %} +

    ← Zurück

    + {% else %} +

    ← Dienstplan

    + {% endif %} + +

    {{ einsatz.art }}{% if einsatz.ort %} · {{ einsatz.ort }}{% endif %}

    +
    + {{ einsatz.datum.strftime('%a, %d.%m.%Y') }} · + {{ zeitspanne(einsatz) }} +
    +
    + {{ haupt|length }}/{{ einsatz.HAUPT_PLAETZE }} Haupt · + {{ springer|length }}/{{ einsatz.SPRINGER_PLAETZE }} Springer +
    + + {# Unterbesetzt-Markierung (manuell) #} +
    + + +
    + {% if einsatz.unterbesetzt %} +

    Unterbesetzt

    + {% endif %} + +

    Hauptplätze

    +
      + {{ besetzte_plaetze(haupt, haupt_frei, "Frei") }} +
    + +

    Springerplatz

    +
      + {{ besetzte_plaetze(springer, springer_frei, "Frei") }} +
    + +

    Verfügbare Personen

    +

    Nur Personen, die „Kann" gemeldet haben. Zahlen helfen beim + fairen Verteilen.

    +
      + {% for p in kandidaten %} + {% set f = fairness.get(p.id) %} +
    • + {{ p.anzeigename }} +
      + {{ (f[0] if f else 0) }} bisherige Einsätze · + zuletzt: {{ f[1].strftime('%d.%m.%Y') if f and f[1] else '—' }} +
      +
      + {% if haupt_frei > 0 %} +
      + + + + +
      + {% endif %} + {% if springer_frei > 0 %} +
      + + + + +
      + {% endif %} +
      +
    • + {% else %} +
    • Niemand hat „Kann" gemeldet (oder alle sind schon zugeteilt).
    • + {% endfor %} +
    +{% endblock %} diff --git a/app/templates/dienste_liste.html b/app/templates/dienste_liste.html new file mode 100644 index 0000000..0c5a0eb --- /dev/null +++ b/app/templates/dienste_liste.html @@ -0,0 +1,47 @@ +{% extends shell_base %} +{% from "_zeit.html" import zeitspanne %} + +{% block title %}Dienstplan · CheckPoint Ehrenamt{% endblock %} + +{% block content %} +

    Dienstplan

    +

    Wähle einen Zeitraum oder Einzeltermin, um Haupt- und + Springerplätze zu besetzen.

    + +

    Zeiträume

    +
      + {% for zr in zeitraeume %} +
    • + + {{ zr.name }} + +
      + {{ zr.start_datum.strftime('%d.%m.%Y') }} – {{ zr.end_datum.strftime('%d.%m.%Y') }} +
      + + {{ "Veröffentlicht" if zr.ist_veroeffentlicht else "In Planung" }} + +
    • + {% else %} +
    • Noch keine Planungszeiträume.
    • + {% endfor %} +
    + +

    Einzeltermine

    + +{% endblock %} diff --git a/app/templates/dienste_zeitraum.html b/app/templates/dienste_zeitraum.html new file mode 100644 index 0000000..3a6c5f6 --- /dev/null +++ b/app/templates/dienste_zeitraum.html @@ -0,0 +1,47 @@ +{% extends shell_base %} +{% from "_zeit.html" import zeitspanne %} + +{% block title %}{{ zeitraum.name }} · Dienstplan{% endblock %} + +{% block content %} +

    ← Dienstplan

    + +

    {{ zeitraum.name }}

    +
    + {{ zeitraum.start_datum.strftime('%d.%m.%Y') }} – {{ zeitraum.end_datum.strftime('%d.%m.%Y') }} +
    + + {% if zeitraum.ist_veroeffentlicht %} +

    Veröffentlicht · Verfügbarkeit gesperrt

    + {% else %} +
    + + +
    + {% endif %} + +

    Einsätze

    +
      + {% for e in zeitraum.einsaetze %} + {% set s = stand[e.id] %} +
    • + + {{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %} + +
      + {{ e.datum.strftime('%a, %d.%m.%Y') }} · + {{ zeitspanne(e) }} +
      +
      + {{ s[0] }}/{{ e.HAUPT_PLAETZE }} Haupt · {{ s[1] }}/{{ e.SPRINGER_PLAETZE }} Springer +
      + {% if e.unterbesetzt %}Unterbesetzt{% endif %} +
    • + {% else %} +
    • Dieser Zeitraum hat noch keine Einsätze.
    • + {% endfor %} +
    +{% endblock %} diff --git a/app/templates/dokumente.html b/app/templates/dokumente.html new file mode 100644 index 0000000..8c8745c --- /dev/null +++ b/app/templates/dokumente.html @@ -0,0 +1,99 @@ +{% extends shell_base %} + +{% block title %}Dokumente · CheckPoint Ehrenamt{% endblock %} + +{% macro dokument_karte(d, mit_verschieben=False) %} +
  • + + {{ d.titel }} + +
    + {{ d.uploader.anzeigename }} · {{ d.erstellt_am.strftime('%d.%m.%Y') }} +
    + {% if mit_verschieben and current_user.ist_admin %} +
    +
    + + +
    +
    + {% endif %} +
  • +{% endmacro %} + +{% block content %} +

    ← Team

    +

    Dokumente

    + + {% if current_user.ist_admin and offene_freigaben %} + + {% elif current_user.ist_admin %} +
    + Freigaben +
    + {% endif %} + +

    Aktuell

    +
      + {% for d in aktuell %} + {{ dokument_karte(d, mit_verschieben=True) }} + {% else %} +
    • Noch keine freigegebenen Dokumente.
    • + {% endfor %} +
    + + {% if archiv %} +

    Archiv

    +
      + {% for d in archiv %} + {{ dokument_karte(d, mit_verschieben=True) }} + {% endfor %} +
    + {% endif %} + + {% if eigene_wartend %} +

    Meine Uploads in Prüfung

    +

    Nur du und der Admin sehen diese, bis sie freigegeben sind.

    +
      + {% for d in eigene_wartend %} +
    • + {{ d.titel }} +
      {{ d.erstellt_am.strftime('%d.%m.%Y') }}
      + {% if d.status == "abgelehnt" %} + Abgelehnt + {% if d.ablehnungsgrund %} +
      Grund: {{ d.ablehnungsgrund }}
      + {% endif %} + {% else %} + Wartet auf Freigabe + {% endif %} +
    • + {% endfor %} +
    + {% endif %} + +

    Dokument hochladen

    +

    PDF, Word, Excel oder Bild, max. 10 MB. Fotos nur ohne + erkennbare Dritte bzw. mit deren Einverständnis.

    +
    + +
    + + +
    +
    + + +
    + +
    +{% endblock %} diff --git a/app/templates/dokumente_freigaben.html b/app/templates/dokumente_freigaben.html new file mode 100644 index 0000000..e8427d6 --- /dev/null +++ b/app/templates/dokumente_freigaben.html @@ -0,0 +1,42 @@ +{% extends shell_base %} + +{% block title %}Freigaben · CheckPoint Ehrenamt{% endblock %} + +{% block content %} +

    ← Dokumente

    +

    Freigaben

    +

    Hochgeladene Dokumente, die auf deine Entscheidung warten.

    + +
      + {% for d in wartend %} +
    • + + {{ d.titel }} + +
      + {{ d.uploader.anzeigename }} · {{ d.erstellt_am.strftime('%d.%m.%Y') }} +
      + +
      +
      + + +
      +
      + +
      + +
      + + +
      + +
      +
    • + {% else %} +
    • Zurzeit nichts zu prüfen.
    • + {% endfor %} +
    +{% endblock %} diff --git a/app/templates/einsatz_bearbeiten.html b/app/templates/einsatz_bearbeiten.html new file mode 100644 index 0000000..af365fe --- /dev/null +++ b/app/templates/einsatz_bearbeiten.html @@ -0,0 +1,23 @@ +{% extends shell_base %} + +{% block title %}Einsatz bearbeiten · CheckPoint Ehrenamt{% endblock %} + +{% block content %} +

    + {% if zeitraum %} + ← {{ zeitraum.name }} + {% else %} + ← Einzeltermine + {% endif %} +

    + +

    Einsatz bearbeiten

    + +
    + + {% include "_einsatz_felder.html" with context %} + +
    +{% endblock %} diff --git a/app/templates/einzeltermine.html b/app/templates/einzeltermine.html new file mode 100644 index 0000000..904a693 --- /dev/null +++ b/app/templates/einzeltermine.html @@ -0,0 +1,45 @@ +{% extends shell_base %} +{% from "_zeit.html" import zeitspanne %} + +{% block title %}Einzeltermine · CheckPoint Ehrenamt{% endblock %} + +{% block content %} +

    ← Planung

    + +

    Einzeltermine

    +

    Einzelne Termine ohne Planungszeitraum – direkt zum Besetzen.

    + +

    Termine

    +
      + {% for e in termine %} +
    • + {{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %} +
      + {{ e.datum.strftime('%a, %d.%m.%Y') }} · + {{ zeitspanne(e) }} +
      +
      {{ e.HAUPT_PLAETZE }} Haupt + {{ e.SPRINGER_PLAETZE }} Springer
      + +
      + Bearbeiten +
      + + +
      +
      +
    • + {% else %} +
    • Noch keine Einzeltermine.
    • + {% endfor %} +
    + +

    Einzeltermin hinzufügen

    +
    + + {% include "_einsatz_felder.html" with context %} + +
    +{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..8214e4c --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,21 @@ +{% extends shell_base %} + +{% block title %}Start · CheckPoint Ehrenamt{% endblock %} + +{% block content %} +

    Hallo {{ user.anzeigename }}

    + + {% if user.ist_admin %} + {# Admin-Shell: die Sidebar trägt die Navigation, hier keine Kachel-Buttons. #} +

    Willkommen im Verwaltungsbereich. Wähle links eine Aufgabe – + Planung, Dienste, Freigaben, Team oder Chat-Verwaltung. Nutzer anlegen findest + du unter „Team".

    + {% else %} +

    Schön, dass du da bist. Deine Einsätze findest du unter + „Termine", Team und Dokumente unter „Team".

    +
    + + +
    + {% endif %} +{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..870cb2d --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,32 @@ +{% extends shell_base %} + +{% block title %}Anmelden · CheckPoint Ehrenamt{% endblock %} + +{% block content %} +

    Anmelden

    + +
    + + {% if mehrere_teams %} +
    + + +
    + {% endif %} +
    + + +
    + +
    + + +
    + + +
    +{% endblock %} diff --git a/app/templates/meine_termine.html b/app/templates/meine_termine.html new file mode 100644 index 0000000..06f12ce --- /dev/null +++ b/app/templates/meine_termine.html @@ -0,0 +1,71 @@ +{% extends shell_base %} +{% from "_zeit.html" import zeitspanne %} + +{% block title %}Meine Termine · CheckPoint Ehrenamt{% endblock %} + +{% block content %} +

    Meine Termine

    + + {% if offene_planung %} + + {% endif %} + +
      + {% for z, e in termine %} +
    • + {{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %} +
      + {{ e.datum.strftime('%a, %d.%m.%Y') }} · + {{ zeitspanne(e) }} +
      +
      + {{ "Haupt" if z.platztyp == "haupt" else "Springer" }} + {{ "Übernommen" if z.status == "uebernommen" else "Zugeteilt" }} +
      +
      +
      + + +
      +
      +
    • + {% else %} +
    • Du hast aktuell keine zugeteilten Termine. Sobald ein + Dienstplan veröffentlicht ist, erscheinen deine Einsätze hier.
    • + {% endfor %} +
    + + {% if offene %} +

    Offene Dienste

    +

    Hier ist jemand abgesprungen. Wer zuerst übernimmt, bekommt + den Platz.

    +
      + {% for z, e in offene %} +
    • + {{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %} +
      + {{ e.datum.strftime('%a, %d.%m.%Y') }} · + {{ zeitspanne(e) }} +
      +
      + Offen + {{ "Haupt" if z.platztyp == "haupt" else "Springer" }} +
      + {% if e.id in eigene_einsatz_ids %} +
      Du bist diesem Einsatz bereits zugeteilt.
      + {% else %} +
      +
      + + +
      +
      + {% endif %} +
    • + {% endfor %} +
    + {% endif %} +{% endblock %} diff --git a/app/templates/nutzer_neu.html b/app/templates/nutzer_neu.html new file mode 100644 index 0000000..29f2aab --- /dev/null +++ b/app/templates/nutzer_neu.html @@ -0,0 +1,50 @@ +{% extends shell_base %} + +{% block title %}Nutzer · CheckPoint Ehrenamt{% endblock %} + +{% block content %} +

    Nutzer anlegen

    + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + + +
    + +

    Team

    +
      + {% for u in nutzer %} +
    • + {{ u.anzeigename }} + · {{ u.nutzername }} + {{ "Admin" if u.ist_admin else "Ehrenamt" }} +
    • + {% else %} +
    • Noch keine Nutzer.
    • + {% endfor %} +
    +{% endblock %} diff --git a/app/templates/planung_detail.html b/app/templates/planung_detail.html new file mode 100644 index 0000000..7d77a76 --- /dev/null +++ b/app/templates/planung_detail.html @@ -0,0 +1,56 @@ +{% extends shell_base %} +{% from "_zeit.html" import zeitspanne %} + +{% block title %}{{ zeitraum.name }} · CheckPoint Ehrenamt{% endblock %} + +{% block content %} +

    ← Planung

    + +

    {{ zeitraum.name }}

    +
    + {{ zeitraum.start_datum.strftime('%d.%m.%Y') }} – {{ zeitraum.end_datum.strftime('%d.%m.%Y') }} +
    + + {{ "Veröffentlicht" if zeitraum.ist_veroeffentlicht else "In Planung" }} + + +

    Einsätze

    +
      + {% for e in einsaetze %} +
    • + {{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %} +
      + {{ e.datum.strftime('%a, %d.%m.%Y') }} · + {{ zeitspanne(e) }} +
      +
      {{ e.HAUPT_PLAETZE }} Haupt + {{ e.SPRINGER_PLAETZE }} Springer
      + + {% if not zeitraum.ist_veroeffentlicht %} +
      + + Bearbeiten + +
      + + +
      +
      + {% endif %} +
    • + {% else %} +
    • Noch keine Einsätze.
    • + {% endfor %} +
    + + {% if not zeitraum.ist_veroeffentlicht %} +

    Einsatz hinzufügen

    +
    + + {% include "_einsatz_felder.html" with context %} + +
    + {% endif %} +{% endblock %} diff --git a/app/templates/planung_liste.html b/app/templates/planung_liste.html new file mode 100644 index 0000000..d0decad --- /dev/null +++ b/app/templates/planung_liste.html @@ -0,0 +1,52 @@ +{% extends shell_base %} + +{% block title %}Planung · CheckPoint Ehrenamt{% endblock %} + +{% block content %} +

    Planung

    + + + +
    + + +
    + + +
    + +
    + + +
    + +
    + + + Mindestens 2 Monate. +
    + + +
    + +

    Zeiträume

    +
      + {% for zr in zeitraeume %} +
    • + + {{ zr.name }} + +
      + {{ zr.start_datum.strftime('%d.%m.%Y') }} – {{ zr.end_datum.strftime('%d.%m.%Y') }} +
      + + {{ "Veröffentlicht" if zr.ist_veroeffentlicht else "In Planung" }} + +
    • + {% else %} +
    • Noch keine Planungszeiträume.
    • + {% endfor %} +
    +{% endblock %} diff --git a/app/templates/platzhalter.html b/app/templates/platzhalter.html new file mode 100644 index 0000000..50095b2 --- /dev/null +++ b/app/templates/platzhalter.html @@ -0,0 +1,8 @@ +{% extends shell_base %} + +{% block title %}{{ titel }} · CheckPoint Ehrenamt{% endblock %} + +{% block content %} +

    {{ titel }}

    +

    Diese Ansicht kommt in einer der nächsten Sessions.

    +{% endblock %} diff --git a/app/templates/profil.html b/app/templates/profil.html new file mode 100644 index 0000000..378b684 --- /dev/null +++ b/app/templates/profil.html @@ -0,0 +1,55 @@ +{% extends shell_base %} + +{% block title %}Profil · CheckPoint Ehrenamt{% endblock %} + +{% block content %} +

    Profil

    + +
    + {% if current_user.profilbild_pfad %} + Profilbild von {{ current_user.anzeigename }} + {% else %} + + {% endif %} + {{ current_user.anzeigename }} +
    {{ "Admin" if current_user.ist_admin else "Ehrenamtlich" }} + · {{ current_user.nutzername }}
    +
    + +
    + + +
    + + +
    + +
    + + +
    PNG, JPG, GIF oder WebP. Ersetzt das bisherige Bild.
    +
    + + {% for feld in felder %} +
    + + {% if feld.mehrzeilig %} + + {% else %} + + {% endif %} +
    + {% endfor %} + + +
    +{% endblock %} diff --git a/app/templates/team.html b/app/templates/team.html new file mode 100644 index 0000000..53faf5d --- /dev/null +++ b/app/templates/team.html @@ -0,0 +1,29 @@ +{% extends shell_base %} + +{% block title %}Team · CheckPoint Ehrenamt{% endblock %} + +{% block content %} +

    Team

    + +
      +
    • + Ankündigungen +
      Infos vom Team-Management. Alle lesen mit.
      +
    • +
    • + Team-Chat +
      Austausch der Gruppe – alle schreiben mit.
      +
    • +
    • + Dokumente +
      Geteilte Dateien und Fotos der Gruppe.
      +
    • + {% if current_user.ist_admin %} +
    • + Nutzer anlegen +
      Neuen Zugang für das Team einrichten (nur Admin).
      +
    • + {% endif %} +
    +{% endblock %} + diff --git a/app/templates/verfuegbarkeit.html b/app/templates/verfuegbarkeit.html new file mode 100644 index 0000000..3a7920a --- /dev/null +++ b/app/templates/verfuegbarkeit.html @@ -0,0 +1,57 @@ +{% extends shell_base %} +{% from "_zeit.html" import zeitspanne %} + +{% block title %}Termine · CheckPoint Ehrenamt{% endblock %} + +{% block content %} +

    Verfügbarkeit

    + + {% if not einsaetze %} +

    Zurzeit gibt es nichts zu melden. Sobald die Planung + startet oder ein Einzeltermin ansteht, kannst du hier pro Einsatz „Kann" + oder „Kann nicht" melden.

    + {% else %} + {% if zeitraum %} +

    {{ zeitraum.name }} · + {{ zeitraum.start_datum.strftime('%d.%m.%Y') }}–{{ zeitraum.end_datum.strftime('%d.%m.%Y') }}

    + {% endif %} +

    Melde pro Einsatz, ob du kannst. Änderbar, solange geplant wird.

    + +
      + {% for e in einsaetze %} + {% set wert = eigene.get(e.id) %} +
    • + {{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %} +
      + {{ e.datum.strftime('%a, %d.%m.%Y') }} · + {{ zeitspanne(e) }} +
      + +
      + + + +
      + +
      + {% if wert == 'kann' %}Gemeldet: Kann + {% elif wert == 'kann_nicht' %}Gemeldet: Kann nicht + {% else %}Noch nicht gemeldet{% endif %} +
      +
    • + {% else %} +
    • In diesem Zeitraum gibt es noch keine Einsätze.
    • + {% endfor %} +
    + {% endif %} +{% endblock %} diff --git a/deploy/backup-db.sh b/deploy/backup-db.sh new file mode 100644 index 0000000..7e29035 --- /dev/null +++ b/deploy/backup-db.sh @@ -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" diff --git a/deploy/checkpoint.service b/deploy/checkpoint.service new file mode 100644 index 0000000..864d310 --- /dev/null +++ b/deploy/checkpoint.service @@ -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 diff --git a/deploy/nginx-checkpoint.conf b/deploy/nginx-checkpoint.conf new file mode 100644 index 0000000..3b952f6 --- /dev/null +++ b/deploy/nginx-checkpoint.conf @@ -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; + } +} diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..c40ff92 --- /dev/null +++ b/gunicorn.conf.py @@ -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" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bc6ef54 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask +Flask-SQLAlchemy +gunicorn diff --git a/run.py b/run.py new file mode 100644 index 0000000..6bc3665 --- /dev/null +++ b/run.py @@ -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) diff --git a/seed.py b/seed.py new file mode 100644 index 0000000..63eb4d7 --- /dev/null +++ b/seed.py @@ -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() diff --git a/tokens.css b/tokens.css new file mode 100644 index 0000000..ed58089 --- /dev/null +++ b/tokens.css @@ -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: 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.: + + + + 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: + + → in base_user.html + → 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; +} diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..eca487b --- /dev/null +++ b/wsgi.py @@ -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()