From 987deb79a94cb6b1fd7cbd0590b14297bfc26b1c Mon Sep 17 00:00:00 2001 From: patsy Date: Thu, 25 Jun 2026 18:58:44 +0200 Subject: [PATCH] Whole ehrenamtsapp7 --- .env.example | 10 + CLAUDE-backup.md | 213 ++++++++++ CLAUDE.md | 272 ++++++++++++ CheckPoint-Ehrenamt.png | Bin 0 -> 148093 bytes DEPLOY.md | 154 +++++++ Konzept-CheckPoint-Ehrenamt.md | 270 ++++++++++++ Stakeholder-CheckPoint-Ehrenamt.md | 129 ++++++ TASKS-backup.md | 385 +++++++++++++++++ TASKS.md | 431 ++++++++++++++++++++ app/__init__.py | 124 ++++++ app/auth.py | 215 ++++++++++ app/csrf.py | 50 +++ app/models.py | 375 +++++++++++++++++ app/notifications.py | 40 ++ app/routes/__init__.py | 111 +++++ app/routes/chat.py | 135 ++++++ app/routes/dienste.py | 429 +++++++++++++++++++ app/routes/dokumente.py | 236 +++++++++++ app/routes/planung.py | 339 +++++++++++++++ app/routes/profil.py | 110 +++++ app/static/components-admin.css | 397 ++++++++++++++++++ app/static/components.css | 417 +++++++++++++++++++ app/static/fonts.css | 93 +++++ app/static/fonts/alfa-slab-one-400.woff2 | Bin 0 -> 19052 bytes app/static/fonts/dm-sans-400.woff2 | Bin 0 -> 36932 bytes app/static/fonts/dm-sans-500.woff2 | Bin 0 -> 36932 bytes app/static/fonts/dm-sans-600.woff2 | Bin 0 -> 36932 bytes app/static/fonts/dm-sans-700.woff2 | Bin 0 -> 36932 bytes app/static/fonts/dm-sans-800.woff2 | Bin 0 -> 36932 bytes app/static/fonts/roboto-condensed-400.woff2 | Bin 0 -> 51412 bytes app/static/fonts/roboto-condensed-500.woff2 | Bin 0 -> 51412 bytes app/static/fonts/roboto-condensed-700.woff2 | Bin 0 -> 51412 bytes app/static/fonts/roboto-mono-400.woff2 | Bin 0 -> 32796 bytes app/static/fonts/roboto-mono-500.woff2 | Bin 0 -> 32796 bytes app/static/sidebar.js | 24 ++ app/static/tokens.css | 340 +++++++++++++++ app/team_scope.py | 51 +++ app/templates/400_csrf.html | 10 + app/templates/403.html | 9 + app/templates/_einsatz_felder.html | 50 +++ app/templates/_theme_toggle.html | 31 ++ app/templates/_zeit.html | 6 + app/templates/base_admin.html | 144 +++++++ app/templates/base_user.html | 117 ++++++ app/templates/benachrichtigungen.html | 31 ++ app/templates/chat.html | 82 ++++ app/templates/dienste_einsatz.html | 95 +++++ app/templates/dienste_liste.html | 47 +++ app/templates/dienste_zeitraum.html | 47 +++ app/templates/dokumente.html | 99 +++++ app/templates/dokumente_freigaben.html | 42 ++ app/templates/einsatz_bearbeiten.html | 23 ++ app/templates/einzeltermine.html | 45 ++ app/templates/index.html | 21 + app/templates/login.html | 32 ++ app/templates/meine_termine.html | 71 ++++ app/templates/nutzer_neu.html | 50 +++ app/templates/planung_detail.html | 56 +++ app/templates/planung_liste.html | 52 +++ app/templates/platzhalter.html | 8 + app/templates/profil.html | 55 +++ app/templates/team.html | 29 ++ app/templates/verfuegbarkeit.html | 57 +++ deploy/backup-db.sh | 18 + deploy/checkpoint.service | 23 ++ deploy/nginx-checkpoint.conf | 31 ++ gunicorn.conf.py | 19 + requirements.txt | 3 + run.py | 11 + seed.py | 57 +++ tokens.css | 334 +++++++++++++++ wsgi.py | 9 + 72 files changed, 7164 insertions(+) create mode 100644 .env.example create mode 100644 CLAUDE-backup.md create mode 100644 CLAUDE.md create mode 100644 CheckPoint-Ehrenamt.png create mode 100644 DEPLOY.md create mode 100644 Konzept-CheckPoint-Ehrenamt.md create mode 100644 Stakeholder-CheckPoint-Ehrenamt.md create mode 100644 TASKS-backup.md create mode 100644 TASKS.md create mode 100644 app/__init__.py create mode 100644 app/auth.py create mode 100644 app/csrf.py create mode 100644 app/models.py create mode 100644 app/notifications.py create mode 100644 app/routes/__init__.py create mode 100644 app/routes/chat.py create mode 100644 app/routes/dienste.py create mode 100644 app/routes/dokumente.py create mode 100644 app/routes/planung.py create mode 100644 app/routes/profil.py create mode 100644 app/static/components-admin.css create mode 100644 app/static/components.css create mode 100644 app/static/fonts.css create mode 100644 app/static/fonts/alfa-slab-one-400.woff2 create mode 100644 app/static/fonts/dm-sans-400.woff2 create mode 100644 app/static/fonts/dm-sans-500.woff2 create mode 100644 app/static/fonts/dm-sans-600.woff2 create mode 100644 app/static/fonts/dm-sans-700.woff2 create mode 100644 app/static/fonts/dm-sans-800.woff2 create mode 100644 app/static/fonts/roboto-condensed-400.woff2 create mode 100644 app/static/fonts/roboto-condensed-500.woff2 create mode 100644 app/static/fonts/roboto-condensed-700.woff2 create mode 100644 app/static/fonts/roboto-mono-400.woff2 create mode 100644 app/static/fonts/roboto-mono-500.woff2 create mode 100644 app/static/sidebar.js create mode 100644 app/static/tokens.css create mode 100644 app/team_scope.py create mode 100644 app/templates/400_csrf.html create mode 100644 app/templates/403.html create mode 100644 app/templates/_einsatz_felder.html create mode 100644 app/templates/_theme_toggle.html create mode 100644 app/templates/_zeit.html create mode 100644 app/templates/base_admin.html create mode 100644 app/templates/base_user.html create mode 100644 app/templates/benachrichtigungen.html create mode 100644 app/templates/chat.html create mode 100644 app/templates/dienste_einsatz.html create mode 100644 app/templates/dienste_liste.html create mode 100644 app/templates/dienste_zeitraum.html create mode 100644 app/templates/dokumente.html create mode 100644 app/templates/dokumente_freigaben.html create mode 100644 app/templates/einsatz_bearbeiten.html create mode 100644 app/templates/einzeltermine.html create mode 100644 app/templates/index.html create mode 100644 app/templates/login.html create mode 100644 app/templates/meine_termine.html create mode 100644 app/templates/nutzer_neu.html create mode 100644 app/templates/planung_detail.html create mode 100644 app/templates/planung_liste.html create mode 100644 app/templates/platzhalter.html create mode 100644 app/templates/profil.html create mode 100644 app/templates/team.html create mode 100644 app/templates/verfuegbarkeit.html create mode 100644 deploy/backup-db.sh create mode 100644 deploy/checkpoint.service create mode 100644 deploy/nginx-checkpoint.conf create mode 100644 gunicorn.conf.py create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 seed.py create mode 100644 tokens.css create mode 100644 wsgi.py 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 0000000000000000000000000000000000000000..ae81ca7882d61c32b781689faf01b9f46e308e22 GIT binary patch literal 148093 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

~X*?{9vMd)*Yv?{Ey}ANB!f%wI`Ltbz^KOiT)b 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 ZNAwJ{9}UJvCfXYJ3O|^lWO6l-`p6QV z+I_(2c8G>h9YOj6Jtybb7$$7nohhwrPmtIBeqGw<;(?w6wvp#dBrdpn3>^1LT#qOr z!IlmvWP9=UQ0w)gN_W&Njx+W->?g;`D)p%wccldVrjBtah!Dcd<8IzmZ;BG(_uo;1n8GC2uKC)onA=V@`1{g|aU@@?43fcU|zToanQ!oRINemm>UKmfSu zs)39dqS3gN=q*NmDrHnTzu-TY5>=4RU$!Dx1t-Uy3;lCa+pdbUSc9JW>`<%x6wRrLE;%)XM-0JT> z1`$!Um7916MIr5&0?|AJ7ydD@3Pa99c|SjG!DBtu{#=iDVjqHnww`wxqQnsdAZ^FP z$vRTgO$*8QRgrDKKN(n&By%Dl;lx%^sjeIg!zuivBrU>1=!G%L!eIQx@`Bsx+qBcq@ehmb^PYjQ}I zR14XEXdrtArcU9bBawKn#D9B1L{5 z`UNFMPt33kt_^sTT^T0XFzV%7^}VWn?jGFd-=DnqdOz7>SwdsTS;`11Rz_sz%%>#j z=N~EDo7B(ou)1E11%KhKLKR^sFk+KS8+1{;jq~_IB3Q2MbqQ@WRs*j9jfMww-iYwN zBG~@iid@;ctu0EOmoE%M(uO>Q3vQF2GX^JH@clc=|CxnI+V{A# z0e%=Tn2VJpt!u#|8c0>RZwRT!Q^b?vQ4zBbLS;pYpRAJ9U3`tWT7efvXz5p=5a&bN zZN#X0VSnNQNj~}=Ly;PCT{`*V7G9Z}YESW{;KyCm(hpg95|2dPP4ryrnV5{!GO%!t-Rol&!gAh&D+c-};GM2?m4 zKQYQz_v5F#9uMT~$Eb7%SS;esRiR}r@bAv_t$k~)447tn^_Fx^!OnY-7n5;0UdJK~ zASl3$e)CR!K{k*z$nfcT$B2rFW^qxP#`*R-#TVNgIJLI(3GKkb|2A52N1XZ<>*E)C z>r`znn>xk$r<~wv4|*o19o$bjsTke#!o^l~bQ{v{WPna*>eWgQoJuwLZyd5<%yZ#k zZ8Uz`g8SUj%=nuN6=dj`XWzS`Wnp6BD{>daOI1Ku8sLex+#io~VoP~Lt+0P@M%21m z8HjYi{psBu0?%s;uR@&EVVSh95y;FV8Ys29XYI+`jHMB0rWv*ld3|x(%7@6!dzQ)W zf+2EE>HdU~K&_x_}#6(0?Ako#70F6A{C-U!nNCXijTk3L(=k~%Q&K)M6=yBB# zJNo}x07k(RXM*=^Xg<1Hef7}baDAJm_7(a;*UI82&c&i4?d5Cw2k6t=iOauxx4RxZ z)mjL~DM4j((wLojzCbE8hEw{?WJ{I7vN=i=+hx!ZSg2bF4L2WuJ)(w%#oE3^q*b(P z0AUhy3+!NDa5L-H>=UMM^~lPuA1K=Qo@B?YAFEzow!T>7cs0vo_9(}2`?qI&P*|IQ zR}(!Hy_4KCXifyAOK8FYqq*ZPLpY-_n0F+@-6;x4GrD-jRwsg7dr{#9{1ZC!&04TE z9l`LN3}R^}&0pJD`!9jZ_C2b+!>gB19_#gAPw4z6#%EY8KObrwg0w2MLKbo94r;blihdV>XtVSmG2>n zcNNp%*EB?qDrla%CuSj0Bo4&xk}kA>JSUl{XF9kYpQZYJf{^=}|5GD{ z&$RZ;%ARTVbYT?z< zaEy&!7!OQZrwT9gou5J{zG&S){c94pqVQJdq%8mBytZm4`cHXMpnYFj_s59xm%)r2 z7u*33j*ezI1T^6{WVlN=-J>2D*0K!%gOMWXAY{jw`FO#Vu8GLxWW1>8ef8*if13>f zK>Rc}Z)QCo2~q6DrW(7Jb5~E|ig9E= z_f`X%2F8xysK5DN9QCF@hdb}&^0)mR-~a5z9A0m*K^lyz$M$-`8=+)^7AVZuD^qO@8gaSeT2(D_6ce`cCe+@Vq6lM-jVne zQo)KwE<*+aTshch&Zep{YQh%)Lw{G60tg89&x3$MO1uPP;>VFcVMO{}kHpXe(!{dI zdA7Th2aIs|nWuB+Z~hvbauTdu5lM}MOQIhP@u@DH$2bXcF;ucpgBQ(Q*L|_oSH1~` zaT+RUQB_rs1wZOO_W=ypIy8ZiLpl5}{)EH-^berjfjjQz&QE=o>%RCErf$ED4YfL} zjAf;@)SSn<6mdSrO#9NJn_JmyLCUG2J2EVOn`ry2h0%pdW>g(kCKaLQGz54V4dVtC zdN$#KZkOHOGg-H6J@X9Cc=<~(r=JFE*FZgiXdSI8^H3GcgOjLL3*}>m$6(^Mr?T+x zeXf038og zGjP{k%v^Cb*M0fR+a1eJ@45!$x&RX0#M`?%)X_ zA`p{2drmPnIyoj5XDwXpja;a@g*svWzi~Aqr#*o)fBog0=~C#q04BC=r*q>?+;`JW z{OqQic;Mdq=uPfna{B|c5=*b!CQ%{nbx4wgX1z(8rnI|V5?iO6_K;e_igj!0_>@jJ zWqjRQM%Jz4;A4*B@Dom8?AW8>@WVpPcC7}DI@ByAl8+O-N*;0jU>Yy7A75Cloy)TU zSBztuJ7zO@@zjuqrsi4Hpg}SjR3AO`4NiQVL6%#B6$`kksutncU@^JS7mwm1@nIlI z`k!C31`gRwbpSgJi+!(? zeLP)bix!nYt^aYW5v;5S^d803$^6fIc|Bym2%l9;psGg#H)v4Rh$jnI&zw_b`DITO ze9;T`tR8Zm>4DIjR#jD158ZP0k+ySxsNZk!>1tp%?#HS$SE^P#we7d9{XB=A{Bu&- z-&ghUh>yke){rwId5UW3@0LxcLHjJvl7(tiU4^8(3W`@%)naKR6i?jq{ewsPj+MnC zQ5*by<)Qt&IRRCb=%K1d)V4iFxQkz&j*w;!sl)a&s-Q_0QjCR!nAbetUKg){ zMO9TT<;_kD0HF(XfIYyjPM15k@8FJY+qm=oZS0tuX0p>^y4PdR^bDz2thF?ggprzI zO{>M0wW~SikS!d&WebO_7-x(iTo?)7ideuD?qBac#+W506@3uXT6v@~o+ijf z9p0m3<4ALaE*``}tq!A&kh!pO4bHh>^&T@ATrb7$oJO|YfLwJgc4n55Zig)+O`dV` z6M4odC$m|gLGWTS>ho-wIfTMRJC893hCO9W*8e*$^k|1L%nHpQg$G7d_)Bp5-4%U=Eyx;;(UbhMd z)Y0I)Sltx+FVn1EqN)~gB?}X{5mA$r9jr9>BlhdjpFAfTUJ%o0C->aod!WbhTlY?9on~8sYVma{6HlYC*k3~ zmL1?8;6s;vkBhGV5#9A0=!wN^nGvNL#twOGGhmIutKu;+(b1IvfzJtxAu3w4abh-G zl^7u%XN`F;B6cb~AjZ&B&s1XZn>X{`%dh0ld+z7|fA%l17HB?3#}%rIijNMIW?tXi zqC@3;1kz?%SI`w8?t+dO#(~y;oi;EQEJd2UFRW1L{gBDK9X+a7&i=mBSmcaUN z$G=PC*SdkzTd^bcSm;h=^2sjn0Pt^DT*F6ycmsaJTDnOMXHA4%Qi^xZRSa>F@!p4+ z26adQFsMlEJXpZsG;ClQ|0n|m`9|e^PQeuEg9cOV8JUDBr=+XbamoEZ=kLD$Ep`Iw zW1yEbtdS5ptfs(63DHuSa&uscQLM=9y1`1E%6)IMHddV`_?fw(n%;#1y-yrfGLmre|kJl7yA578}>C zV)LpsY*@95wPPc!N-UNz8*fS229vmeF87m{#Mf^RaM|s5^PiVrNqgNo)HZN2geklt ziuZE?l@`L7v@jaerKVd3?%~2X&h-}`<6u2lP`gla2`^`2$Rl}j<4ja zH{HzERV#VP$tSWBXb@!B%e^z?i=a;=2gPk*`_wGEXJ*(jIl=Zld${wy`{;C2thKZn zO-37aR*W>*vT;3!ZQR6~W|Pr)4>aQSpX6Q{71?yzj=J*uDmw&7-|J}ErCbiSHH-l@ zsJQ%>bwP1jT^-8|By(+7J6Q96Y$33161aE+*2sUapiW||A1rs_qkf{UQ z?cqNz`xf1?F*1WSas`cLYU_&xw>}rU1|47;xOIA#i@$$4 z-@oY=el|5r+H7JP4eZDWlGMN&oO*iBQLEMPvlBs1=X%(m-idZQ*lvoQon=%VTgFE? z_oNdz|Kw9R$XXgeLj#~lgnf=Us#{UM+H&iB|>OQ@||Pfsk}jRyx{A#pSD9@NlP1)pN#qKUOw0gpuz zu&r_0&cn(T^o(VfdVX}%t-SM!s~Me|;>;rslO@vi2`Q<4eE+4>nSLk1n+!7 zBJGD>8M$OLavpLB5`Uf(NNY_d$H%z%njdh&xld;-o|j|^s0sEfs{`<2bHF?SeCfuU z`PgOO=3Wo1Sx4Ile)B7aGp+*@Icz* z<~#1={WpG`+MeAUvvQnQ{=#{jx_L8Wv6E9rN=}^>hiIuPs>&g1ipS&U7$hz$BBb}= zEI8)}PJxQ?A?h>N_E%$yB_RFlgN1B?yz2fPeBc}3b7f)1u?l%!gqgtt8 z&=3=7!x_n&b(Yg$c&jMBAOPQzs| zEc*!-GSEB&Yy;l@*)Q?kyY8a9W(~7z)}k1kiQ^!rF=QybzhWc@SJ~_6R4^vN<0A3Y z&`o>T2q;`HMa19)rkf+!<|tRW9yeTi8D`u4TyV;1yz-p0Swpb;wkQd*?0iigi=2kM zYv}cWMKB}_GnUBs!hLYiCkB<+hABX7bT_MmcY+OMU5Ej{@To5s|arCTZrZvW7bCiGh;f?(B zH@?9ee(`*M`MAe3PLZv@6hqNWJ`-C<4ym9ftmO zl-&nYz^z@+J3f0c*FUg}&dQa{ZrmKqx74GeVFYUf7|9wo8pk}6fd^k;glM7Tq+q3B z%^jamc26nLvkC2_M(yCk`1t+X`NTWl&8wes1{a)qGOLl86EYkCRkjgJGq{T7Z0U#GO+8+vob+38#mxw+S=iU$IvBu##M#Dmw5%{uIGckf_ApY2Bjbq|Q7a9;6AK}E7qRckxJg{y( zZ~D(qaLH|VFd>HC5^hJ10LN`Pgrwc3rYVT;|K9oQ-rr;b$cn(+nt^*P7{=tI3)2T= z;_rzJ4j6xb`}B#V!d|&d<`B2=C*4TIl!DEGPqcpna)cK+T*?imDb{-$9uHZu9WA zPk);GG4xtv^u*v)<0(q=HhQj$F~LPJ8#RcJ{DL@&Pz^vq3*IG(m>djP0>T~)GRxHf zag);EFceO^CIE-7hZ#vo*R1DVU;Hvx@0nm~DOwq;Qj$UDhAh3STH>7sBX(}Lq2Lf%)#60D zJ%ruBMc=!eAMTk%R*sXRagi&!Y$%xirf!tD>6-!BP*@Z#*@&utB^Ese27LvFooMsU zMj>axBw<{UiZYWV>=|FhJ3jRpwxje|TJHYY29uy1JJRF@r=N;W?jaGV8B>r!7Ebta zRAVG`A&PBq(F|BAYJx(=WvAPCBp&p32rs|v^DLf?KeF5_qtL`s!((SAIQQtSJmug` zv=&*1VSl@%UX)4T>v#Q(&)@K4Wc3$FdU521RbiioD z_h+KHYu&KfXjKQg9KqK1rX9Pv;`Q!P=dd2E@BiJf zF-S>0A+lGnF!=9G;rRqEgux-m>ebxaOS$ygA2C5NpVEiao<49@M_36w|H)70#P#b* z_UyvAF6#VT1E1K1aGoHa%ulusr)huu;5;^N-Pzmcz0Wx#*%)IGz!kwn|Dk!OC;NF; z4A%GHfnBU=&vM~YpUP^Wwgl~td#D?Dt0Vlw;&7`#t14n@X$P=(YR(ngC1RQcu&KVm9^hN>3lAz{A3ph(CtQB&l| zEaUM+xPbfaR#_V^V^>$T_$nosHeGqkEler&ti?syWpQKAPD%!uv%wU}jKe^{-oU`{ zS$R2TJ>R%8(CHO*Vd3>6%=S{GImUNx{0TcFXjoF+27=>d17$PtmY2Plb2hA@zGpY1 zR!AZ+%K&*cCeVNy@=e~DBzj>4KwzR0LkbQ{;cNS!Q}sD7ArH37f0hmv?0N5~A=JGm z*}aD+G%Rm_-EVS4vrYqDG!efK_CDwU*WG>x_sve@TCEsC9uhH8Rh0Md)E^n;O^LdM7{ky5#PIs{24GQw9toc6o3b zLgTvJO0f{kNmPe!AWPQ0Z<&uKQoLiU5em~kDlN8lZ4c)pDWKcAjr`|NDwHE1=WSmZT$zxA(|KGgJdfOp_iSLg`IUdIOedOwSurNOyvUy|03g)Pt4=SIdF zV~_%&GJJo{RU01Y^c>T(vqOz-7T5J>=Djr&parZ0-f-#@`M~RbgJZm7e9vyiz0#1d zd1WD0Wj;e7(@Ci{9@Q*9@=v_x9Fz=P^gBoKHTB_PM1u>ZR|Jn$Pt!ZbW@gwpHNjh7 z{44zN`R8yjFa{*CXTlPd94j!}>Ge4v`Pe$oB_rzv6-!L9%;kPs z&XCBmE83|4bzK)P3JJuMhTk@>0aKfTKaS4V7uw~sEw9~=(xM8Tzvj!fl zRyZ>%@xtEii8JFOHS=tr!wqnYe23slsHELlj@-C`4Rt$|h!=bzWN8H^sbfrpQ4CL8 zHO7-)`7(YEeC*1r`Q(*XGT9m-9UG&g1}CNu%(V>EWgz>Y6sw)N4SEm8;KNWI;~k0b z(rEXnPfv0D`nA05-19hP(?-_CZivbAGG)o<9b~cWM;>$#we+fxiDH8?!RBs#6gp)6 zUaLkjsm)Z$@g55P8sYuOz_FNUh-OUw_5JJ94iF{Fr%XFf*ESzZc}2S&4qLmHjm=sZ z8CPIo@t4ZLeJKUIakqSJq)m}2T!_Z@B6<6)s>QWA35Fz|Xiqxga5jv#xW6+4jaJ?{ z$`&JeH2i4Q?Bkiv^LZ02chTVcQ1hNfH|5M@k70GeREQIs#oCzg_ZYPRTEF_k;nB9fSCM#73GtR`?stHpUIoj-4k z6zImNDHBinTbm6|I_O{;&XZ_y{3r8vXZn%aKG9l zpOfUz$rVh5bY88+(g6(#$|~RmXP-f9W`d@7)S~UPF{VTuEn1*ws<8}=We%uWx#+o& zy+6jrKWm5&%Qy`g!90@|XVT3~@|j&9gB=DdLQNGrImzSKt>>&`k7P6l&$8HA!H>}c zoR)+IW&3d%AVV(4PJ_gG_YkR3)@`XImy7LWYx0X%ozk#|=brpTzIWXZxO2LV85zZ^ zk8JzUh|Yk6(K3iId*L>eL8O$4kTHa5(CnuFL`_bxmE6X+7`L;wxBvhk07*naRJ;VW zZikT_+jz|@U(On!me1JtBcSLZ&kgpkKjH_1Z+$_rB8AVTg|Eq2{60zmdmd(e%1o!r z#PkfEZjT2hCi(gFG*{pKGj7_oi{98cP7E65mn@HH~<_513? zJXAoE^AYCz23jyAQ{{}q59MoX)^N?#GTzq3 zatk`YH;SXS>^&$bDssowLI_zVGm@B+nY3pao7lrkp8bogi=!4Zut`-Nhs7nc&SV9d z%vK8a>_v};h2A=o1vw$9l8OZVc!4nFy~hyer28o+s%l}!6b7MY1N;5hy5-Ma_EP@v z-#-RPLZ{K7OVQeS2q=&t1g_!?JreVBC&P%b`;)sOi=5J;XyWyho#1R@^6Z4BF*ICC z?SUQq-Y@?WPu;kIu{kqb|L_`aGW>P(g-(D*UkDdB`Rr>tg^#yQO|yN^9`?-4aL8^KpG4xifirgVT z2dwo?od$xFd{LU&H-5^dMP29VnS|MutJ$OQ6TtL@FWz`dh;c?RB6y$D>vTy_NF8*0 zxXujLz=*XZUTGS`seZttV-O>!Oa=>^hXsXf}#?l z<1;Xvfo2~i&3TYWNU)5a>8LNu>@uS$HD!QjfW#}cUYFLc-TeM9{}NBz9PR^?+dpL% z|JAy?SdVY>2?XYI%v~0phQ3=LITFvt5~)%tCl=+U+Go29LZV?_e$t9@-u~;a=8Yfx z5O;Mu*cIb+ad>aCfIf^jId~{aF>?YWx_1|}$oNj9u}$AAAc6!2EvNhDQfN3ujkMd0 zZr{fLc*(DD-oXdW!(}l0KT|9wY!6lt6fM&LOMT=g%Sa4y?g>pGO<3k-UwaduM3cf1NdK zNMZ&|u?9du2HN`3e;1AA|`lBh#+Zb zyGlEycK7}09rs`b(%Bho*I}JUJG0R3u(DZWi-{m?;U2@Ri63& zzP0x`r*c(SCv__eNeGk$5{Qf?1c+cvj?Om5cx;caeec+FpYgTF;~C$X=kYw_gzwCl zAUuGKhldQ~vv}`~rXZrLXdt@88bw=FQ9|f^`muL07r@IDG3`xv}{yR1#9IE@Jq{ z>GU#{9<&0iGHRebH^VudHh=Wnzro2uRKHz%b&N9wHYyEtrL5D>0}maX<DF7#MT46V^|T-qixph-&7YSXg@-Lw^}4iQ5ZqQ!hZ7-Lv0*rX2A zN~-qB#)LGv=PhV(^%P?Y^-IZ9+NKHue2ZnyE26I@546@vQB8k0Gr3JDFKXF}DKv_= z_5CcEk`b%@D7&#;K|BtTVt3d?Ek~|CI!?ZxStrW{`w$4jd%GpqwSbe|^VW z`OtU1%Ll*qb=uR@%(dENqGW;eSac!9;5L+H4aQ%iEBnk$*c5WGZR)~iQHB8P!c!>k zb0Uy9q2n^reS5id>t=rU?Qh|fR2YL`NiKc*67kqi)|bL3){z(w9+$9LkYllXLI*@} zPJ~2jHSYCca@2JIXo$!qEIMiU`u!I@k2hX^IsfTn|Hz$tcjG3fm`@W_3{Im((y1T# z1D>pCNG9hkYt~h|8B7%A)`5i~L{zGbI?M2`-TcOtSMbuOp2-BB#7W+klpCf=l_Rhi z$ppIq40hR!+wQrKo9@1ghxQ-jk=c3nEH2VbJG3UokamZjf~5r$8_2Q@oeEBe&J`L6 z#u#jGvDh?xD_!D&RT4IFl|W#t3SNnZ1z!6)ugcfB2;Qk;M6kJvrqK#d#593<1rz%G zUB016&6QTAq4mp#@=_@s-UJkyxG444*34fJAAnZYSRN$6J_rgzhkZ6W1E@0*s=wv;n(}c55*vh3(J&W_sID^Sno8d$Xkl6|3 z$0TTzX!vgpC74khEfIe8q6>J$0k|mwCFiYhLA{UaIHqbMS$Fu zz|CkKo4Gz16N~0K$umcJ$BLull-BG#n->@O&6i)r3r;zSDJV?B24(nSZpr%b1S3EX z>T-(am(#UF4(VFAY4}0zW7hTL!jl(VjD&?~w3F~Z-}Xixu*%F{)2ycNn(E-f z>qL~9rEsb^SGJEDF^I`i|DG7z1H@(ivrP*fPDw__!6~~C?paj6{DXUGfB!D*>>N5f zLp$p--AOrT>sBs%+Buwm>ZxoS8D<#4pmY!qCR7;`LkDF<;Xhn?1-q_J3dM#hQ%;muz_;e*Q(hhT4Nf&%UdzUDTz@!PU%>S96ZQo+v8VX`VwAz`e|$g zIyK_!`oXawxu-d6DlVGJu>~4j!-7!ncw3i~e)58c@kIFZSG}CQz~}F|pO1a>Mjps4 zb0fp_5<}Pdx@?7&UyCrcmT@8??1>nRI#N+mbqpVz;e>9Nx4huFT>Z3j7z^jo3NpuD z5z>-j$WV4IEb@)J@8L_|yMymNx|7{1^d=_hwL92O8=DT(+te;LG8>rZxd4SDY{DUG zl*BB_#P`a6r6^CCnU@MDBNu0BD$lukRH#b>au@5bHbp1lTIvaeLPRTmztDG?NB%DE zsL|MXPD*RuT&u{IF19(>L<<1Zq>1762MlQdW#L+-Y%voFgvD@55w>4+bDBP#sPGcV+qo_QhPnVIE1*L{ZX?s$aO^hOp;iZiAEj#Vm(k;|^3 z5kV*o24?Mp2RTiIS6*^4FFF5QHseXlodVPi-b?y*S=Nsyq=;LTS&D)8(b_B5G}B;= z@hLcQPYg!K`f=F6LyCr!uJEQaPv@u3Je?2T{zKmXm9LO)J^`C}V zl2)HffQ0G@+LWv12T3V~zlKzrI>@72Jvud#N_V>`{Z0U+p zrQk>c%eGu6rBRc=>30$svK9OG=rf#ifm7qV98A%IX;J6s&d)H0aQ11ZbJa60;#p5Q zgW=!?nyJz zb)@@u^Ok2no1Z`bTsAt#NEl%S;k*u}YL9)L0QqDo5BCQT^&ny`If3a~K_v$`m({F2 z!vf(7eXD%=TtALzzQ?Ta=QP%;3DKuUWWfv}3mO6$Z~)jnKaXj93t<hY!x zj5^b2{p+Q5d$va>F=VYa-~Z8%dCsXPl40l=6Y9@iB07m$?-D!_a`e&EiTSw8XgZ*s#OcQV)M;6}%=X-ZEMGPOmTKHj984S{mvpq3WlVjAlB&ST|T zo!@69sAWQruI=W*F)XTn(L zWGS$f^GT#N#6$ZI@Dsop6EX}!ORBmTMd3`EHzrwjJ+H10x!91bu}0wWm}-!Ul)2|F zXy3SyCvm1h2KS0c7`k2^Et<|Y{C=I_a1;eV%~H^oEG&u^$V6BG=78N<#=Vd2;Eo4> z%)O63#!vR`<$!g}sbL{a(a{mQV`IU%ryqP2GPwg)>9p)a7a>#2ycp8SX|8|hVLtzN z*Dz!=hET>6!*r|7DI2G__$g;{{^_T4Lc7HH5`5-8XJrgGi22W8*sw=1Wm|BHrw!=qB(TGI=?pF7y`PBV!CMvqh%EKMzxD z1v!$dT8G}=YoL}I8Q@ZUAc65wv!?f4y&_Pzl(g&6Y=}9;T3u18NQ+)2>9enkJ zAH&Sg((ZMcG{VNAA)dPZ1kQWP89eQj)7aGVpt=+E8QM|D2uo!-qVfa}BO=oHOj8}q2e@F2oI;2XE!!56>#UGCbw zpV?N6Y-pHXs|9HXi=dMm3pd3$aw^4Ww3<%Wr1_i)pnXE*7=+zP$^)6>mc6r#eD_XTxBLL+=gP?pxp8%IJJb^Tc7Z4E+TdGL_`;%KQTubEB*5$P&k6ns=w)7D-e zFKb#f%kOudQk?^4fmRCsIVm<)$yo zW*$~TlNba#v?grPFTz&uu;?cxCQ=F-!mqvKEu52f7{Qx5 znZVC=5m49*{LAfk@DJbk20!Uq?8Fq^v_&?)iEOMSefkz!sovN`Ja`H~DmDYF+J#GB zpGu+NsZl0Qw2%Dh5F|+v^<5giv)KQ)brw1u(kZMnicc6@rU}9KK@w`f*s6>_U$v&Rz!5r(>?=vRwF0h_*CZ^o}kY^w`fN z^m7(ms88oDi1VQZ@x~uN#NU762JSt0kluz3EOc79?WfYiVJS8G#UvF4I-)*0qq*+B zL#2!`^TkhV#J%Ms;0Q!$J#R%#zd<4yYQLE{l*I%X8^er^^Hou9`O3Fo=cAl6ImIu% z^eQggvXPOHj&320p^O5rJ^c(``mf&2Hy?eB|90)QJc?m)WQ=aoD!@{rz9w$e(L#`{ zhjbUPb8~dWFqyX4GBM8f4O5(b(uq9nl+)NaI>JOMj0EkA1z-mF@Galvns48XotmO2 z650&4P*knnug5oE`rmKq=b>5$ExLk%C^74yU&BG6Ee5lH4`&+X-~Ygcm3oc?tf$lJ7x~DZ*dXVYLS@~hKA6@Kr1Cvi;B>5T4cW3z;Jhw&c1!T z{?dzi+vRU4EW9P?FF2qlem)!ozVzUaxaMf+CFL~@Nbgwj zM9ZR!q{zhOWhfW14_x1TspPH(Ob%70=XzmNNeYlnl3>(e#kVXkAj~8wX5%L2Rrtr9 z`}p)n{|Vi_lPk_Xn>W7rMV!`dF%mLzF)w00!*Fz#^jtNy&LwIjyB>Qw4W@>elBjiS z{a6bwgXXm{q{t}zVBbOBd;Rs?vSSBq+Cq0^l*Q3;TmTAau=I_hocOur9uWDm>7**$ zWL9W_t>O+zv(hbTDH{Nic$#gd%B;eSF|@Xx#I2(I{+DjRJ^T}{Jm)Na_R5!VQkpP= zmmeNQ8AW)>30ry2ulzE%?c2?JK6xGY?tBat=$MpEBg34&bt|WB+00Y6Z{y7E+n6>c z0FY7;D~Z19j&}25sh>^_Ev#3Q#Lx>0lD_58tqKGT${bYH2A6jzT|(oj@yNlYiTDp1 zpL@_&@H`FA7BE@HM5o0RFbxca>_iSt&!3#YO^)HtI>VC=@zUM7eD^G{dokm|ojbYv z#}9GuV~=v*j-AZd45u*M>+-IvU&a+@J%tTLeP0X7q{SSZ0Dp7c^?c?22br7Pz})yW zHkZowdfWwvB3F7elvq~Px9b{XiW?_RDsA-rgh#U^J$a>0h6NWUoR=*KsKMGius#fe zlaxi2MW>i_h}PDV`1HXAuDj+VOwP~q))&2ySDkk@<4}O=_=xO-{0S!ge1?g$06; zJ~n}K;5O{4LtW}-$7Q?y(v02Kv9=VZm(udm7?RLWdz&p zc@fK`>eRKG*1VOJCfpHP^b`6uB`T#2BSqUGVrYr+pwvVrRFaRu5}YQOK;H9jGhFA- zc2X`sX&dgOZG|&vFYt3pg~w1%Gv2Z(&$8XTdk=s9iEDY#LU(G4*{$2Ks=mz>DY{|$ z08r5MXo>)H_sKe8ZBTbaK@8SrP`uRhk0r@1RjKf}eKkpvVzqTeNe8SLx`0fKF+VxU zUw{Am{MF|_&$CWGk$1o9m7Ll#jN&t6%4)~=`f;Qw?Q7XUv8I^@NlZ}D>jw$z#GAvL z(vsTuy^#Li0lfEyukrDlZXw&Wh3*O4>E;_qkusJ>hI}GbF&$Y2P+Te!e!LI7)F-HB zHTph3goKGkqCW^$lzEf|F{Im0;6{{R{J;lzYOBR>z5aDvFgeaJQgmXp0|b+%_Fy9` z%A7TA0RAr%M3P!YuDOJ_+O*`L^3QVMV$kbX`ur*$2SFZNZIA#bGRUB{EJJ_LeXCea zM|f+f^0(G&cNoKbm=B+S;79!RXFkiWpfmP$DhohYK8_vURoOm}pQ>T~K8)Nok*S3g750 z@~yvnA14a@?(1H|#T%y!vZz@H4@UXU3`n=F5qv!^fj)%?K||gY-Qn;Q^;JjXa$m$F~+aokzr9qq%>}STjqq5`i04% zK1Qi6*tObT(Sqcv8*AVMw@J6^@>*_1tV;?jMH?ZqWcj5ESe7R?#^2|}yrx#i@;cab3Kho>~;JXr_G|2lR&u%(Nn8j-+eCV6s=6`+bCc2w8 zb8y3E77{7SYu-F7GTDl*tIueyU}_D{ul09DEGO1gMQ28(k4>M01(D!wp;}S#^lPn) z#22D= z{I(OxLVKSip~cmPg$Cox=0yuzRj*<22@e7ftACc&#oh%ry7bu$S3kv;(Nf*H&y{l3 zxjeF6h6AApJ%r3C>Etwbs^zyoek~hk5AyC;zk=tVd=f)W88;HljGDKF<;n^5yJvvJ zly=bth<1l2uils0$!qQT=AndocNH+y6(4gkCXAFm8UX+pV@mys2=G@7w@d&?`TIH_ zJUH>CoJ3NRBnj7km_%^`D2q#Sdx7_U@diHegCEe{u!)0PPaq3r3PCVQ>6sB*Rm*^N zDZamM)rRZKr<}#r$Zn<7XE}D9!xlOoI&clGmrk@#-4l|$eKjXi3S>AkXXzoNlaq8O zCi#Qw{+a28dH&5CU&kdIy&ZX}MyVuXmbo(1&^@w#ESKriJOhlN*dQzIj&UNSkv@-! zBp_Qk3UZjoBG|}6tJpr^Gxt5fUtfD2`!`IpaPp~i1Dm|)g%5RpsVNXjA+TU-oCB>z z59V=$gr%ZIpMI@HxyaI~>IV13d~?$kf>Kl3JYq$d6~n^l7_*~e{QDce$?)zSy#DfM z^M*?=VH{yZ9m8V!z{9}$bHn#iNUc}14Y88pYDrJ^%tGa3 zBT~&g9mV)XYS{pPUip8UsC_-;p$14~uV%hgKzMxpfw|vP-)KBHHqPScIKTVxPjRvc z|MpF<YGMpyz7~U6-H1?R09;x zB%cB9TCbF=&5)8LeNcpCVhXE{Uwz*P zc)@9>@_Vm%DVsydq8+)Qt_OmTZ%p@rJLg-3jT%JFv72ddeR}j!1T~<>ww^&?+46+~ znZknjQp9_2_$D8@?KZM4o0%6wCI**;Mcis(78O_2`)a`za@tA~>qiPONMV{3U;I*w zW9*(eaB*CvK1e}8b3TG~IQAHW*}RRLQU28@KFQGD-Mr(4&*#+_UdUA8xVa2btcHU$ znhQo7qWNVq34Ixfoa5lM@Wv?qF5*<=0!#V(Wm2ZDjp6}=Yc`-N()lKA!WHmf3d1O0 zS_%eOMIuXrP!^<1EPPABMUCvvXMjh6_y5Zm_?J8GWPWNRiyJq4`DVQTiNO@^qxm(u z0i1=^#>u9smAEp8w3)XOTA6cdTVXU-q@`W${G`G)G6PgPOSjd*nXk&S z#3MziR(tb^!x&Rq;MP)d>&MDaXx^*7U!oGS{{R3W07*naRN#&Sv;5&lKFXu57K>ZA zuwas?=o8YyIG^s1`ob%z|4=ilFaC$K{IEK-)SqgRngile8H#*7)d_x0_1x8^{r&yx z;W#M5?C>b<&Is?h<6i#i%U|X46SwnAFMBy>jt&*|-gGI#SinabYhQ}12nSW`NBvoo z9PAYlbX;yQ-rHd*!ezCVu7TN6O4q6ZlW($7CxLea4T^_-Mpn()h&Wr}obxpq2Af37 z5;Es=z#ia>cm9y~fAP!gO55}%rp_)@vSr zae2q(LOua(i8Fn`xsc;t%2+PU`@+dnWW{`&K`^b-&7%elt;;S*%KygSy_dINdI>-O zyyq}2zEwG0Hz7PM*#;*$RoK*qlsk4>F5}ipZHVZaYSin;%28++pI!KeAN-KNz5buc zwryi3O;Ai=f0rR?ey`){>xztzUtE*I!B8s;e(Ang-OLZYg>rTL2GylK4~m~Ru-l1@ zUGhlVM8rLJC>jX4Xity_24TSn^CBcCoWu=k_}2gaB(2>$x$1)Rc=L;1z==i*$?A3i z*rK^nzmG<6#^I2PoNuF9lquzytD9gX=bR*1YbzUeUxz_1xa6v@DM||Bqf0YD_ujew zY)<_5^{$F)G_A#kl$%<`V>JfJvjHsJ`q&QM_nCj@_Japt^HydyY^G~+Q6s`TNMUQP zfsh#sm{#TNk95`Dg$X8n0J0n0|P?wOhZ zbWwUHVd$h&_+NM2%PkK+#GkzF4V;R2kBe1g+}4kkHnB(640?>pNNcdijeYJbK1Rno z5po4G(0P(Ow(Zu@kCMkaLzKN&6#qSpLpK~ zY3z;c#XAN}w%R;0 zjh~|{Hg*Z+2LGBYPM-@HHO2GE&%4r%0KY&$zhQDE*c{NAsM{hQ@ftc=N@?e>KJXy# z|J)b3_uwqLaU+YvV=N4hVWni2=Y+=kwZkyU|4yhpbji(_-9x>KN}3o9KMv0 zYeyN$$LRphPTpc1S*;E<6AS;HrfFsD#F@DM7EGYkrP* z{pH{C@80oNo-sMjWW;k=_pUh9a;@nfo8rJ}Ftj?xK!Y@BF^GmqG|&)-^_I)pGSv%! z;ZfiZKKf65XYYRO=FQBB7U}DpiSL$C;gQ&7(v=a74R*oUpk;4-lnN_kWNVePQU?)( zPOF|#C>Qr~<8gWFyw*|C$J~!2CZ$rd&rN|$P4A)9ImpGIY|WCojvYdJout~LCkZ5A zMVJ9(Vw%?26rVmY%fI~nM>%Q!Aphx|zshN?gkij~l}6`c)R2&nsdzeE0hq!@S_pAf z??K*@n%A-8%s~vg;!UCsFv|Bb(Z&)f8AjzR7tSZ4EPrS)4a;3Db1+cfOslS^K{+w0 zc9Nh%SG=ympML1WeEyL~aobL2ZhV4;@hM!`*zycTl5c{MF_gN&9X2AeB(bqh`dn$& zK}|I(&s!(+`(dK*y%?3LwRlR9fzC5W+CNEF8JlVV$Ie=C-Xle`3=t(Ug3Y^FLOCPX zb*Rk4d|pI6jcVpW$kcae$?!0y)8cpE_W}Ok)vx4=(@$j*ZwP1BQNjvfrv^i}QnuS@ zfA5+`vaZP*;RdtTM94fEs-xib(!}zyOP05g0l)P>-^Z;74l=)CiutH7I^P6bzEUhz zGYIx*aq6<=l+oTI<1+_%;mId)$)-&Vb-ScWL;p1^5*-pPH99KKJM`=KMZT^cEtOw& z%#v?veh*c|R3l3dj1rQhRkTyK)X~l|+6(iH+Ke_{FQC*za0RGXs_jmKJ+nk@X3~%v zwkQh%dwR;k*f@`oa_{~FbR+Ywd@{xhL#%-?m}BC}t$7>2zjfszBVIMBucjTpcb!v| zgA?lnyJ3P~LgEMjs@&+hA;Yj2!{XQkGik~~ zpnz#mKrAvMksw)}*V6R)#q$YYi29Bq=SZDpDC?2T&S1J-Qd|%Z)5f(eUf;xd@vn+X zW6Uk8ou{MpiD&{i<=<_~{FpwIdBL*`&%f%bUdGjR~F|}_$8x|Mo zsI6qN`Z6)%KwO~&nLoL*yT^YyyfD;0lhQlkB>wc;>-g{;KV%oM7)n8ru# zg`9lknF6CC3?10Zg*M~;zwmbc;JKG^#kQ@q=jLhxH|2R2>yqnVWcq$y`@SOjxhPN7`NFM!UJ&?R} z53H+XjB<5P=e(Pv^>$ULB!vi+MA4SiJ6I$Fijn2@QrFX$;jGJ{2Z?lJl@5-H?jpmF zJj_eCY~n9|_RTzXbcEJo&s&-|+yfJ3_VRAn>c(3IyJEm3wGNqR@z<#J-Z@9+TmTwU zv^~0&tR7L&P=F-sFet;lF59|`y!okT@S%79Ja0YgG{*PrVnQt)Rh+X+26L{y4g}Gz zqSR-++1ddkENnlKzy918x#rt92jI|OGF(4a9)t}t{Nq^EX^={)vY!aKewap7Y4Po3 z-<$98hadO=4|j%{8y&`}ucxcamHq~_;tj&A29%7JDr4$sJ@zoabj6E#>$&IA38jz` z1miR~aD=_5kMHSgkkA_c&B;gSa8)e4y1O?CKODNQ7HZ7fR$Ip={~ZP8j1Q96qY zoT6~qB^UC`mt4pl2M+SrpZYX+?VKSSpJ1`op&J%!8aBz*X+4zQ06p6+M0pDP?z+@^ z<#XJMjJhIh%8at#HC61*sCDqM0QX$Rr>0z|BTdk0t zvL&fHNegAm#_>`_*);>Hh_{gh;$g>jY0b_uJU7EnUwkode)}7l1RhfuUFecnt#l*h z8I`hRRsw}ITz7)ttRpL|jj5D~#Q3N0ODGg2o;1nJfl{{0#=}r+JQk^`J-QO5i});1 z!8Zx2o2h}8%h(8P0DkS+m+|t;F5{2he+_q8i4?z6(pl_b z_U+-BCvE5FuYN6OjgK*jXZ!c?duWDdv|(vsQxMLJHnTLX`LX!aMly-@1v9-})U6 zPEIkKv?{N!a6a-*B%`RhGRsoP(t9P*%sOTeMz?I`BRAg6sB^sSvS+fv&(flHzpfBj zEl(V>q2sXv8j=P{%*q5@A1Ad+wI@W)1NQ-c@}ZCLBPT43jp9O7X@Y3eihCWUOgDBE zIwEv-KFS-k7*^T02--FPT(!w!J!U*u|7e1Z0U-)$H%N*~${bn?x5Fl(C({LTsu5`AHr=^w zb3|4o@(B^67?WVL+|0`rS_{?8ScNehTA~T_84ns_O^c|tR^twtoQILm9}TUywUndQ ziNkTWF6zq~7XR;WJnNY}efu{4^xErSa++?tO&4td?!L21YFwvEc{02<17;OQrZ(__ zZ{Nh&^fa$M^9-hgkwCJ(>@V-hbBH}V$5f`F-fFgfl=s9tRKiZ+zkT|%e1D-!HZ_Sg ze#6qD<4RW5-k1b(--zomy7Mu9^XjX4^%NX!xr0I7HbcG9GbJ?I|(1-hEJW%nGzx8HVQoZl(5M{Pe zrc%Q>ueh37;JbVG^WU%g40r9{Pc}Y9HZ;V7^8!!4mDr#fTIRIM#BiZJVN43ve}39B zl}(}KnM9GKR3*L#DO;Lg>Xm045;fp_6ev;Gg$p@ud7R5jqgF&rund_In>WrnZ6yBt z(v>MQQnd_@i90DG!f6SpYQ)A^qhGw#WJ{p!GKMpY*|VGTH*e1s`xG2f(}RgwTQTttk_jl1#_teda(=~vX0K8tdeNbqFIpQse$ zt|$-ol_~QvSsKyBiJT-5Q3YWbrRCttlTT#)?QiB!{{AEE7#qj7hseaCVn~W}B{Gxq zV5KpAZhV=hXQ10|Bbzq!p6maa(#JY(QYSepFk=oh%(Z3CCc|~# zU*mQ*2o#y5FC8dAijV>wU=rB4VUp**?ak~3Zr!~o#eenn5yCt=>dZdkyE_Fr76u@@Z^QI~;rq{eqZ3{!i~*NVjPoaNeIvj3 z_aDKH!$P}FCS^}eR(K-LTu5)P8#*9w#SD*ScC;r0EavG5K$$70Gb<(rR|7{S} zb8-xpC!|)(c13y7`ydqRb8;+$0SYBS28qAmYpDl->mU3HAHC^Tbjuccd5yGQ>x%7s zwG_>Y?|MPdGKS&aBAtgH;dfs58lJs%Gvkd2-|G-6kY%|&b-8I#)4&%U;7gGAp4W(( zb^tl94t1npkVoGu*vuO!sru_D2?<%PZDM&Bg_h+BBPoatK*sy|wU*d8^3>^Y01%@n zCv2YPg>QKyv%vieJwEc~ukh77f5?1ih;(X-d6fCyB32A8lpRc(K*-uyG4!kpJ{DR; z6B;r#s#4KButtmYd=)&IMm~uPo2pio)FNfUDp+trUmy7Dls>i5yF^`O6b5%oj{>Z< zp-tBGNqW_LbSyC6?Nw@c#bo&8QrUlpIB$OWnl>J%$l?Kac?0IcBb z6bQY4CdFl}I5T6dC1Pb%_(biHA-Z64fYVfATZD9EggX!H;Xi%+ll(ugc{SSlMs+c3?F9;o8`(iedH7rMOn3twVx<1|^?!lK~BtLf$9Jp{MHs876t z`il_J8^?5Pm-e3B{K3z>nyXGdneli4XuMdKRwyHDSOpO*XLGBw7!k58^9$Gt7oi+HBt-^7nMBw;lyb>Se~Oux z{uBp*?>)AIPk#Md-1g&#m~FL@i79$%!a^^@MQw>aTe8gKjkBURV9I-t1IAqUA!o+< zGC-{hK?heko%H|M=wXw54WdMO1YU|@7mAO;);CEKEY6cRqqMtiXcSI?;ZB0sF4#p< zHH;)FNiQSmE|AR5aMIW)FT40+uDtMkwia^TiXkHj#fN3%SZQul_F520gRf2v5lZBC5oGuv`Y*2PV3P^MDz8FrPms%3<6;8{~+{PA1f z#J~OUN0ANF%%v%d#w%~SqQj_Ke=6o%_%|>_iZaRqFuGwgH$VI^|8(OmyzSCUicXf& zFmK&jd#O2EYIwLtIr?L=JW=*`ALo3AXv6LTyMPbh@D(2GS$boW^ulW#TC~;KsK)Ig zJ7Y0YQOC(A?U3y;vU@kb@cifT;?qxMs{Zh$Iq2%Mm2RkOy9Od+Q%h!Z=R?Hg)?9&6 zDmCjIR2Jopq$CKc^J(HlQcozX=}=Ty`dUgO7d`ujun6T*l?i1G@a*#~z$sfdbJfqh ziUnXVaQn_(eCiwD=GF&)%sw1blasW2U09f_WO%9@n(I%qNgkmO0F!b zg{-&}SFBH}FrQf_rZ)1A-@K9Y&pw;WCdWt-IxCHsk3-K2qYM;9lw&H>pjBgM|SXqyY9wq+=La#oI?^rqWyK@KAT-tOw=ErsM5C3*>`}~ zoO2FuI{(}PFvuz-Xya%<|4SR(N{!Rfov#5bFwm@wnu(5tR zMwK8bCNTPKH8PaQtx+G3HAqZU3nRG`qTF#{GjQ6bjlAsDS2Kqf=X&(OejdF40nXjz z8T+lg9WL5DO;~)zl;+2=!B*4rUWv!ZAdbS}%qZqyt+d6KDDn<9cTCVs2P=S5y{zDvQF#7i6}gC(Mi z8ki^@QAXzGId6KBpMUWS*c=wy!rgMoeuV~-sx%1;Rnz+UBBDVID*l+d7*FiDXL7~J znj)mBNs%iq+!Cq0qKMQYSI=@m3l`B?h6|jUm5#N60gW_gAmXDFK6@CL22Pro;Ia!Z zh#4UT;Lu2+P}G7ZUiY8@IOVzkmHvFGg7TGuh{G8L<+8}0Tj4=Z5?b$yh%{t+a?!1( z4v0MC)Da=Y@{;q;4Wiw(4j(Z`&L=K^rdl@%(u$RJ|0b>#fu2&1d!?MDx4%G;n;O9Qkpv=@79U0C2ofVrkYk|edO5ARbe z4z4uqrz?1$G*tObNYm#Fpfr|!G>ng&tmxl26%%aCl|oTK7$`G+v=%rCDx5Bt{Wku~ zsz#z*8nOyfP^1E3*C8$E$;~vgfM<)}+|7ItEQf%Xc(dFG1AZTUUCfE4yG1 zsI<6;DyGObRNX0kmQ5m)y2{lAkBO?siU+`Bz%O3$0-iS7!OYIk3N4tuOsHLS|TQ4U#S z`xpie^T1COK6T^GxQTHVfL>tumzwVCYlJ~5FbPpKjXw#zbwiU-kVA{K%#;RcJRX z<B`&4Q^_$WT_(br}V1PwYI(ci6vopCtZ!t&NIi5T;xjdJ+P z1-FW4@3Tuexp(tdgg&VkzH-EDwQ*Tl02U-a)1gFPDF1<%|c+i_-g|3KIS5 z;Rb>`5iKJ6I;W;SE^~pc5#R*iov(a3ql@#jvqchhefIU`95YxA_TITco}pq+=TKy5 zn7a-fxI-%AIbR0^2SbWFCH}AQRdk-8$ zhKI?-E5PLdp-z6u-T8EYrhg)q_QE`;jgD~T1?Lxa;5zxLC^<+!w9R|do%EYQ`A!7- zqKnvR*;y_6$3+B5jQ=XUmO~b$!Uosn-8lzNGS|4My4+4$g{@lTOP!s&o>K;hW}PvC~GWDd<+rF8`nFrJG$OqQT!k4u@Byzk{R}J(k zghuOdnGFtxenD{f1uXB;w$>J8zPJU~IagWaw86bF#}Wmmh*sgEWaG-8=WWc^dQqoR z{7DA(KOe0+Wl`i2hiGZb-L#qK_aqe)?61RyQp=)Pe0yeW(cScYJgyD}CaG5EZhk(_ z1|Nm6x#q5v5-AG$apAA2JvA}S$v>|Ii#B+Ql>I3a;#agoI3T|9|h{7NTs!@tlE<*QqA$ z3)HOn{V{84yyWGNZN<&6m=LPRo>s#$CCOZu;UMtsAKt^Ak3EJP879jW{OUqZ(I!$! z@oOa5W5f#q&Cc@7leY8XQ%|7{q_VnoR097&GfQk~t*T_sg_ciKNteg=#c??xZhNdU zej~4lhOZ(+5cT;Nr5f!Y#Kn6Ts3LTbBuSwk;0boNSg4;!P&EM(7i4~2Nr223-6GO7 zrVvQ8sOJepcHVWftiD=&{wzW{qmPUjSvtdDbFRXg+Yn|o)$Wd>US>RS3|yGfZ8Cg? zm$yRa!&>#5FISq7I2CO3ZjW++4pu{~W(HWMoRg375)62+mm=$8WJzv{NPWP##5oWqQ z=1hyve&gF52zy3dnCy534zXIYtBD{cNpft0hNPjEeasUdn=*|J3Dy0;4d1y9o3_bF za85}jElhbDNmxp{&d%|w%bvvqti10z-WWn=q9tIQk_LCnT)4+Cm==wA zD*2#J#CR+15S0}hdcn;og=?GIGYMAQ1}UU+2S(_5Dwckbk-I6z*2}1ph@ox7M@LT} zz>ostLv4~?=1WPzCm@lkhG+qH0eqWXzJr|&OCN4zBIE-_&j2F%-nDYT776HZ@f{DDw-wI{DPOx~l){;oo{B008 z7(_&MfdV_|`@H;6%=$D(`wVgg#Kw;=rWoLtGrs# zjm>QgLd!aa=H@tSa)R?u+QwJ_4XasgMT8V(1bE)L=d#5JLo>6CpmYo*s$l^Vk^nAp zQf3luIBkUSw z??fF#lF;gPY0u1X_NGm28X0=R@UU|kqD#(v3ePxkJL#@n4B0Ln0b^ZJcB*`Z?IxEI zk2kOUzw;7MHT5nPU{;z(m06q|MC2e&0yr_Q%sdsKFMltqB(5Bb`Ip+Pc&7PRqDor~ zskIC*b{St-gX8F3jy##^e3%}P)-pp2A1 zgZgtzf__Tk0^qBCFTQS5v})%6=I@ukzT`NBIS~EcxTtHv^&rb=Vdz*#Yhj-D%mH3_ z?$enF_ghv{m5P4W21bArla%M5brzj{`xsV7+X;j+WSJTCU&VPl=jUn!hDE$;E$aVv z$FAL#n*O?pRcBw7HQ=DJB1$mKfZ^0wZ&?{PczA^NUx!h)uc1{u3nA&<3^${LveJnvdUnE7yGa zD=Z8TV_O}piFGMJgK1gr4w+{Xob=Z|i|<96$OzSWYkYXDxjUkODWd9n_mk<%O!SG3 zQOaF=6D{9jnVJM$Tx4iro{P3@<~M)yU7ViyjVO71qN;~Qwl?3e#(~$L`!t4C`P=J0 z%h1FmS-V4K44GE%henHd4#HN-p~!*Vz-PA#z^_qjab8ksn*_J8fE+x)1skXN&0qT! zo;ozdXjn(8W>KYaUh~r7iuU1ggkO5zvsm1-lN;{;5ff9BEI5ZVNhK3A$e}UU=4c5> zx68!*Jnww@6}<5DlYOvr911z<3VLe#3DIlW0(yi)``l9fpuhtT{`i`|=LcQO;^Z_v zC*)$XBiSM&`*!gAZ+ab9Zr)UpIhNxf=`fGdMVJ9*fJN8ecI34h{GwV>a?2Mv2gAn8 z8Mg|{+lU)+2(qjB0PckjkHyd)x`3}a<~lB!QrqaIFT#y$t1+ zQ4{GHlwdU{u!(p;H5v$9W4W5(6L5Ux_j@tA*Ii&y%koQ3l8ZejDumPk%8-zS(DM7( z1y#fE>(65;FgZvGaY>h2HI?4-R1ArM(U1ij2~mBjTJRbQhsmWj&NYZAG$Jc^fw_Dh z1WR}qxHmTFO{hc-Kt*||SwyV>%W9T3y3;BbXi#ugN?n`E=O!YwjW8TqCdYy=M^demshImNn?g%b;KwCHPoOK*PPA_n^00+l5)eT>i1z;Fj|qKjcc<` z-2ka%!}8TOv*v#Dk2ly&!ooQWOhwPLP}wlmn;uJht5);uQYdb^KE=Y7B>V-TE@QN$ z@smj7^IfVMDnac>rP;-CRfaZ4sBDym=P{Xsnfjh862#54xJn!N9Hy9qEbCI*FQr|5 zW>t_VwJc9k%@-8&#`kSV;qRIMzY9;vH0X>h;Y*Xx%_vJY9OTotBqo;gYkpvrVJ8El z<8w~L9I`d;p`>`9Xe20K91aqXnTK%(w=t`)KQxC7w&Iq|^93B8hbw z@b^nS_j2Bt06bUczPsFQt+;UycwOb(S2_Nc`#U*CEwZb2ZgL1ZoYPW@|m~WSRGp6*yQ|m0l0={Ejn=Y^jw_D>@xPhos`ygC{dQXTB1OQH|QkXddcpWgT zAM3~Zv3{%{9LEIt+-0?HNLC@hI0>|p+dNdjP>wsnbSU=W}_2XFDP`vp_lb1=8iZaKFjPz>Td7doNp&I$V zJ5`KGc~g&&MnK-4YSn=lY}}@!f{_)d>8&5@$NI5;tRFnlyC+bC3r;RRDwv~vg9G)BJj>&%MBR{YX(|vn_ zshu7E({X)#rz83PtMa)x6e;U9dtQe+z7EH@bcdYKURG4wHcE;9^=3Kt9NtJ_!y%oxwxyW@O(>|R@Y|?q<}Mqy}+M+_H%sd z_S;#UoTlrPp)6zi;9majUGL;+0y69rt$e4@?xz6#HUs`FXDN*S|67~$)K+EI{RODsL$6N@~)`Ru$`^$ zg5=0(*{$kEH?9M+Hs_PC^GnI8tE#uEubG@Zc{2^#Zw79VW)cTgzkwq{(U8>-!e(uF z69AA`a-RqGSjWQ12#e!mI0Gh2=(UHrXa5XO8y{mdyk2TzvrS5o8r5aAemx@C(1c?V z=70xgW|<$Kg0TtIIWlMIX^&asb2+(Zgw!XsJi#K*=ml~#BDK0`-LlBR9OhNStZ_(X z4il1d4K`MXbN6$3dsVZP^Wj#;l*4`h+=}Fym%C4LWQAdue?GFz^{H9i{CyHsOywwo z@Foy32pGh@0&b0X4e6TP@!6V1Z18xlkZ+H4Ebuzk&CFu617f z8^w`e#UKW{&SHTDgkC6R^EJtUt>f1YHnMhVw_$;La8cSYJ?3wUH zDwF7u0v9Zu0+q*ix`%5Xu3Fw)pJZ-Q^28N)(!iH1vJSxvO^(90nXbiiP)ePK3Su&) zbGS5sha=5th_ecr_|5$waNlB=kA3OOeDkh*m{A}}Q1uNkt=U;#bKwQN{beupmayG! znJx;}#fY$wdC)KeeEqRU_|NaVhM6R#m$nMvWHd@U1)E|IDw5*oiSay;5!YiQ8Nc)P zw{zacO+M@3T;PZpEDA=js$|xZ;map~{*jOJncMGReq;n=48~bns%#t{=EWCWz^k73 z98PPs3cKiZH4}c#je^X|4AKtMA})uR0eF0}=p6OtCs}R8E34tWn8S8G#eE(S;>r_d zQ#nL%Cr4`!>@SNxo)gf=Ye>eEhO&4>=A!5N39iOwL(M;L zHC>a8Lo_Hez=?1WxOM+5|K%S(#t#>}==3JKTef5K%^N{okKuF=IyGHn4y?7PBpxsn z6U<<(#o>vP2iqgeZQ07c&M4h>U>dmUWGt@RRMDfV2Lt213>IcN&>3UCa6$9%!>U4J zaG8H?&w9Wz3oK4d(mVMyx)T$KNwL;qjNzcd`ySoJ`~JVbl`c6%II7_!T=bsx@=qQS3?e3Jo#*p7rQ*vFtOCrm|r@9uTBUWKng%xDqMvxz;{W4`U+ZQ2styL&HQ@TO%xi z$KbRwuXEdVb4-AS$Tn4jZ&j7(7|4&h#u@{`PE>&&!hYbRw|tksyy2^Kw`^r$qD6+_ zq(s_1wFp8=f=v>7c>nlLVraXJo>MZTXs=5P%SgM009|0`>C$`Jj?H1WdFH9nDZI)OXo;){`D?hwgU)fsuAR&}#Ys7=j4{#H;mcH_ z1h>=L!sN67O)6kW@PA$|tHu}%jx@8JIx)diE2RynI-D4GEG%;Op4}{%1Pzgr!5|?R zO|vdf*|>qN!^1^pWi@+17ufHV2X^da7U)WXb*>ODYb6HLvuvA~;N;O^Iw&cE24i#^ z7J{9NU4DGvAlG&H>h7@ZR>Ig&i}B$RQiYB{Cu|qP`0kxF0GR?f z)Sjn9v8q@x?3iER?p?d+wb~#_0Qv@1*KFZjJkRj_8uIIvY#NYAiV>wHFd>H1CMOtn zN@~D{a^A!H5Ae|30*i^C7gjUWc_ABvlGuzbV`H2=G07;9M8IP?{gk}>=&^3beYC+Awx@XO7z+d5l?gs2H5u;wCz)9Ne>u zr=7T+r=PIZuP-Z!^!u#G4OSg0$^ydOd-n0z+$>grBrTz{Dyp`agSMB^b{UtPdK%+W z%RH_!vR0tyESX8ztMH>edssmIx|+`yCm7@BXp$rtb(r2Fr;LxYWpsojkp1Qm9yxG; z{WhZ;n#=vUOE5l^#`)q2v<}hodof;>Sh2hcOVz~%B3|xETjp&AHx~(>u zI=`_?84u*7O&w};(*yT&^`)0E47~9<&*ss&8Lt2CZP>I;ve@P7^Uvo+r=CO?-;Vi( zTW@1AZIc<#c2(z<6?_woMi1N79=byn)GE#h`%!Ma?|v@2;OPtjzx^|>_7yJ<90x7= zC$jV=It$#ma~J>D2S3c*^hPpik%?d=#i148Q1u;gg*>X?%(OuNTH$phl?5_YCEdM) zUw__nc3jN+fQN9I;?4)Hd^Y)mdbBhF8UJ6N@6o?w+9RJw0jGjIx)tpp8YI->fEQX2@fQO zLknF@xKfm?$oY`rU0Gap_;s3hv7T$Z)&A2TmV8!6XM8HQGwvJ@?4qo@HXYy+=cpjSr zU`Uopd}shV2Y`RM|33cn$39MW!imhM9g=nn>l{Xejws3eEGN5+zx>6Y<1`~-yK<}M zxHyzqgr6w@Z|$9-J2~aKP!ck*A@exydUU?@|Fiez;gVJLz4zz0*4{Oq`E)ll zG&9H`qY^5q+zQ@Pk zcLlE=8zYi%OxsN!)I!OA5fX&w9sJ3)|IT_*m`a6)5bjsW*J4o)aZnPC+Om;19exOZ zaLI*?1NB`0Wuesn_Btx5gH&lY0e}9D?{M?uPZEutAQR5t3S+R2v$lv3c%4bSp7-@TbFz)4bZIANOXxLTuWsPhLlpdOsb-e7V%Q=6Q39eMhm7~W>zca z<5C+_A%e3h=onM3Eb7#n0*ThlshHF`60O_kqEI<@3Y|$?c)mdK%&w`^C8&ZP9zjId zW>ePvWG(;aj=MO(7=HK77jgDMYZ$;%MTqHKbhBDb*Q(gT0g`%+L=-8R3|*lc65MI? zh1s>QPqFhLF0ps-RDzc0iDDA-OpC;DZ?&8D@h`Ny>F}R3>#6L@oO5~r{(_0>L_(#T5w?i<%}*n8eVNfZP4dw5xM@bCm% z0avRq-%Lp=B^mHm5)ZogMe0%LWPCWS}-gMA`l(3X@$^WHs4kaXi+kkI9 z@>6blVm*0vWF({*ly+4Erc zAm}t=58}f8MQnori|wBxwZ!$(Y1j=X#9B+LG_5oxF=^pr)PlBbc|9I^Z0Ym^3d~}m@AiQ)`mzT zh4oKEP>D1hj1?szp%ckn)pn_UrWMvWoOt;WrF=IfLXadOwhrggfDv&luuJcrd3Lwt z!*{MbgSLsUvWveHHZ{M2`HSXkCw0m8cp#US`-bwbeEsBN+r4{@0v`P8X zZ(Pr*BZK_uyWYm3;DN8w8*!n+NX#jJ3|jg7(m6{c-jX-!FwZeJfdXssCcNn-K76<#6ygjy*sFWoxM#!+S%wry+M;Ytd#%|+?Oem))+CDq=%l(RU|DEwSdIhBJ=T*#7J zkzj^KX*$D$4j$Y*S=f{WM#g;xSOn2@jXRucZ`RqyZLM7ZLQ{pxGDS#rj2arFDUJza zd1zY$rhb9e0m3-40;|I!4Phqj2$K+;bzx&MSn(86zxUZ)n$qdws6o8qr^TV2AoKGa zTrG3%D^6m_ro^`8Khw3_mDbue#=`{^Kq0BfX8y`L>Ne%n)%#Nm3ribl<=Xb@h2ytU zBvI~Szbdrcy)CWZuqh;C9oMl(QKoX@kUw9(QgA5oRjxJ`rnYk4i6?N%VTT~id0+kJ zb7hS^qwpKsdCBR_wJ=jP@~hleqh8ZDb*M57#}#*v~auUN(X(^LHM=dR|ju6P#* zsrKe79nXaB7(IB2F-xR0qzSq)$BC;}aqQ~V)NDeOx$miV$E;JNMs#!Z3>?%cX#&$qJVI z9>a7dpjzc?(Q>v3zYaL&5Hr6WSnn4{3s_Ja}4Jn zus@|Dcy~waovvfm(oT|8NPgQmfWozLik7obtMlZ>7x>2AKi~sroKATe8-)vN)Fkkk z@7~A;1Y53STo_TgcIQdCBEYcSm$Np_uTcp&v=;MQuX{Dm%{OUeYejZNL<+btLE;h( zrFih^4g7f1X3|O>rxey2&_3aeu&9|Cj$bjxtByRJsMSJSgLJHbwSKj6EHvU+F7kV+@}9*LEg4eV zIxyB(9xfYC_{}vI@s~nusGwX9EmtA4?O;7Pj|U)Zdc{^)&bM`d7_TQD=9xZo3{|r2 zs;RQyRYQ6sGPg&oJEYEUFPXm_CSQEQN*E9IODTyjShgIowq%=MSnJ@J&Xl^ z?W@jma0Hhfc`!$YYOpt+y7!~vdbF4&j8y=dnd+uUFm~}ti!T(t;S0vSC=YQY3MHLY ziC@5M7ha^tdFnV8TS!C-qcLZRTMdqm6_>BspFyBh03s^q0hLZHDMBMG(pNq7FxT95 z53|E#%xlT-Kjk1%;0A_xV)I76c>7&^=gLQ_( zrqhitC>A2!@2~Ldf(U3?-myWa0&a?DWnKIIAM(ibER3%t&B3AVGa}CAvuY8+IzRw1xZ;FkSsgk#(&PQQ z@7|(F-|m(JaLWWnx|ftrg9N~cTHNircoQwyBzAIeBs(<33Lv!%j1#J+2#Dt9*uNZ8 zNm`UtniPIL&@GQk@TdrabAK^HdsJ*nq?PIosL7&p|)db=*Kv zXe%4nZu`5!9N2%BCY6X;=a>?ZxJ0Bq&Ow~nX&Pe3eB3ES2}&HP^U0Q2hq8o?Dx)sR zvY6!yQwmuG?qabR4;(bE4Ubd!zu(FGDXa9dMQy=YMZ|Y&dW>AQE4t%6z|J}8sB&|y z4j?Wv19Ezm$Ffv#R1iLJ;t8y%RQT%~Z=*FjhEtKhMV!h(cdlCDM^8M?rnAprxJzuz zg=f`@^{;}ig6{}=t&)mT!~uX=+KHvJ*Z8q5o!;&OJb1vV?9ya34Vo`M_{Fn)^#}LS z7+*n}gZ3;D3Y8>Ol%k|G^No2-xkBn3Vg;uajY^H@=V$q+?_9^9T>54d9+)UQx#_3^ zxK=3z*81l%ROwcEc9XG?#3d%{!~Dayuj6I!dIyz&7tssk=tB3W02E%^Ot|ufn=!+q z*vOA1G8#9|5NCcvft`a8x}+&$jmHq=$#hW-RCf_L7bY)pNEGXlU(*~Wg9^9rW9vIz zRjsLqpN|)-PWDpury>jLC9Bim8CiD0rtP}2C$k$F6t0V>odMevp`9M+joB<OnrD(z8 zA|lKuDb1F_mUK9lb`5D<8}X4rW^A8^+EO5* zhrjpZ3c+A;N|739)drZWBzllqZK9XUeBAw^m0!n${b=D?Vu9Fv^SrNOP zc;@GTCsV^ee&;&oYIRyslqc{?6mim`IyJ#NFT9W=*BrnnKl?9i!r@9)(4Ld;oTD;0 z${p)BaO|UNdE0TvFpaa!gdU&T;6fJ}a;IrSoi4AC|)-d8b%MlJ~%yYns73{bFN*-DJ7^yB} zwMIPH%j@+h)z!7^b-$<`fI!LL#|t1i-x+j)t%k)pLnKAetSx+)+G_+VZ4dlJ@qYbf zq%>-e=rlM9qBr`NDi^(=ol^iowee0CoC%e8N*O5OC}XLFhi0j->LRatcvXQI*Ev%T zExC#_)UZ^sh2Qmm?*5!|*Q?a^--_$}J{3SEO|S;S{4iUjc@a>)MKw9CV_z|9g~78k zl7J|0gjaJ1e=h!g(jysjz5cGi!pJZV*81us3G~eQ@V@gvQ-lU8yxO0a{kwm8T4ExC zRl3dP4p@u~ld;?ctSAcRJ5JJmKZ-(2y&;^L{CNV*HWTJr2}s-?orwQEYb{vEP`O0C zr1NftEhd>3oYlRruG1dRQ*d}qQ2F&rf{|PID+#Yw;TonL*G+YF=WN%~R7|iGf=1A- zgkgbl+M?tfO8K8_I7hQw!LDAzUw-2{K7IegYz^1Zu>O`$p z4#ZVTobgCl^&=%L$NL-ui(%Z2?{ zDYS<~G)hH?F`Ym+`Q>DDHGKnPX2G^>oCzkjC$??dwr$(CZGEwA+qR8~C)Ue3=e|GC zyHUMX_wHI%8L|Fw?2xEn;?WHIC(}^UlgTtYcfT3pZiN%?OZ{HayDo;1U#}+V%#=}< z@iy-a^E!=WUZ0+4C=v}oDM%H$CUee6mnakpo_>9pmD*FDtfwBQ{>J9HtzOpdGagT8 znQ=Kc3P*rBYZ~9D8sRXtPHqSiM{~SoItf-(m>phDrP_LkGFx{|D@eo|D)vwta*aSV zsn#aDilmG%GF2Wj)*eJC$c7Fz&f)LVh3}vatgXl7UI2V;MUW+WcE{IDH(Q-#lE5VO zG4=83_<=qs{4PwCoqVUUcs;CW@Vd#2e!Cy`gQf-JS@W<%&xAV=%J-5VvSr zSrgr0xIrh@i{8*l0q|U9pnS^wIQwo3#m#B9^N~j056P22rJUf5!ol8RoArb)?_+6u zFGy^?t|5Q8J3=$DOH=L`j1-!Wppr^Gc;9319?aIdvq2uN(@E+2Rhd}>X8r^~AxETP zQE5muSx}|ncrnBC+_S@X)g_o4!-5uD4ywB~Wi@9^x zd;d^14cw!dDPP6yWA~Shgtq*?LDTagvMuJz1P#wDl6hFqt|pS`wBsUy zE?Du(2*}m`9?>L7c98Mt7hCE4P%jZiMKB+Ew4!emCIIz48xA9H{TcNbWihbdCFcK^`aVrxZLN^f!F_!TvaCk#oWx zh***q?ktIDnQKE@a-6yFy(h}5;_%ELf-FFB=)Y7#RpQ~|ZpAO$|7Dz_-|!#9 zo3Jw8qBK*t+ZWaWuvELE+m5n|uLKWn@`(zWS&g=W5&i2S9C|Jpuv5~Z#T03&7b07r z)~RnB*ExE;Pj$AwmYA3!LM>on^@WWDH&7xFN<%09qL9_@#I@#ma1OrWte!l#qf{{O zPA`LdG23$z;$j_nDJ^a}jPjh#fzZYpYa>98p%8N6zEL+b8|WMU)L{Ij>ALe*5ne%} z>W+7$fIb|mWVT#BDdmz1M#xI87fo92{V-S6yM^F=h_Pc}V2@0mAYPf5{-el{^7(Ws z#iuhm(yqbldmd6HT(-F4PBluhSsE$_R^{-5;ewe6uQJIVf zTDXm*&n9#4Tj**dqxBciVc+{KPZ;0(s-IDXG7*JZH(rPnxmkpz3tsNlL`v)KIDXHy zbfLe|IT+~%_#q2SaluP}dG^+UAV@+9#Vp@54(KS}w4digi1&w5-hTV5hO`Y(Scw6R z0$?TySqlXGUzPTc{8qKDuq9I4{fX9kK4GFJI68&4Wq5YcR`#L4;}Gg;ju7mWP1ZO} zK6SYR5(-{3`h7!4Pv#Dk$GxuV;`*2br1qe9QGXYUI>4>IvA{J;G>FPI0Q7w>@_sZP zT|1`(oi3xeU|ZWI$9Hd~AemaeeRDCv1$(GmM1HgLq$ttGcm3d}(uMlAx97=aWu_&) z8eY)FUvAfti-_{zJ`I18UN8c^@yZzVqK!#lV&vWu{R4-~-gD(gBg9a}fVFBsH2V-p zTg;^HO{?&QlV2d2~J7AZGk z2;DF_z>V4wvhJDWqQTgb1@%Vy7l5X|?o^HjTh}<56H(1Eh1Y}<^K^#c?~0meMOgNf zx>j@h%)L@DCeu3NY)&+$IQ?;Nxj-e|77J&fCF%}mKy({%*)yqw-8xtGTVGk45{-p% zW_Q~erbhMjU=Yrfp5Um+yVK6@nrks-JhNJ3a(kVh!Kw4?Qw3=B6|BFtoawr=1uaKC znT7l?<9GYIbno>igoq$HF50NQjspHvPW{Vlpr7;e`!o1ie~aGe`D?IH0rI(`cMrqDLQ3a~5^OaV6|F(ijqbAd|P*yc8UUefTDd@JH*&S~m( z(({dvuGfdNVJGi~c%){{bJk1#6S z6@jdh-g!FnnS*$#wH>-&$2MLIET>d8iyVnetJSFP7Q3)FA$iL!HH1wxus$pXKGxB< z*SFWpTFUo8QZ))D2C>5&Ga3Muk$WQU#PoG6MePCo=EDV!+H1L=oRTKL*5fL%k9~f@ zAh^Wju6_u`e(~_whTIdk<~v z_d$V|wH}I#bFV~odfxb(=Io7LZO0y2%y-WR2UpBPp}?Mp=8&l+jz${|&~+N(`n7AC z`Jm2yb=y@GcW|A@GyRHr1n5}z8BY4VZS>VsOlo)j)P z_347a!KD<_HdWJnO@m@xvno}yVg8tXeG46j6(vj&n(~;HbzeGX4dGN0-=q7{MskYn z9sMr#;N3QtqW(K0p{|E5r!tb?2ho}7nBl1XQJ8|gbN6u~;V~vA?L$Di2?*yoT`$wj zP4B=^EhmQ5mY$I?v3?xAo+lQ{(NN5c=axlDhd`XOQKiYB#)hGCzOTFMI4S1C$2d_+ ze=F8-N#<&tl&GNzI8M(i@GNc|8>^oV4EQOk9Xpu#Smp?octw%KaC;FlO8TkmcF>bry%|<0GtRXgYx}l zg2Q6QVeU?zCYCxtSPoqT8X}TyrpkWkxSe2KK#g9Ohq}j3^YLldlg8KnRLM=x0t_l- za5y4`3=ey+Y?R8^cK_hL*U)PJQo&%Y4FOP)?Shl4Av?Uk=_V4oksdmq7fqV|QDQv5 zMJT~JMbRY7uL3NYJB~Pz*$-8uv=eABQ9ozk+0aC`9W|TX9tivq3ol)>PUaGu!ohPxK9u*mTs?i2ae?uiAvKp((MY^72?DW~a2SqdE(@{`U zJb8)MGl@I3e^p#Dt%KCqI&3`A@ud>}?v`3P_VOz2cZpa#Y7`^^S11I`BDl4Jq@m1} zH4I@-5KM_>(rvktt?qpKn1+g(D_dcx5Q@mxpH6d~#^3AjC_Vc#eT#FE5ObEG{&u)L zqGn{c^qW%ebti@b6iaZkj9Vt%zo+R;V8?P!aPiPSjJSd8gcg$aId-BstN6`O2#bVJ z>V?}(q+2*jRu2gz4`{C+8;YoU`nBjx-D_}a@mw96t6>~?_l|}XtN1s_u=DrNgHRF4 zkl=q?%gI;pOk|)L^dK=AE0Nj=BN?}OSqycO@cvux;+2v~2F)Q}NNYM@lfeR zF_)08MHD1n8V8w}od9G8HaqJV0k01R4M)|GKcTJFe|<0$r`% z^^cDAf(UaOk`%81wT`9w$g|t+TkpM>Y)Pdgc7PhMa9B;nvNLi)lf#lOaC-5peW#rj zMb&f|R$I_uL%nGiM5*Us@jmYt!w^>j%Od+bqKUQgLkXdwWa#l^;mgYrngHx2o=+w! z0}m2ywOljkIvp8k+aDqJ&o`m}s0(@0jSxIUG^2`F_aiDm#>R#`cYj%bow~3YQn3(6 znge3h#nL@vv~t}O59N3x)T%M|=h0amwuAWo;9`z~N5_Ilk^(whVDLRhYaRHHcHQve zfba8;v59p3h@>c0dJ0NA$CSq+{lQVbr{MXkbGVnz(ZBfZ*qLl?6}^kYrf`#J~ngQ zm~`;@D_D>&?SK??DnM$gJ@P0xTg0&Nd}>mJN@)tqp>$d0kKuhUKf5TEtti((CK(e8 zuiFh;rM6_rv}amza!{)rMabNAW$)LPI?9}G-6rOdJqLub@9CLD^)a<#QQO~zX>^xDG-sb+)o<~nz zcgU#uV7(;ywP!v${XVIDoV%U|N`Yph?~}RA-Ro1Se3?$t%~i5Dft2teoxKd!E?;uv z?Q2JIfO06lw>rb5hRJxh?_r<}2%W^?Y+2Hp?q!WsO=s z@ecz8PY5R>mOkjmdy!$dTdU;$$phk7i=uKB?73?9^*0WZ6rig>r2=Jsk6dlXn@SAF zn|?ISaN?gSNZZw~O-!>!GN!H%ELO0teUmVUStYu|5wbkNnCJrMz6oIxQYn=l`)By_ z-dFCLj!#?D36%v!loiUb1_(BmaV2HpzyB${=BVX~T0FoENi4cqRXr|fLtEnrwMSpIee1+d(=_N8myQM+!L_+ff_=L_ zKQjaw5lb3XS;iTP41A-{Ld!Eq6kAxzNTF1d7g8XW4}H0*BwzXy^q`>u5CD#tzQReW zl-x9#Kj|mc-i9Z=F^kp4+=wvpx!BIiYdMPfEb7SL2#2WeeTFA$&`@An?sSJN;+I78 z$l+$o9oueu3S@~H!y_<4On*U4!GgKHd-Xu)EKgPfJ?BJH74wgQj9x}n@mOxrwd;G$ zzntwtEuGss1?b5g{IBdYR&lkKTD~T`C@vQa)yIZ6SYOC(HmBM!XSO?er^8GUTLKLT zy5di+RE$xeI;@wNG~8j9-uF#y?+*t0=9IPez#(S?H0lk$Es#PUK3x)-(9_b_#@27F zJkRG&b`dchQ6bB;6jY}mGNV?%u727K1pyIq)1G zhn*~<-6=rz_g+<#4o2DugZ3CcF&Tv^Y64{$vg>ZGDdxBxgfn%6rgYl5im&&d1a?6b zKkwJ$Xyz00)=YEknHp4TM4D=eQMLPLx2=A&A0e46p|pX2;*s~)u@9@WR>Uu;l3F3f zaHy<8WI3~vX0`y`rVvKtq6rpe=mYx&lIj}H4qQP@IV0~=D%qnVbltwalhg91OmeHh z-e^jUVsL~S7Z1n6Q!Tfd z2IjW00k0evPK{r?SWI8D4^FVmC*`xV3N}*;GMeIv#9E3(Zvm+cxh^8r~S1@$+_`?|0 zL4Y=VDP_`@+nt9QgJuNSV{Kh9<|JaZ&*^Q-pvrQn#GUZ%bJ`#+Y6kRG*%`p1sQwLn zid(_Mo76a-9GdH_yCH+DVY-IarfQ9%Km&(Z4kUqIRS^iir0zfK3Z|q-O%Pe@QX_Tq zM!JfN);;#+!uIr$f*b*fp_Xc`xqTdc(9uG2vS@*(flg(fe1% zNtbVwS;IQ=U9{wb@PSIoeB<>DH{Q|~a4#E$w%a`|UItht1Z7csy0_x!IqARJt}+*q>Cb*xuA&E zX~^>JyosBfeRxHd*XK?ON!@)lRv*ZYIyJ)Sl3Y!Gk|Q>qHqcg#ZCL0UuUlEeYMreU zB#6GSsL5nu78EY10dF}?D510i~OqAf_h&4@oAi&YnHg z4rsrolp*&dnMC4TKz!_D5tKDJuiL}3yYnThW|2|P0(_uLpa==n-A|_bs!(&hD=$SL z2~DITW(T*+_~3;W^_8KHP~2+$I{jGu-8zSbMM{%WC-AfpPKX+Yg?ZfAy4?>9iPKj( zDyE@8f0S6*{S(2_N!^Z{Y0*KbY+-}?tl)XlSMj}=S9zYmJe`AuDXd?50E!gok4V%T zP}W%oRfF&+*+MJ9a6wegruw{4IS{CvbnA^4SB0W>le8dQNJMu}YZ`@m^dwV{3Qvj~ z$4}6vRx~Rmbif3~f7=N4`YIZ)qE3ptEjqV@5w6UA+F@y~9EHZ7f{f9h`{Myc8px;g zNO%8^h~aDfu(Zk{EK~%fC5@>Q7}7=M#i>&yiWKQR9n=99sa9Kew?x{`O9rsux3L?T z9CjOtgRDzg879mtp}~fyvsMX=RyvZ!2lSh$iETs3F{Iy*yS=hBHU$eqpdQ(!FsL^5 zjZ-9$BWN?NP!eVw673&NN*v!u$e85Ri3zFx|I)M}`Q>%hfRWKSR=pZE46SG*3vo1V z80aad3U#u6A8ZANR2}xrI7uYMB%kNK`50wY%0k;ES}~)SL9l3 zTtgc_`=zyd(KSzt2ajRI>NW?$>)P{H9;BB6@jK5n-H?@XZwhn2NR1hHc6OxcjCVo5 zK_0TtaQ#ELQ0q#H`@USZu8BLSP?5%vaAvnyYl$jx3aqC!^E26NTGB8TahMD}eR&Il z-yeB~sfe?|iE0Q{w8}kHi@wcOlH0e>N4u;6Ylb1nj1vMQqCdqE!|@@;&h-totJR6Z z)(MD^1ka=;22g5Ug!*-Tk;1(_wkk%V2rZ!bFygLkB2n5duK=m&@>7-SLrRD2l>rI; zy5W3puF~qwB{MS%hYYJPct6O~iwY-ckhf%22jJklFM?N%9bIsBN#vs@)(wlhYCvkb zshc)BErDubd`E!wVnj^rSsQ@NOFR6P4q6;MKyl;R=deT1HI>j_nI2F}w8Wftrl>1Hy^>WB{X|A=dKi26 zHgZ}}WuUh05?Qw%uSyWwWNvbBks}S$A?PM~?OJoVn~XbLU&pL@?e{55NLefU`w5zD zwoR;n$<+}kx9v`KcuZ->{Xrl)z@``?kOnteh^tQ3KB*63%a#JX!sTwcQs^9wEVno* z+nFMbE#zXYiWyFIDQuC3)Q&c5o4t3SI-rh&j9M$Ej*$}H3Ig9JD3?bwUXf^Oq!Iq1ZghG=B;F}6P;n$oed?dX z4586!!nC^O0gvzfX%!JbnQM}CyfvV{_$#63N2fPMUOf>Sj>x=qpl&v?2 zW>vgstrWfgL8u^HCdl0prq^K~97mN#X`Brmm(^+)URo$|y_*)XY(ArREKsm2Z0Y6c zC@bd9or8upEIc!R>*7(``6tD<9>Lb66?>5@Hlc8fDWAc$w~!Z(N$GR{(OFr8LHnj% zy!Y-gow9W!ae?`3sIzHK-dT_$O0kT6OA^)g95T^PlWkOI&dQd#<+aMAY--A0_M>vK zD0`ZXEVoBcy#b*(v$**mawf0)goHE*yzS0_+D%v1d!BY&PS*bW?l*6#&YNk4F@Ir> zQN1v@)qeC7#b&*et}+x8Il~F9!^-i1I96Q^>QK^#P6E}1JdZ^rUfE=kQu4%L%ip%dlo zE&W_MX49Vam7gIaYpHBW4rHzp3`A+kH?#vG>+Niz_&N9;4mSSozwK;>s8$A&4TA1S4P+ zX4qhB)Pa+ETIcj8yF2Edcb7JcU^W6=bh4&7clr0X9QEW2rbfVM5#9dRyCuKDlG6xp zqK|Trk3<|ucPdAb74sF>Th#1ijkMI8TYYXk5-9RTe*0+wF=}#O@D0DdxkI+JH3V7q zzjtb>l8@?rUqfP6!Yja$@i?N@Tf|b?zfg9f6dOQhANQdiWEZqSu`@qk3BJi0Hd5U9nifFp(lm4L`o)} z9M9Rs$4|;g5<4-4a@^h?=#Zp2H=h8WRcDC6-lKTJg#RV?alE{0KdTgH00~hKfF5Z! z86vWx06-P~k<~ma`|E?_kA2;pmeu3Nbe!^j*kb)pEBht2rva*-0jF%gb%x_dly^7& z7@Z{&W0ZX0xHRX7#^{^wGW&s}RhcNrO=cKnNsaV@BVt0{kGs?<^WypBl&T*s#LjJUx8PFH8eprYS;H2 zfD=C-L~=1s9d%M#ZnG^6B@`Kad6B?d&+lQ1{is?w8R+#YqR`GEXJW`y|BD7JDS}5s z42pbOy0G<1Ol3Zmbl0lxH|5^fhj}YX3RkrPwF*PjJ=}&{8W72jY|*|CF+dQj4;C5FEAY#<$dR1O|~Kq z3Th(;aMy?M`ArUId+k|5{Bb)>B8fF=cy1NhjTrFjM#@F8td#`}d$(ZwR_KTw8xN3h zh@kYw4TB=G0q3<}zH5IT=Ec{jhbUron;@gtaQMgRdh=Eaa=DcMGQp1DRNDX4LAO_+ z8_-wgQU|eypJ|}7wFf?~=KA+-Kg$=ulckSGvMXMq|%^A;gS;;ox2KVT2-WhfiAhPX)Ont zpG9-f5;Qs&VubT+H;k64^>yI2S!djrL;GFv?&Eq4rzBp282r*!3CP*iZ*)qRm-fs! z{npDO7GCqkD}z?_TtvNBJ8jv0!$rqt)l3cMIp?ZFj%lypQ+ic@0RVS|}oiIz=?WZ2l z*E3D4(`N@6LlIT_=;fR+Mw1MM-TK&OEMMqzt_x}9fMy38uHCItxztSBs8-Ocz8qJf$TSNPdbEU83j6oROFRwuN z?A~ZpNwrQPbpY_Y5%J~Qa&~yZ1D)i7Sg-%<@nk*Be}vX z>^z@Ui=Wqc;nquXmwaRUJkbq;xYaYZX~eiGvk=s9tg6KH^d8%_p5i!ho3ztcz5~sU zUq8SnclN{@slWexxx^G2ZeGS1oKz8i5xh)Frg(?YHD$tayi#u&09^kdu|8w7Fli!F35Y2&7VG%v6#FXnsiGY1`1kf#_mmj&r>2&{ zroZ$8*Y-k+Q@0hgwB=Zr%pjm_u8=x!fa%syhpBb!^}5AW&l2gtRSQ!2YU#a$aBUTp zTF;Gqtj9=N^QM+!lD9_p+3z`QEb5;%?J=Gux`7vm6NouR5L6~_s&wt(5m7IBx08L_p^aRy=o9Vh^tV?^uWK9lP zb5{8x-3-UN(sNkXSYCar4U^5*+>`!4%i5mn_Y1SDmTSQmJLL5AjA8Q>^iV>J*g++@ zgE1q5DZCE8BAz6gS5I~j(My-?^)&{<+l@=;O6IH-$wuynnMBK3XnqxTt4=Fi2Y9tiWKV zNLsHoMYPk}&)%DDOHGS!<|PCeTJ}K)q~W_!<8xoH;ru;9iy!rDL~t|4UU0ZOOQljU}F_i!#wJ^ zs*Re=wrQI#A@0>JzbnKUo-CqhMbc-~1Sqz(4TVl4b9Z~dRH{XvDsyUp@ShK35tuXp z0ZcAXsUglNOue|iHd<_C7nqc`QjW%56&QplS7%ZFGxUE(wU>;{{*Tyfj5)-`E;&}% z$Bt_f1M+}@0ICruv|n_nX;O&e6dXT~D-M0P2#L(|0?6B!xm4Q9rmDeStGqL?m^7t} zxhwUspw6vHH2zm@o3hM1Q6;epArVc#R!nm!09w6E)NDNKQFE6%Mp$+&c`4k>N+eX2tPEE zXSvh-)1S2#wm+tP=6ggz=5arfnnqnyX-(Sx60n31dQ5%t==`jE|If&Z@T2xNh+jh7 z?XpX=Xz*TMnc5tql{R4dB3@>EG8JX8vOQl4U{O_Hd@xLV>Js6Gm@4`z<9H)KCmASZ zoF4hI?-RY6vEpQ{k7x4uw*R0A_cHF3KkWQp_uq@I<_V`j-FUv(XPHmCH7#s^p-Z=F z%D`cjJxmC}Px|m40hn{iK?sfZ()$HuInMOWr_5O4ZES#1ne?y^u%EXkS#LK$qO2pi zs-hq8>vU>$6cUVUpL8IXXG{LKGJ2QHV8A_Sb@P4G@Z91>nxkdpZW*Z5lq|wA5N5be zuwkhOIcT37X1}hWN9k`%)Vc!=9EMuJSKM1g5VKe4>H9jSv)Wy*Hsaa;T59RJbh~a> z`rOEBoX=wZuYLR|*h%|UBP$O}M+h~{A@UnlnEafgrA#9e38xW%(S69V|EZ~nJSZWW8wTM$SC=yrVIFHcVDmo;lZ%2CbD z6k;I4R4KKcC#cFk6R{>P(txLs5e;#~B2IK_xsn1@gT~bDvQe}Neq(X559nOx9$fx7 zcByt1o3=*j_P)>kuiyIk;8kWQlM;KWd7sgCUoU?&i4KVq1|p|FBM*oRc=;w#&GM!d z0PJorDQQ?G{WOIL8iE)@cN;=*$6beU0B$aUaez3zL)|Y`NqWYS-W?w{H|y;w6ujNN zx1#s|qW%oh3o~(^CXRg1e~7arz^m2fFllv{zmYdOq6;S85gI_iNF%9~)qb0d%<(=p z=x8LM%a{}Taa0~+8Ea6qI-}6<#oP7FQtC7z|C@y=#1*3HS<=2xe1fF}LhN-&t!G5e zto6814p;Z)^Z&w1-3`zj%?uY8)jnBc`Zddb7=y=0;F>VfT zNR|R2U1q4E%el6j&UW`8;q^=Q*}C|9v$S4RM$;UHD^;zI5YiX)L&cy)+(ZocpIfKx zW~QMw?MqFUR3uM7aq4Fp*StcWKXg+p7`T(Mz8-$)35PK#Ox%$Q3D2p0NLo@`*#KUq zetaEA#M+<8{wIIq(D<5`Y3hF%5#vim{!u0=T!Y}Qv&oFpc_7K9OJPXiEH_s?#SBhB zJ6P8cEg^e3)a#69!>h}5@YKi9jyw3^Q(TD82_oG=`qzJs`Sr3}bNt#SsfbGe)r1T{ zfi76cB+K0xL;cweZh7_~Mf}Z`E`(8=L_KF#}zxmN%&|8L-Mj&(07WOLlF}~=>__FXX_d6 zrn4^4z$actR2L}!RhNufwr&x6TJn0>sy$@|uRi^&8=>U??}NYVGq23IbN`s}zwg!D zC)gJ*=*wg23y!OY5wV3#Tr-iwAYX5KJvqWC$UyEfn-F|=7)s%LLsZ-L zV2ep`$aFxh1o0IByP`HRBA2s1pb^8Lxyg3peFuH)cHiCTOoX^3Fj6yCEtokFW&qye zs88vAy-4Nr;q@HcUEl28!8MM zHe^WP`R4L}xEZxYvhu`wIcn`0j^XplH1;Nu!8H)JKM?phfK6FKkg?fs`c%EGZ|%w$ zbQZ=88!3Fc*ZH}QVnwnm`M<0`y{4;}~5j8a}pOtYCWXB%GhVKSD$p2dmr>f&!Lh!$5wSR+j@2u26EC(HM zF|4P8(*U~fTi%|JB77!=n#AOaVufhrx)o1IQmgPJ()p8^?D}s?-nW)XcEvP|zJgo6 z#@gud=LlJ$Qmkfl2gVsguo4AsxQ|}FS1i6Q<^XI=qIpCLPJBCZ>Ux&45$fg)ik;8Q z#rD6+Nf83KHK{|oQmKr}%n;e;H0oKc9V#QFOgdlfg^{j2!C!R>EA7VV;i z%;H`*^TlVTN#>4$MUs(x-j`bJPQ5Cys5+E`vkN!Obue#RmJ#5+!|dh}MrhMS7E!(L z8?7&QsWxl-3YwBe!~>dAC_9V|q2w84nb~{)dB%!#+U-LGAh~c4lR)Ru(ixg={NIr% zED`$dJxZjGKXPs;ZUjHe>b4!*?x4oZSAdaOlqyo(yUsd~`8xC`)C!p?}GK6+wKP`ylu`5OSclDJ@heuvJi)qF?EAY z;4Lq1w!7+gomgHEgu1<~LC9umIi@`H3hSUD5v1J|IpJ{ljjAG>er zdo08uLm4#por7Jj;Fo5Q$e`x`p;X!g{pWcI9pA$^mzvyKSG%L;(?rk0>1I)4(D2tv zYU5O-VRik-uAR@%-{oClPHx#kqN>RLml=N~f$Yb%uoG@i%Rn`pI=&m88=P2tto&HW zvLUYs1@ZsE#OV1ApTu3E1aog(HEDL+5xbS|4#nvaSQ-$EdgZ5zLg@P*T}{GB@zaP9 zv-fQwo8xt65C+`g4WX;8Uf4u;aUz7@IO+^XbcixeqvRda-Xh!9&~vVLq_H~~`YJu= z02Uv#!v25}?naaR!{XA3DaXx=QTs=!`z`sLU5^KmY))P@dKdu^AYDaI{&(?( zx<*22v_oo&fi81a&|Tfm*vjj+tm+P7P3%4q0FX8!-;q5W37nZya&uV*MQG+jw#(Ff z*CR`*gK@LqZ@vNYnUXgN;s0HALgs>NdZl3eM_%U?+^s!hYj>RBpSjy}%N;S5aQi4i z5JOP;pw-}Nv5k==M-XRc74x!A5|iYPA{%Fp{0Gc8(NkHY=N_dZlb?|;nVk=DwSDKQ zW0y2fQs`q2>P?e=EIVS}S-GWtk{^wi$1LpYMKYU>{NtIjId0ElnD^YlnN34JcL%;XB@Q=4qtG1U<)NFsP z0dta4>lGfv#+9_$|ejfi)1*CYYU_kr1-Hq08Pkj+NA&5M(^+x{m6kFrMqH zxxmoKcxAXRV!6p^VCqo%HIUoYX_=4f*);pBC!Mud5A#zDYNlk23;hzW9279OlXeP6 zc|Y`R1^iPvU*ul*FOIIZ1|(z`WBX19SRloKKa@syd zS03VDxlbLWH1fS$2>b(Z{#ivJcG_O}X|+9Jay@58YnS~kaRvGiZQzwNRPyIhnsq39 z0&q^>c)1yud(CRPBXnuAdtd|WqPCr13P>7ZILy);Hh&Nh{V7P#e*qm#JhA%voipzI zTxmUOVXp9kY)s@?{pE?PkYu04wP$ynj2^LhL@execsB?V#c`pTa$7y z!D5KTm`u1q>B2P2eK;g*czwd$ID0MUZtEUz{Gz%^8LSjRA@}WmwQkihj5!?Zt=_Fb z;_H9ToURqZdf36SPRi4_$GaAuDZq^bseloqX{(>`;f%ufI6?Dq`(RO#rPVFX%-!h|7Pr7nVgu{jJB~`H@q84X zz+O&m@O0v(r@%S#l_|@*(Fc2}Ym)D|vK;Mr)meb&ZM6qI+imr|`Pm-4o#yQ~8pIY@ z2zklMN=@>w`G;@yhh4~un2JQ}1!>wR3?6aRbw+tKyYG8VzD&>h2IAx9jS?;Zxts0H zBkEmq8Ri>&PhvucAkhOEpxfqMXMJ~v@b!GceK{C`4yrhzXf#h$&b8}PqKfYfI&SI*8X+3SDVK(Ei3EI z!N>_3YfFnm=k91M5K&UUDQ1;LKqG5<%mbK{c;l%YFN6@1T;LjxF^s$}K_dtKnNdJM zUM^3N+G_6LLljvebw7OSnH>ZcTv7pth~;0hxtWe7S&V-01#6JDhW3!&E;m%a&Mykv z-*4m%w8fhNHqC#w0!`5Q5M7-qt)s2Cz+EBq+->?^IOZiWZilmvM06KMfW@;{t8!q>EHGaYx~kk z5>#D!IacLhr7S76b7IBld}hdJ&9dLfAtpcmVqcPewSHp4Pq6V(KH?kbsY;a(iLqRx zUjWPOaQ;Hx`(00s{(7Iqkse-YBcH|dy7~n<(s;$G)G}$>wQTKF5rZHQFdIw+rucS! zXAD<2Qo5)%6z65wSiIMgE3hqWFER{&Av0T^?6LUCm#AzPh_Z62|$v z@tCa!1NC`+>gK`5@@6D*8Em#T4#QW79z!h>NefBK={tyl{?JqkqC!Lo#uUTiJVw%@ zsu~S6s`f}IC|xx#JH_TN!n@LYrzK4%h*J~+lN5~6mBu6KG#wn(?1URA`q*XUpJKv?rLQ&U{ z&H*Vi_8;R~aqST%xuj?eBdKclIodzsnA3BHY)7@`v%34$1fIZ9B#75o@$ zb24x~0!)2h-EiBk?IE83rRwc-%%;21Cy{qYdq__+g6p`j3hGfKk9xAlq>_~Kc#I4c z6l4V@!C{p+l39$Xh3++)16p*=hFT29_4&u8r0owcOU*8 z_Lo=YZBlrjd!OdBU|lLBU9mXY{bXzK8AtRcQad*Y=NP`4UF85D;KM&HV>&mMKQB9b zX)jkQWZIB{EV4Ppq|5#qC--xNTekno`b<+qjd*>ZAjKEUh}07qt6S7XnMN}qjF9Ae wB@aJCwi3Aijps~>5Nomd{n1&bBka&XV5?rBC^6Yg8G5~4{>x0wEzGB literal 0 HcmV?d00001 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()