Whole ehrenamtsapp7
This commit is contained in:
parent
0c83ce4d6e
commit
987deb79a9
72 changed files with 7164 additions and 0 deletions
10
.env.example
Normal file
10
.env.example
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Vorlage für /opt/checkpoint/.env (NICHT ins Git – enthält Geheimnisse).
|
||||
# Kopieren: cp .env.example .env und SECRET_KEY ersetzen.
|
||||
|
||||
# Schaltet die Produktions-Härtung scharf (sichere Cookies, ProxyFix,
|
||||
# SECRET_KEY-Pflicht).
|
||||
CP_ENV=production
|
||||
|
||||
# Langes Zufalls-Secret. Erzeugen z. B. mit:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
SECRET_KEY=bitte-durch-langes-zufalls-secret-ersetzen
|
||||
213
CLAUDE-backup.md
Normal file
213
CLAUDE-backup.md
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
# CLAUDE.md — CheckPoint Ehrenamt App
|
||||
|
||||
> Projektanker. Claude Code liest diese Datei automatisch zu Beginn jeder Session.
|
||||
> Sie ist die verbindliche Quelle für Stack, Regeln und Logik. Bei Widerspruch
|
||||
> zwischen Wunsch und dieser Datei: nachfragen, nicht raten.
|
||||
|
||||
---
|
||||
|
||||
## Zweck
|
||||
|
||||
Interne, mobile-first Web-App für das Vor-Ort-Team Prävention der AIDS-Hilfe.
|
||||
Sie organisiert Einsätze (Terminabstimmung + Dienstverteilung) und ersetzt
|
||||
schrittweise die WhatsApp/Signal-Zettelwirtschaft. **Das Herzstück ist die
|
||||
Dienstplanung — alles andere ordnet sich dem unter.**
|
||||
|
||||
---
|
||||
|
||||
## Stack & Rahmenbedingungen (fest)
|
||||
|
||||
| Bereich | Entscheidung |
|
||||
|---|---|
|
||||
| Sprache/Backend | **Python + Flask** |
|
||||
| Templating | **Jinja2**, server-gerendertes HTML (klassische Multi-Page-App) |
|
||||
| ORM/DB | **SQLAlchemy**, **SQLite** für v1 (ein VPS, 17 Nutzer). Keine Annahmen treffen, die einen späteren Umstieg auf PostgreSQL verbauen. |
|
||||
| Passwörter | `werkzeug.security` (Hashing), nie Klartext |
|
||||
| CSS | **selbst gebaut, kein CSS-Framework**. Basis: `tokens.css` + `components.css` |
|
||||
| JavaScript | **so wenig wie möglich, nur Vanilla, kein Framework**. Einzige Ausnahme v1: Chat-Polling (s. u.) |
|
||||
| Hosting | **eigener VPS in der EU** |
|
||||
| Plattform | mobile-first Web-App, **kein App Store**, „zum Homescreen" möglich |
|
||||
| UI-Sprache | **Deutsch** |
|
||||
|
||||
**Erlaubte Zusatz-Bibliotheken (klein halten):** Flask, SQLAlchemy/Flask-SQLAlchemy,
|
||||
Werkzeug, optional Flask-Login. Keine schweren Frameworks, kein CSS-/JS-Framework.
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Prinzipien
|
||||
|
||||
1. **Server-rendered first.** Seiten kommen fertig vom Server. Formulare sind echte
|
||||
`POST`-Formulare. Kein clientseitiges Routing, kein SPA-Verhalten.
|
||||
2. **JavaScript nur, wo es sich beweist.** Einzige JS-Ausnahme in v1: der Chat holt
|
||||
neue Nachrichten per kleinem `fetch`-Polling. Sonst nirgends JS verlangen.
|
||||
3. **Mandantenfähig ab Tag 1.** Siehe eigener Abschnitt. Jeder Datensatz gehört zu
|
||||
genau einem Team.
|
||||
4. **Mobile-first.** Getestet in App-Breite 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 `<head>` injiziert). Code darf keine Markenfarbe
|
||||
fest verdrahten — immer Tokens verwenden.
|
||||
|
||||
---
|
||||
|
||||
## Datenmodell (Regeln, konzeptionell)
|
||||
|
||||
Jede Tabelle hat `team_id`. Namen/Felder sind Richtwerte, keine starre Vorgabe.
|
||||
|
||||
- **team** — Mandant. Felder u. a.: Name, Brand-Tokens (Primär, Primär-stark, Akzent,
|
||||
Akzent-stark, Schrift), Logo-Pfad.
|
||||
- **user** — `rolle` (admin | ehrenamt), Nutzername, Passwort-Hash, Anzeigename,
|
||||
optionales Profilbild, optionale freiwillige Angaben.
|
||||
- **planungszeitraum** — Block über einen frei wählbaren Zeitraum (kein Mindestzeitraum).
|
||||
`status`: in_planung | veroeffentlicht.
|
||||
- **einsatz** — gehört **optional** zu einem Planungszeitraum (`planungszeitraum_id`
|
||||
nullbar). Ohne Zeitraum = **Einzeltermin**, der direkt zu besetzen ist. Datum,
|
||||
Uhrzeit (Start/Ende), Art/Ort (z. B. Tour Altstadt, Party, Sonderveranstaltung).
|
||||
Braucht **2 Hauptplätze + 1 Springerplatz**.
|
||||
- **verfuegbarkeit** — (user × einsatz) → kann | kann_nicht.
|
||||
- **zuteilung** — (user × einsatz) mit `platztyp` (haupt | springer) und
|
||||
`status` (zugeteilt | abgesagt | offen | uebernommen).
|
||||
- **dokument** — Datei (PDF/Word/Bild). Felder: Titel (Pflicht), Uploader, `status`
|
||||
(wartet_auf_freigabe | freigegeben | abgelehnt), `ablage` (aktuell | archiv).
|
||||
- **kanal** — pro Team zwei feste: `ankuendigungen`, `team`.
|
||||
- **nachricht** — Kanal, Autor, Text, Zeitstempel. **Nur Text.**
|
||||
|
||||
---
|
||||
|
||||
## Kern-Business-Logik (verbindlich)
|
||||
|
||||
### Planungszyklus
|
||||
1. Admin legt einen Planungszeitraum (frei wählbare Länge) an und trägt die kuratierten
|
||||
Einsätze ein (Freitags-Touren, Samstags-Einsätze/Partys, Sonderveranstaltungen).
|
||||
Alternativ: einzelne Einzeltermine ohne Zeitraum, direkt zum Besetzen.
|
||||
2. Status `in_planung` → Ehrenamtliche melden pro Einsatz **kann / kann nicht**.
|
||||
Änderung der Verfügbarkeit nur solange `in_planung`.
|
||||
3. Admin baut den Plan **manuell**: pro Einsatz **2 Haupt + 1 Springer**. Die App zeigt
|
||||
pro Person **Anzahl bisheriger Einsätze** und **letzten Einsatz** als Hilfe
|
||||
(keine automatische Verteilung in v1).
|
||||
4. Bei zu wenigen Verfügbaren: Einsatz als **unterbesetzt** markieren.
|
||||
5. Admin **veröffentlicht** → alle sehen ihren persönlichen Dienstplan.
|
||||
|
||||
### Absage & Übernahme
|
||||
- Sagt eine **Hauptperson** ab → **Springer rückt automatisch nach** (Status `uebernommen`).
|
||||
- Der frei gewordene **Springerplatz** wird `offen` → zum **„Übernehmen"** für alle.
|
||||
- Gibt es keinen Springer / sagt er auch ab → offener Platz für alle.
|
||||
- „Übernehmen" dient **nur dem Nachrücken**, nie der Erstverteilung.
|
||||
|
||||
### Dokumente
|
||||
- Upload durch alle, aber Status startet **`wartet_auf_freigabe`** (nur Admin + Uploader sichtbar).
|
||||
- Admin gibt frei oder lehnt ab (optional Grund). Erst nach Freigabe für alle sichtbar.
|
||||
- Admin-Uploads gehen direkt durch.
|
||||
- Liste geteilt in **Aktuell** und **Archiv** (Verschieben macht der Admin).
|
||||
- **Foto-Regel:** nur ohne erkennbare Dritte bzw. mit deren Einverständnis.
|
||||
|
||||
### Chat
|
||||
- Zwei Kanäle: **Ankündigungen** (nur Admin postet, alle lesen) und **Team** (alle posten).
|
||||
- Nur Text. Neue Nachrichten via Hintergrund-`fetch`-Polling (einzige v1-JS-Ausnahme).
|
||||
|
||||
### Benachrichtigungen
|
||||
- **v1: nur in-App** (neuer Dienst, offener Dienst, neue Nachricht). Push/E-Mail = Future Log.
|
||||
|
||||
---
|
||||
|
||||
## Designsystem
|
||||
|
||||
- Quelle der Wahrheit: **`tokens.css`** (mandantenfähig, hell + dunkel via `light-dark()`).
|
||||
- **Hell/Dunkel** folgt standardmäßig der Systemeinstellung. Zusätzlich gibt es einen
|
||||
manuellen Umschalter (Topbar-Icon, schaltet System → Hell → Dunkel). Er läuft
|
||||
**serverseitig ohne JS**: ein POST setzt ein `theme`-Cookie, der Server schreibt
|
||||
`data-theme="light|dark"` an `<html>` (bei „System" kein Attribut). „System" ist
|
||||
Default und jederzeit wieder wählbar.
|
||||
- **Nie feste Farben** im Komponenten-CSS — immer Tokens (`var(--cp-...)`).
|
||||
- Erledigt (Session 4 + Nachzug): `components.css` enthält **kein** Farb-Literal mehr.
|
||||
Die früher feste weiße Button-Schrift auf Türkis/Rot läuft jetzt über das Token
|
||||
`--cp-on-brand` (BRAND-Ebene, bewusst themeunabhängig, kein `light-dark()`, da die
|
||||
Flächen `--cp-teal`/`--cp-red` in beiden Themes derselbe Vollton sind). Pro Team
|
||||
überschreibbar. Regel daher ausnahmslos: nie Farb-Literale im Komponenten-CSS.
|
||||
- Status-Mapping: Türkis = offen/bestätigt/verfügbar · Rot = dringend/Vertretung ·
|
||||
Neutral = Info/intern/erledigt.
|
||||
- Schrift: DM Sans.
|
||||
|
||||
---
|
||||
|
||||
## UI-Regeln
|
||||
|
||||
- Mobile-first bei 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.
|
||||
272
CLAUDE.md
Normal file
272
CLAUDE.md
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
# CLAUDE.md — CheckPoint Ehrenamt App
|
||||
|
||||
> Projektanker. Claude Code liest diese Datei automatisch zu Beginn jeder Session.
|
||||
> Sie ist die verbindliche Quelle für Stack, Regeln und Logik. Bei Widerspruch
|
||||
> zwischen Wunsch und dieser Datei: nachfragen, nicht raten.
|
||||
|
||||
---
|
||||
|
||||
## Zweck
|
||||
|
||||
Interne, mobile-first Web-App für das Vor-Ort-Team Prävention der AIDS-Hilfe.
|
||||
Sie organisiert Einsätze (Terminabstimmung + Dienstverteilung) und ersetzt
|
||||
schrittweise die WhatsApp/Signal-Zettelwirtschaft. **Das Herzstück ist die
|
||||
Dienstplanung — alles andere ordnet sich dem unter.**
|
||||
|
||||
---
|
||||
|
||||
## Stack & Rahmenbedingungen (fest)
|
||||
|
||||
| Bereich | Entscheidung |
|
||||
|---|---|
|
||||
| Sprache/Backend | **Python + Flask** |
|
||||
| Templating | **Jinja2**, server-gerendertes HTML (klassische Multi-Page-App) |
|
||||
| ORM/DB | **SQLAlchemy**, **SQLite** für v1 (ein VPS, 17 Nutzer). Keine Annahmen treffen, die einen späteren Umstieg auf PostgreSQL verbauen. |
|
||||
| Passwörter | `werkzeug.security` (Hashing), nie Klartext |
|
||||
| CSS | **selbst gebaut, kein CSS-Framework**. Basis: `tokens.css` + `components.css` (User-Shell) + `components-admin.css` (Admin-Shell) |
|
||||
| JavaScript | **so wenig wie möglich, nur Vanilla, kein Framework**. Einzige Ausnahme v1: Chat-Polling (s. u.) |
|
||||
| Hosting | **eigener VPS in der EU** |
|
||||
| Plattform | mobile-first Web-App, **kein App Store**, „zum Homescreen" möglich |
|
||||
| UI-Sprache | **Deutsch** |
|
||||
|
||||
**Erlaubte Zusatz-Bibliotheken (klein halten):** Flask, SQLAlchemy/Flask-SQLAlchemy,
|
||||
Werkzeug, optional Flask-Login. Keine schweren Frameworks, kein CSS-/JS-Framework.
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Prinzipien
|
||||
|
||||
1. **Server-rendered first.** Seiten kommen fertig vom Server. Formulare sind echte
|
||||
`POST`-Formulare. Kein clientseitiges Routing, kein SPA-Verhalten.
|
||||
2. **JavaScript nur, wo es sich beweist.** Zwei JS-Ausnahmen in v1: der Chat holt
|
||||
neue Nachrichten per kleinem `fetch`-Polling, und die Admin-Sidebar wird auf
|
||||
schmalen Bildschirmen per Hamburger-Icon als Off-Canvas-Overlay ein-/ausgeklappt
|
||||
(Klasse toggeln, kein Framework). Sonst nirgends JS verlangen.
|
||||
3. **Mandantenfähig ab Tag 1.** Siehe eigener Abschnitt. Jeder Datensatz gehört zu
|
||||
genau einem Team.
|
||||
4. **Mobile-first.** Getestet in App-Breite 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 `<head>` injiziert). Code darf keine Markenfarbe
|
||||
fest verdrahten — immer Tokens verwenden.
|
||||
|
||||
---
|
||||
|
||||
## Zwei Oberflächen, zwei Shells (Pflichtregel)
|
||||
|
||||
Es gibt **zwei eigenständige Layout-Shells**, eine pro Rolle. Sie sind keine
|
||||
Variante derselben Vorlage, sondern zwei getrennte Templates mit jeweils eigenem
|
||||
Tokensatz:
|
||||
|
||||
| | **User-Shell** | **Admin-Shell** |
|
||||
|---|---|---|
|
||||
| Für wen | Ehrenamtliche | Admin |
|
||||
| Vorlage | `base_user.html` | `base_admin.html` |
|
||||
| Navigation | Bottom-Nav, 4 Punkte (Start, Termine, Team, Profil) | Sidebar links, fest: Start, Planung, Dienste, Freigaben, Team, Chat-Verwaltung, Profil, Abmelden |
|
||||
| Grundform | Mobile-first (App-Breite 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 `<html>` (bei „System" kein Attribut).
|
||||
- **Nie feste Farben** im Komponenten-CSS — immer Tokens. **Nie Theme-Tokens
|
||||
mischen** — eine Komponente innerhalb der User-Shell nutzt nur `--cp-user-*`,
|
||||
innerhalb der Admin-Shell nur `--cp-admin-*`.
|
||||
- Erledigt: beide Komponenten-CSS sind frei von Farb-Literalen. Weiße Schrift auf
|
||||
Voll-Marken-Flächen läuft über `--cp-user-on-brand` bzw. `--cp-admin-on-brand`
|
||||
(themeunabhängig, pro Team überschreibbar).
|
||||
|
||||
---
|
||||
|
||||
## UI-Regeln
|
||||
|
||||
### User-Shell
|
||||
- App-Breite 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.
|
||||
BIN
CheckPoint-Ehrenamt.png
Normal file
BIN
CheckPoint-Ehrenamt.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
154
DEPLOY.md
Normal file
154
DEPLOY.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# DEPLOY.md — CheckPoint Ehrenamt auf einen EU-VPS
|
||||
|
||||
> Ziel: App unter **gunicorn** hinter **nginx**, **HTTPS** per Let's Encrypt,
|
||||
> Secrets aus der Umgebung, **SQLite + Uploads persistent** in `instance/`,
|
||||
> tägliches **DB-Backup**. Befehle für die **Fish-Shell**, auf dem VPS auszuführen.
|
||||
>
|
||||
> Platzhalter überall ersetzen: Domain `checkpoint.example.org`, ggf. Pfad
|
||||
> `/opt/checkpoint`. Annahme: frischer Debian/Ubuntu-VPS in der EU, Domain zeigt
|
||||
> per A/AAAA-Record auf die VPS-IP, du hast einen sudo-fähigen Login.
|
||||
|
||||
---
|
||||
|
||||
## 1 · Systempakete
|
||||
|
||||
```fish
|
||||
sudo apt update
|
||||
sudo apt install -y python3 python3-venv python3-pip nginx sqlite3 git certbot python3-certbot-nginx
|
||||
```
|
||||
|
||||
## 2 · Dienst-Nutzer + Verzeichnis
|
||||
|
||||
```fish
|
||||
sudo useradd --system --create-home --home-dir /opt/checkpoint --shell /usr/sbin/nologin checkpoint
|
||||
sudo mkdir -p /opt/checkpoint
|
||||
```
|
||||
|
||||
## 3 · Code holen
|
||||
|
||||
```fish
|
||||
sudo git clone <DEIN_REPO_URL> /opt/checkpoint
|
||||
# oder per scp/rsync hochladen. Danach Eigentümer setzen:
|
||||
sudo chown -R checkpoint:checkpoint /opt/checkpoint
|
||||
```
|
||||
|
||||
## 4 · Virtualenv + Abhängigkeiten
|
||||
|
||||
```fish
|
||||
cd /opt/checkpoint
|
||||
sudo -u checkpoint python3 -m venv .venv
|
||||
sudo -u checkpoint .venv/bin/pip install --upgrade pip
|
||||
sudo -u checkpoint .venv/bin/pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 5 · Secrets (`.env`)
|
||||
|
||||
`.env` liegt unter `/opt/checkpoint/.env`, ist **nicht** im Git und wird von systemd
|
||||
geladen. Aus der Vorlage erzeugen und ein echtes Secret eintragen:
|
||||
|
||||
```fish
|
||||
cd /opt/checkpoint
|
||||
sudo -u checkpoint cp .env.example .env
|
||||
# Secret erzeugen und einsetzen:
|
||||
set SECRET (sudo -u checkpoint .venv/bin/python -c "import secrets; print(secrets.token_urlsafe(48))")
|
||||
sudo -u checkpoint sed -i "s|^SECRET_KEY=.*|SECRET_KEY=$SECRET|" .env
|
||||
sudo chmod 600 .env
|
||||
```
|
||||
|
||||
`.env` sollte enthalten: `CP_ENV=production` und `SECRET_KEY=<lang & zufällig>`.
|
||||
|
||||
## 6 · Datenbank anlegen, Team seeden, Admin erstellen
|
||||
|
||||
```fish
|
||||
cd /opt/checkpoint
|
||||
# DB-Tabellen + erstes Team (CheckPoint) + die zwei Chat-Kanäle (idempotent):
|
||||
sudo -u checkpoint --preserve-env=CP_ENV env CP_ENV=production .venv/bin/python seed.py
|
||||
# Ersten Admin anlegen (Nutzername Anzeigename Passwort):
|
||||
sudo -u checkpoint env CP_ENV=production SECRET_KEY=$SECRET \
|
||||
.venv/bin/flask --app wsgi create-admin admin "Team-Leitung" "EinStarkesPasswort"
|
||||
```
|
||||
|
||||
> Hinweis: DB (`instance/checkpoint.sqlite`) und Uploads (`instance/uploads/`)
|
||||
> liegen unter `/opt/checkpoint/instance/` und bleiben über Neustarts/Updates
|
||||
> erhalten. Nur dieses Verzeichnis ist schreibbar (systemd `ReadWritePaths`).
|
||||
|
||||
## 7 · gunicorn als systemd-Dienst
|
||||
|
||||
```fish
|
||||
sudo cp /opt/checkpoint/deploy/checkpoint.service /etc/systemd/system/checkpoint.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now checkpoint
|
||||
sudo systemctl status checkpoint --no-pager
|
||||
```
|
||||
|
||||
Test (lokal auf dem VPS): `curl -I http://127.0.0.1:8000` sollte eine Antwort
|
||||
liefern (Redirect zur Anmeldung ist ok).
|
||||
|
||||
## 8 · nginx als Reverse Proxy
|
||||
|
||||
```fish
|
||||
sudo cp /opt/checkpoint/deploy/nginx-checkpoint.conf /etc/nginx/sites-available/checkpoint
|
||||
sudo sed -i "s/checkpoint.example.org/DEINE-DOMAIN/" /etc/nginx/sites-available/checkpoint
|
||||
sudo ln -sf /etc/nginx/sites-available/checkpoint /etc/nginx/sites-enabled/checkpoint
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
`client_max_body_size 12m` ist gesetzt (passt zum 10-MB-Upload-Limit der App).
|
||||
Statische Dateien (CSS/JS/Schriften) liefert nginx direkt; Uploads/Dokumente
|
||||
laufen bewusst **nicht** über nginx, sondern zugriffsgeschützt über die App.
|
||||
|
||||
## 9 · HTTPS (Let's Encrypt)
|
||||
|
||||
```fish
|
||||
sudo certbot --nginx -d DEINE-DOMAIN
|
||||
```
|
||||
|
||||
certbot ergänzt den 443-Block, richtet die Weiterleitung 80→443 ein und erneuert
|
||||
automatisch (Timer `certbot.timer`). Erst **nach** HTTPS greifen die `Secure`-Cookies
|
||||
sinnvoll — `CP_ENV=production` setzt sie bereits voraus.
|
||||
|
||||
## 10 · Tägliches DB-Backup
|
||||
|
||||
```fish
|
||||
sudo chmod +x /opt/checkpoint/deploy/backup-db.sh
|
||||
sudo crontab -u checkpoint -l 2>/dev/null | cat - <(echo "30 3 * * * /opt/checkpoint/deploy/backup-db.sh") | sudo crontab -u checkpoint -
|
||||
```
|
||||
|
||||
Sichert täglich 03:30 nach `/opt/checkpoint/backups/` (Online-`.backup`, also auch
|
||||
im laufenden Betrieb konsistent) und hält 14 Tage vor. Backups liegen außerhalb
|
||||
von Git. Tipp: zusätzlich regelmäßig vom VPS wegkopieren (anderer Ort).
|
||||
|
||||
## 11 · Updates einspielen
|
||||
|
||||
```fish
|
||||
cd /opt/checkpoint
|
||||
sudo -u checkpoint git pull
|
||||
sudo -u checkpoint .venv/bin/pip install -r requirements.txt
|
||||
sudo systemctl restart checkpoint
|
||||
```
|
||||
|
||||
Neue Tabellen legt die App beim Start via `create_all()` an. **Achtung:** echte
|
||||
Schema-*Änderungen* an bestehenden Tabellen macht SQLAlchemy so nicht — dafür
|
||||
vorher Backup ziehen und ggf. manuell migrieren.
|
||||
|
||||
---
|
||||
|
||||
## Checkliste (Deliverables Session 15)
|
||||
|
||||
- [ ] App läuft unter gunicorn hinter nginx → Schritte 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.
|
||||
```
|
||||
270
Konzept-CheckPoint-Ehrenamt.md
Normal file
270
Konzept-CheckPoint-Ehrenamt.md
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
# Konzeptdokument — CheckPoint Ehrenamt App
|
||||
|
||||
> Interne Orga-App für das Vor-Ort-Team Prävention der AIDS-Hilfe.
|
||||
> Ersetzt schrittweise die WhatsApp/Signal-Zettelwirtschaft.
|
||||
> Stand: Konzeptphase (vor dem ersten Code).
|
||||
|
||||
---
|
||||
|
||||
## 1 · Ziel & Kernidee
|
||||
|
||||
Eine schlanke, mobile-first Web-App, mit der das Ehrenamtsteam seine Einsätze
|
||||
organisiert, ohne auf WhatsApp/Signal angewiesen zu sein.
|
||||
|
||||
**Das Herzstück ist die Terminabstimmung und Dienstverteilung** — wer macht wann
|
||||
was. Alles andere (Chat, Dokumente) ordnet sich diesem Zweck unter.
|
||||
|
||||
**Leitprinzip:** Lesbarkeit und Orientierung vor Funktionsfülle. Die App muss für
|
||||
17 Ehrenamtliche im Alltag spürbar einfacher sein als eine WhatsApp-Umfrage.
|
||||
|
||||
**Übergang:** Ziel ist, WhatsApp/Signal als Orga-Werkzeug komplett zu ersetzen.
|
||||
In der Anfangszeit darf parallel weiter dort kommuniziert werden — die App muss
|
||||
aber eigenständig funktionieren und darf wichtige Infos nicht „verlieren", nur
|
||||
weil jemand noch nicht umgestiegen ist.
|
||||
|
||||
---
|
||||
|
||||
## 2 · Nutzer & Rollen
|
||||
|
||||
| Rolle | Wer | Rechte |
|
||||
|---|---|---|
|
||||
| **Admin** | Koordination (du) | Legt Planungszeiträume und Termine an, baut den Dienstplan, vergibt Logins, gibt Dokumente/Fotos frei, postet Ankündigungen, verwaltet das Team. |
|
||||
| **Ehrenamtliche:r** | die 17 Teammitglieder | Meldet Verfügbarkeit, sieht den eigenen Dienstplan, übernimmt freie Dienste, lädt Dokumente/Fotos hoch (Freigabe nötig), chattet im Team-Kanal. |
|
||||
|
||||
Für v1 reichen genau diese zwei Rollen. Weitere Abstufungen (z. B. „Co-Admin")
|
||||
sind möglich, aber nicht nötig — siehe Future Log.
|
||||
|
||||
---
|
||||
|
||||
## 3 · Mandantenfähigkeit (von Tag 1)
|
||||
|
||||
Die App wird **von Anfang an mandantenfähig** gebaut (Entscheidung A1): mehrere
|
||||
Teams parallel, je eigenes Corporate Design und eigene, sauber getrennte Inhalte.
|
||||
|
||||
**Konsequenzen fürs Datenmodell:**
|
||||
- Es gibt eine zentrale Einheit **`team`** (= Mandant).
|
||||
- **Jeder** Datensatz (Nutzer, Termin, Dienst, Dokument, Nachricht, …) hängt an
|
||||
genau einem `team`.
|
||||
- Kein Datensatz ist über Teamgrenzen hinweg sichtbar.
|
||||
- Das Corporate Design (Farben, Logo, Schrift) wird **pro Team** über
|
||||
CSS-Variablen-Tokens gesetzt. Das bestehende CheckPoint-CD ist der erste Mandant.
|
||||
|
||||
Das CheckPoint-Team ist also der erste von perspektivisch mehreren Teams. Diese
|
||||
Trennung wird im ersten Datenmodell verankert, damit später kein Umbau nötig ist.
|
||||
|
||||
---
|
||||
|
||||
## 4 · Technischer Rahmen (feste Vorgaben)
|
||||
|
||||
| Bereich | Entscheidung |
|
||||
|---|---|
|
||||
| Backend | **Flask** (Python) |
|
||||
| Templating | **Jinja2**, server-gerendertes HTML |
|
||||
| Architektur | klassische Multi-Page-App (server-rendered) |
|
||||
| CSS | **selbst gebaut**, kein CSS-Framework (Basis: vorhandenes Token-System) |
|
||||
| JavaScript | **so wenig wie möglich, nur Vanilla**, kein JS-Framework |
|
||||
| Hosting | **eigener VPS in der EU** |
|
||||
| Plattform | mobile-first Web-App, **kein App Store**, „zum Homescreen hinzufügen" möglich |
|
||||
|
||||
**Chat-Sonderfall (Variante 2):** Alles ist server-gerendert und JS-frei —
|
||||
**außer** der Chat holt sich neue Nachrichten per kleinem `fetch`-Polling im
|
||||
Hintergrund (wenige Zeilen Vanilla-JS). So fühlt sich der Chat lebendig an, ohne
|
||||
WebSockets oder Framework. Polls, „Übernehmen", Formulare und die Terminübersicht
|
||||
laufen komplett ohne JavaScript als normale Formular-Abschicke.
|
||||
|
||||
---
|
||||
|
||||
## 5 · Auth & Onboarding
|
||||
|
||||
- **Logins werden ausschließlich vom Admin vergeben.** Keine Selbstregistrierung.
|
||||
- **v1:** Nutzername + Passwort, vom Admin angelegt.
|
||||
- Passwörter werden gehasht gespeichert (nie im Klartext).
|
||||
- Neue Ehrenamtliche bekommen ihre Zugangsdaten vom Admin und können sich danach
|
||||
einloggen.
|
||||
- **Future Log:** E-Mail + Einmal-Code (Magic Link) als komfortablere Alternative —
|
||||
Entscheidung dazu trifft das Team später gemeinsam.
|
||||
|
||||
---
|
||||
|
||||
## 6 · Datenmodell — Entscheidungen
|
||||
|
||||
Konzeptionelle Sicht (keine Implementierungsdetails). Jede Einheit gehört zu
|
||||
einem `team`.
|
||||
|
||||
- **team** — der Mandant. Trägt das Corporate Design (Tokens, Logo) und bündelt alle Inhalte.
|
||||
- **user** — Ehrenamtliche:r oder Admin. Feld `rolle`, Login-Daten, Anzeigename, optionales **Profilbild** und optionale freiwillige Angaben.
|
||||
- **planungszeitraum** — ein Block von mindestens 2 Monaten. Status: *in Planung* (Verfügbarkeit wird gesammelt) → *veröffentlicht* (Plan steht).
|
||||
- **einsatz** (Termin/Dienst) — gehört zu einem Planungszeitraum. Hat Datum, Uhrzeit, Ort/Art (z. B. Tour Altstadt, Party, Sonderveranstaltung). Braucht **2 Hauptplätze + 1 Springerplatz**.
|
||||
- **verfuegbarkeit** — Rückmeldung einer Person zu einem Einsatz: *kann / kann nicht*.
|
||||
- **zuteilung** — verbindet `user` mit `einsatz` und einem Platztyp (*Haupt* oder *Springer*) und Status (*zugeteilt / abgesagt / übernommen*).
|
||||
- **dokument** — Datei (Dokument oder Bild) mit Titel, Uploader, Status (*wartet auf Freigabe / freigegeben / abgelehnt*) und Ablage (*aktuell / Archiv*).
|
||||
- **kanal** — Chat-Kanal (für v1 zwei feste pro Team: *Ankündigungen*, *Team*).
|
||||
- **nachricht** — Textnachricht in einem Kanal, mit Autor und Zeitstempel.
|
||||
|
||||
---
|
||||
|
||||
## 7 · Kernflows
|
||||
|
||||
### 7.1 Planungszyklus (Admin)
|
||||
|
||||
1. Admin legt einen **Planungszeitraum** an (mind. 2 Monate).
|
||||
2. Admin trägt die relevanten **Einsätze** ein: regelmäßige **Freitags-Touren**
|
||||
(Alt-/Neustadt), **Samstags-Einsätze** (oft Partys) und kuratierte
|
||||
**Sonderveranstaltungen**. Die Termine sind nicht fix vorgegeben — der Admin
|
||||
wählt sie pro Zeitraum aus.
|
||||
3. Der Zeitraum geht in den Status *in Planung* → Verfügbarkeitsabfrage startet.
|
||||
|
||||
### 7.2 Verfügbarkeit melden (Ehrenamtliche)
|
||||
|
||||
- Jede:r sieht die Liste der Einsätze des aktuellen Zeitraums und meldet pro Datum
|
||||
**kann / kann nicht**.
|
||||
- Änderung der Verfügbarkeit ist möglich, **solange der Zeitraum *in Planung* ist**.
|
||||
Nach Veröffentlichung läuft alles über Absage/Übernahme (siehe 7.4).
|
||||
|
||||
### 7.3 Dienstplan bauen (Admin)
|
||||
|
||||
- Pro Einsatz: **2 Hauptpersonen + 1 Springer**.
|
||||
- Der Admin verteilt **manuell** und sieht dabei pro Person Hilfsinfos: wie oft
|
||||
schon eingeteilt, wann zuletzt im Einsatz — damit niemand zu oft hintereinander
|
||||
dran ist und die Last fair bleibt.
|
||||
- Wenn sich für einen Einsatz **zu wenige** verfügbar gemeldet haben, zeigt die App
|
||||
ihn als **unterbesetzt** an, damit der Admin nachsteuern kann.
|
||||
- Ist der Plan fertig, **veröffentlicht** der Admin den Zeitraum. Ab jetzt sehen
|
||||
alle ihren persönlichen Dienstplan.
|
||||
|
||||
> **Bewusste Entscheidung für v1:** Die Zuteilung ist manuell (Admin entscheidet),
|
||||
> die App unterstützt nur mit Anzeige. Automatische Fairness-Verteilung ist Future Log.
|
||||
|
||||
### 7.4 Absage & Übernahme
|
||||
|
||||
- Sagt eine **Hauptperson** ab, **rückt der Springer automatisch nach**. Der Einsatz
|
||||
bleibt voll besetzt.
|
||||
- Der dadurch frei werdende **Springerplatz** wird **offen zum „Übernehmen"** — wer
|
||||
zuerst klickt, bekommt ihn.
|
||||
- Sagt auch der Springer ab (oder es gibt keinen), wird der offene Platz für alle
|
||||
zur Übernahme angeboten.
|
||||
- Der Self-Service-„Übernehmen"-Button dient **ausschließlich dem Nachrücken** bei
|
||||
Ausfall — nicht der Erstverteilung.
|
||||
|
||||
### 7.7 Profil
|
||||
|
||||
- Bewusst **minimal** in v1.
|
||||
- Jede:r kann ein **Profilbild** hochladen und **freiwillig** ein paar Angaben zu
|
||||
sich machen. Welche Felder das genau sind, wird noch festgelegt (siehe offene
|
||||
Notiz unten) — nichts davon ist Pflicht.
|
||||
- Login-/Passwort-Verwaltung kann hier später andocken.
|
||||
|
||||
### 7.5 Chat
|
||||
|
||||
- **Zwei feste Kanäle pro Team:**
|
||||
- **Ankündigungen / Orga** — nur der **Admin** postet, alle lesen (z. B. Hinweis
|
||||
auf neues Protokoll, wichtige Orga-Infos).
|
||||
- **Team** — alle posten frei und reden untereinander.
|
||||
- **Nur Text** (keine Bilder/Dateien im Chat — die laufen über die Dokumentenablage).
|
||||
- Neue Nachrichten erscheinen per Hintergrund-Polling (Variante 2).
|
||||
- **Future Log:** 1:1-Direktnachrichten.
|
||||
|
||||
### 7.6 Dokumente & Fotos
|
||||
|
||||
- **Inhalte:** Protokolle (PDF/Word) und **Bilder** (z. B. Tour-Fotos). Alles andere
|
||||
bleibt bei WhatsApp.
|
||||
- **Upload durch alle**, aber mit **Freigabe-Pflicht**:
|
||||
1. User lädt hoch (mit kurzem Pflicht-Titel „was ist das") → Status *wartet auf Freigabe*, sichtbar nur für Admin und den Uploader.
|
||||
2. Admin sieht eine **„Freigaben"-Ansicht** → **Freigeben** oder **Ablehnen** (optional kurzer Grund).
|
||||
3. Erst nach Freigabe erscheint das Dokument in der allgemeinen Liste.
|
||||
- **Admin-Uploads** gehen direkt durch (keine Selbst-Freigabe).
|
||||
- **Ablage:** eine einfache Liste, geteilt in **Aktuell** und **Archiv**. Verschieben
|
||||
ins Archiv macht der Admin manuell.
|
||||
- **Inhaltsregel (Foto-Schutz):** Hochgeladene Fotos nur **ohne erkennbare Dritte**
|
||||
bzw. nur mit deren Einverständnis. Der Freigabe-Schritt sichert das zusätzlich ab.
|
||||
|
||||
---
|
||||
|
||||
## 8 · Benachrichtigungen
|
||||
|
||||
- **v1: nur in-App** — z. B. „du hast einen neuen Dienst", „neuer offener Dienst",
|
||||
„neue Nachricht". Dezent, nicht nervig.
|
||||
- **Push und/oder E-Mail: Future Log.** Push ist ohne App Store eingeschränkt
|
||||
(v. a. iPhone) und würde v1 überladen.
|
||||
|
||||
---
|
||||
|
||||
## 9 · Design & Corporate Design
|
||||
|
||||
- Mobile-first, App-Breite ca. 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.
|
||||
129
Stakeholder-CheckPoint-Ehrenamt.md
Normal file
129
Stakeholder-CheckPoint-Ehrenamt.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# CheckPoint Ehrenamt — eine App für unser Vor-Ort-Team
|
||||
|
||||
*Zum Vorstellen im Team und bei der AIDS-Hilfe. Ohne Technik-Kauderwelsch.*
|
||||
|
||||
---
|
||||
|
||||
## Worum es geht
|
||||
|
||||
Unser Präventionsteam organisiert sich bisher über WhatsApp: Terminumfragen,
|
||||
Absprachen in der Teamsitzung, Protokolle und allgemeine Kommunikation laufen alle
|
||||
dort. Das hat lange funktioniert — wird aber zunehmend unübersichtlich. Und seit
|
||||
ein Teil des Teams von WhatsApp zu Signal gewechselt ist, sind wir auf zwei Kanälen
|
||||
unterwegs. Umfragen, Infos und Dokumente erreichen nicht mehr alle am selben Ort.
|
||||
|
||||
**Die Idee:** eine einzige, einfache App nur für unser Team. Sie bündelt das, was
|
||||
heute über zwei Messenger verstreut ist — vor allem die Dienstplanung — an einem
|
||||
verlässlichen Ort.
|
||||
|
||||
---
|
||||
|
||||
## Was die App können wird
|
||||
|
||||
### 1 · Termine abstimmen und Dienste verteilen (das Herzstück)
|
||||
|
||||
So wie heute, nur ohne Zettelwirtschaft:
|
||||
|
||||
- Die Koordination legt einen Planungszeitraum an (mindestens zwei Monate) und trägt
|
||||
die Einsätze ein — die regelmäßigen Freitags-Touren, die Samstags-Einsätze und
|
||||
besondere Veranstaltungen.
|
||||
- Jede:r meldet pro Termin einfach **„kann ich" oder „kann ich nicht"**.
|
||||
- Die Koordination sieht auf einen Blick, wer verfügbar ist, und verteilt die Dienste
|
||||
— immer **zwei Personen plus eine Springer-Person** pro Einsatz. Die App hilft
|
||||
dabei, niemanden zu oft hintereinander einzuteilen und die Einsätze fair zu verteilen.
|
||||
|
||||
**Warum so:** Das ist der Punkt, der heute am meisten Aufwand macht. Eine WhatsApp-
|
||||
Umfrage zu zwei Monaten ist mühsam auszuwerten. Die App nimmt diese Arbeit ab und
|
||||
macht für alle transparent, wer wann eingeteilt ist.
|
||||
|
||||
### 2 · Einspringen, wenn jemand ausfällt
|
||||
|
||||
Wird jemand krank oder muss absagen, **rückt automatisch die Springer-Person nach**.
|
||||
Der dann offene Platz erscheint für alle sichtbar zum **Übernehmen** — wer mag, klickt
|
||||
und übernimmt.
|
||||
|
||||
**Warum so:** Kurzfristige Ausfälle sind der Stressmoment. Statt hektischer
|
||||
Nachrichten in der Gruppe ist sofort klar, dass ein Platz frei ist und wer ihn füllt.
|
||||
|
||||
### 3 · Eine Terminübersicht
|
||||
|
||||
Jede:r sieht die eigenen kommenden Einsätze als übersichtliche Liste — mit Datum,
|
||||
Uhrzeit und ob als feste Person oder als Springer.
|
||||
|
||||
**Warum so:** Kein Suchen mehr im Chatverlauf. Die eigenen Dienste stehen klar an
|
||||
einem Ort. (Bewusst eine Liste, kein voller Kalender — das reicht für unseren Zweck
|
||||
und bleibt einfach.)
|
||||
|
||||
### 4 · Ein Team-Chat mit zwei Bereichen
|
||||
|
||||
- **Ankündigungen:** Hier postet nur die Koordination wichtige Infos. Alle lesen mit.
|
||||
- **Team:** Hier können alle frei miteinander schreiben.
|
||||
|
||||
**Warum so:** Wichtige Orga-Infos gehen heute im Plauder-Verlauf unter. Die Trennung
|
||||
sorgt dafür, dass Ankündigungen nicht verloren gehen — der lockere Austausch bleibt
|
||||
trotzdem möglich.
|
||||
|
||||
### 5 · Dokumente und Fotos an einem Ort
|
||||
|
||||
Protokolle und andere Dokumente liegen geordnet in der App — geteilt in **Aktuell**
|
||||
und **Archiv**. Auch Fotos von Touren können hochgeladen werden. Damit nichts
|
||||
Unpassendes landet, **gibt die Koordination hochgeladene Inhalte vorher frei**.
|
||||
|
||||
**Warum so:** Protokolle im Chat sind nach zwei Wochen nicht mehr auffindbar. Hier
|
||||
bleiben sie geordnet. Die Freigabe hält die Ablage sauber, ohne dass die Koordination
|
||||
alles selbst hochladen muss.
|
||||
|
||||
### 6 · Ein einfaches Profil
|
||||
|
||||
Jede:r kann ein Profilbild hinterlegen und freiwillig ein paar Angaben machen — nichts
|
||||
davon ist Pflicht.
|
||||
|
||||
---
|
||||
|
||||
## Wie man die App benutzt
|
||||
|
||||
Die App läuft **im Browser auf dem Handy** — kein Download aus einem App-Store nötig.
|
||||
Man kann sie sich wie eine normale App auf den Startbildschirm legen. Sie funktioniert
|
||||
auch auf hellen und dunklen Geräte-Einstellungen.
|
||||
|
||||
**Anmeldung:** Es gibt keine offene Registrierung. Die Koordination legt für jede
|
||||
Person einen Zugang an. So ist sichergestellt, dass nur das Team Zugriff hat.
|
||||
|
||||
---
|
||||
|
||||
## Datenschutz
|
||||
|
||||
Das ist uns bei einer Anwendung im Umfeld der AIDS-Hilfe besonders wichtig:
|
||||
|
||||
- Die App speichert **nur Team-interne Orga-Daten** (wer ist wann im Einsatz, Termine,
|
||||
Dokumente, Nachrichten). **Keine Daten von Klient:innen.**
|
||||
- Sie läuft auf einem **eigenen Server in der EU** — nicht bei einem großen
|
||||
US-Anbieter.
|
||||
- Zugänge gibt es nur über die Koordination, Passwörter werden sicher gespeichert.
|
||||
- Für hochgeladene Fotos gilt: nur ohne erkennbare unbeteiligte Personen
|
||||
beziehungsweise nur mit deren Einverständnis. Die Freigabe sichert das zusätzlich ab.
|
||||
|
||||
---
|
||||
|
||||
## Was bewusst (noch) nicht kommt
|
||||
|
||||
Damit die erste Version übersichtlich und schnell nutzbar bleibt, lassen wir zunächst
|
||||
weg: Push-Benachrichtigungen aufs Handy, E-Mail-Benachrichtigungen, private
|
||||
Einzelchats und einen vollwertigen Kalender. Diese Dinge können später ergänzt
|
||||
werden, wenn sich die App im Alltag bewährt hat.
|
||||
|
||||
---
|
||||
|
||||
## Und für andere Teams?
|
||||
|
||||
Die App wird von Anfang an so gebaut, dass auch **andere ehrenamtliche Teams** sie
|
||||
nutzen könnten — jeweils mit eigenem Logo, eigenen Farben und streng getrennten
|
||||
Daten. Für uns ändert das nichts; es hält nur die Tür für die Zukunft offen.
|
||||
|
||||
---
|
||||
|
||||
## Kurz gesagt
|
||||
|
||||
Aus zwei Messengern und vielen Umfragen wird **ein Ort**: für Termine, Dienste,
|
||||
Dokumente und Absprachen. Weniger Suchen, weniger Aufwand bei der Planung, und für
|
||||
alle klar, wer wann wo im Einsatz ist.
|
||||
385
TASKS-backup.md
Normal file
385
TASKS-backup.md
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
# TASKS.md — Build-Checkliste CheckPoint Ehrenamt App
|
||||
|
||||
> Eine Aufgabe pro Session. Nach Abhängigkeit geordnet — von oben nach unten abarbeiten.
|
||||
> Pro Session: **Tool**, **Ziel**, **Session-Prompt** (zum Einfügen), **Deliverables**.
|
||||
> Nach jeder Session: alle Häkchen setzen, committen, erst dann die nächste starten.
|
||||
>
|
||||
> Tool-Legende: 🛠 Claude Code · 💻 Continue + Qwen · 🏛 Continue + Mistral
|
||||
> Modell wechseln: `llama-switch` → [1] Architect / [2] Coder
|
||||
|
||||
---
|
||||
|
||||
## Session 1 · Scaffold & Projektgerüst 🛠
|
||||
|
||||
**Ziel:** Lauffähiges Flask-Grundgerüst mit App-Factory, SQLite, Basis-Layout,
|
||||
Static-Einbindung und Team-Scoping-Stub.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
Lies CLAUDE.md. Erstelle das Projektgerüst gemäß der vorgeschlagenen Struktur:
|
||||
Flask-App-Factory, SQLAlchemy mit SQLite (instance/), requirements.txt,
|
||||
ein base.html mit eingebundener tokens.css und components.css, sowie ein
|
||||
leeres Team-Scoping (Helper, der das aktive Team aus der Session liest).
|
||||
Zeig mir zuerst den Plan und die Dateiliste, bevor du schreibst.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] App startet lokal (`flask run`) und zeigt eine leere Startseite
|
||||
- [x] `tokens.css` + `components.css` in `base.html` eingebunden
|
||||
- [x] SQLite initialisiert, `instance/` in `.gitignore`
|
||||
- [x] Team-Scoping-Helper vorhanden (noch ohne echte Logik)
|
||||
- [x] `requirements.txt` minimal gehalten
|
||||
|
||||
---
|
||||
|
||||
## Session 2 · Datenmodell & Mandanten 💻
|
||||
|
||||
**Ziel:** Alle Modelle aus CLAUDE.md, jedes mit `team_id`. Seed für das erste Team
|
||||
(CheckPoint) inkl. Brand-Tokens und den zwei Chat-Kanälen.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/models.py
|
||||
@file CLAUDE.md
|
||||
|
||||
Erst den Ansatz erklären, dann implementieren — ein Modell nach dem anderen.
|
||||
Baue die Modelle: team, user, planungszeitraum, einsatz, verfuegbarkeit,
|
||||
zuteilung, dokument, kanal, nachricht. Jedes mit team_id. Danach ein Seed-Skript,
|
||||
das das CheckPoint-Team mit Brand-Tokens und den Kanälen "ankuendigungen" und
|
||||
"team" anlegt. Halte dich an die Feldvorgaben in CLAUDE.md.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Alle Modelle vorhanden, jedes mit `team_id`
|
||||
- [x] Beziehungen sauber (Einsatz↔Planungszeitraum, Zuteilung↔User/Einsatz)
|
||||
- [x] Seed legt CheckPoint-Team + 2 Kanäle an
|
||||
- [x] DB lässt sich anlegen, Seed läuft ohne Fehler
|
||||
|
||||
---
|
||||
|
||||
## Session 3 · Auth & Admin-Anlage 💻
|
||||
|
||||
**Ziel:** Login (Nutzername + Passwort, gehasht), Session, Logout, Team-Scoping aktiv,
|
||||
Admin kann Nutzer anlegen. Keine Selbstregistrierung.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/auth.py
|
||||
@file app/models.py
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. Baue: Login mit Nutzername+Passwort (werkzeug-Hashing), Session,
|
||||
Logout. Aktiviere das Team-Scoping (aktives Team aus der Session). Admin-geschützte
|
||||
Route zum Anlegen neuer Nutzer (Rolle wählbar). Geschützte Routen leiten ohne
|
||||
Login zur Anmeldung um. Ein Stück nach dem anderen.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Login/Logout funktioniert, Passwörter gehasht
|
||||
- [x] Geschützte Routen ohne Login → Redirect
|
||||
- [x] Admin kann Nutzer mit Rolle anlegen
|
||||
- [x] Alle Queries laufen team-gescoped
|
||||
- [ ] 🏛 Mini-Review (Mistral): Auth gegen CLAUDE.md prüfen — als Liste, nicht umschreiben
|
||||
|
||||
---
|
||||
|
||||
## Session 4 · Base-Layout, Theming & Dark-Mode-Fix 💻
|
||||
|
||||
**Ziel:** base.html final (Topbar, Bottom-Nav Start/Termine/Team/Profil, FAB-Slot),
|
||||
Brand-Tokens pro Team in den `<head>` injizieren, Mockup-CSS auf Tokens umstellen.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/templates/base.html
|
||||
@file app/static/components.css
|
||||
@file app/static/tokens.css
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. 1) base.html mit Topbar (Logo, Glocke, Avatar) und Bottom-Nav
|
||||
(Start, Termine, Team, Profil). 2) Brand-Tokens des aktiven Teams serverseitig
|
||||
in den <head> schreiben. 3) Im components.css alle fest verdrahteten Weißwerte
|
||||
(Topbar, Bottom-Nav, .cp-page-Verlauf) auf Tokens umstellen, damit der Dunkelmodus
|
||||
greift. Erst die Token-Umstellung erklären, dann umsetzen.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] base.html mit korrekter Bottom-Nav (4 Punkte, „Termine")
|
||||
- [x] Brand-Tokens kommen pro Team aus der DB in den `<head>`
|
||||
- [x] Keine festen Weiß-/Schwarzwerte mehr im components.css (weiße Button-Schrift jetzt über Token `--cp-on-brand`, themeunabhängig)
|
||||
- [x] Hell- und Dunkelmodus optisch sauber (System-Umschaltung testen)
|
||||
|
||||
---
|
||||
|
||||
## Session 5 · Planungszeitraum & Einsätze (Admin) 💻
|
||||
|
||||
**Ziel:** Admin legt Planungszeiträume an und trägt Einsätze ein (Datum, Zeit, Art).
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/routes/planung.py
|
||||
@file app/models.py
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. Admin-Ansichten: Planungszeitraum anlegen (Status in_planung) und
|
||||
darin Einsätze hinzufügen/bearbeiten/löschen (Datum, Start/Ende, Art/Ort, je 2 Haupt-
|
||||
+ 1 Springerplatz). Reine POST-Formulare, kein JS. Liste der Einsätze eines Zeitraums.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Admin kann Zeitraum anlegen
|
||||
- [x] Admin kann Einsätze anlegen/bearbeiten/löschen
|
||||
- [x] Einsatz kennt seine Platzstruktur (2 + Springer)
|
||||
- [x] Alles team-gescoped, ohne JavaScript
|
||||
|
||||
---
|
||||
|
||||
## Session 6 · Verfügbarkeit melden (Ehrenamtliche) 💻
|
||||
|
||||
**Ziel:** Ehrenamtliche melden pro Einsatz „kann / kann nicht", solange `in_planung`.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/routes/planung.py
|
||||
@file app/models.py
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. Ansicht für Ehrenamtliche: Liste der Einsätze im laufenden Zeitraum
|
||||
mit Umschaltung kann/kann_nicht pro Einsatz (POST-Formular). Änderung nur bei Status
|
||||
in_planung. Übersichtlich für mobile.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Ehrenamtliche sehen die Einsätze des aktuellen Zeitraums
|
||||
- [x] kann/kann_nicht wird gespeichert und ist änderbar (nur in_planung)
|
||||
- [x] Nach Veröffentlichung gesperrt
|
||||
- [x] Mobile sauber bedienbar (44px-Ziele)
|
||||
|
||||
---
|
||||
|
||||
## Session 7 · Dienstplan bauen (Admin) 💻
|
||||
|
||||
**Ziel:** Admin verteilt manuell 2 Haupt + 1 Springer pro Einsatz, mit Fairness-Anzeige.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/routes/dienste.py
|
||||
@file app/models.py
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. Admin-Ansicht pro Einsatz: verfügbare Personen auswählen und auf
|
||||
Haupt/Springer setzen. Pro Person anzeigen: Anzahl bisheriger Einsätze und letzter
|
||||
Einsatz (Fairness-Hilfe). Einsatz als „unterbesetzt" markieren, wenn zu wenige.
|
||||
Keine automatische Verteilung — nur Anzeige + manuelle Auswahl.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Admin kann Haupt- und Springerplätze besetzen
|
||||
- [x] Fairness-Anzeige (Anzahl + letzter Einsatz) pro Person sichtbar
|
||||
- [x] Unterbesetzte Einsätze klar markiert
|
||||
- [ ] 🏛 Review (Mistral): Zuteilungslogik gegen CLAUDE.md prüfen
|
||||
|
||||
---
|
||||
|
||||
## Session 8 · Veröffentlichen & persönliche Terminübersicht 💻
|
||||
|
||||
**Ziel:** Admin veröffentlicht den Zeitraum; jede:r sieht „Meine Termine" als Liste.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/routes/dienste.py
|
||||
@file app/templates/
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. 1) Admin-Aktion „Veröffentlichen" (Status veroeffentlicht, sperrt
|
||||
Verfügbarkeit). 2) Ansicht „Meine Termine": Liste der eigenen zugeteilten Einsätze,
|
||||
chronologisch, mit Rolle (Haupt/Springer) und Status. Keine Kalenderansicht.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Veröffentlichen funktioniert und sperrt die Verfügbarkeit
|
||||
- [x] „Meine Termine" zeigt eigene Einsätze als Liste
|
||||
- [x] Haupt/Springer und Status erkennbar (nicht nur über Farbe)
|
||||
|
||||
---
|
||||
|
||||
## Session 9 · Absage & Übernahme 💻
|
||||
|
||||
**Ziel:** Absage einer Hauptperson → Springer rückt automatisch nach → Springerplatz
|
||||
wird offen → „Übernehmen" für alle.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/routes/dienste.py
|
||||
@file app/models.py
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. Logik laut CLAUDE.md: Sagt eine Hauptperson ab, rückt der Springer
|
||||
automatisch nach (status uebernommen) und der Springerplatz wird offen. Offene
|
||||
Plätze erscheinen mit „Übernehmen"-Button; wer zuerst klickt, bekommt ihn (sauber
|
||||
gegen Doppel-Klicks absichern). Kein Springer → Platz direkt offen.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Absage einer Hauptperson → Springer rückt automatisch nach
|
||||
- [x] Frei gewordener Springerplatz wird offen zum Übernehmen
|
||||
- [x] „Übernehmen" sicher gegen gleichzeitige Klicks (atomares UPDATE)
|
||||
- [x] Fall „kein Springer" korrekt behandelt
|
||||
- [x] Review der Übernahme-Logik (Randfälle) — mit Claude Code statt Mistral
|
||||
|
||||
---
|
||||
|
||||
## Session 10 · Dokumente & Freigabe 💻
|
||||
|
||||
**Ziel:** Upload (alle), Freigabe-Warteschlange (Admin), Liste Aktuell/Archiv.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/routes/dokumente.py
|
||||
@file app/models.py
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. Upload (PDF/Word/Bild) mit Pflicht-Titel → Status wartet_auf_freigabe
|
||||
(nur Admin + Uploader sichtbar). Admin-Ansicht „Freigaben": freigeben/ablehnen
|
||||
(optional Grund). Admin-Uploads direkt freigegeben. Liste geteilt in Aktuell/Archiv,
|
||||
Verschieben durch Admin. Dateigrößen-Limit und Dateityp-Prüfung serverseitig.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Upload landet in wartet_auf_freigabe (nicht öffentlich sichtbar)
|
||||
- [x] Admin kann freigeben/ablehnen, Admin-Uploads direkt sichtbar
|
||||
- [x] Liste Aktuell + Archiv, Verschieben durch Admin
|
||||
- [x] Server prüft Dateityp und -größe
|
||||
|
||||
---
|
||||
|
||||
## Session 11 · Chat (2 Kanäle, Polling) 💻
|
||||
|
||||
**Ziel:** Ankündigungen (nur Admin postet) + Team (alle). Nur Text. Neue Nachrichten
|
||||
per `fetch`-Polling (einzige v1-JS-Ausnahme).
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/routes/chat.py
|
||||
@file app/models.py
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. Zwei Kanäle: ankuendigungen (nur Admin darf posten, alle lesen) und
|
||||
team (alle posten). Nur Text. Nachrichten chronologisch. Dazu ein kleiner
|
||||
JSON-Endpunkt „neue Nachrichten seit X" und ~30 Zeilen Vanilla-JS, das im
|
||||
Hintergrund pollt und neue Nachrichten anhängt. Kein Framework, kein WebSocket.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Beide Kanäle funktionieren, Rechte korrekt (Ankündigungen nur Admin)
|
||||
- [x] Nachrichten werden gespeichert und angezeigt (nur Text)
|
||||
- [x] Polling lädt neue Nachrichten ohne Seiten-Reload
|
||||
- [x] JS ist minimal und vanilla
|
||||
|
||||
---
|
||||
|
||||
## Session 12 · Profil 💻
|
||||
|
||||
**Ziel:** Profilbild-Upload + Platzhalter für optionale freiwillige Angaben.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/routes/profil.py
|
||||
@file app/models.py
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. Profil-Ansicht: Profilbild hochladen/ändern, Anzeigename, optionale
|
||||
freiwillige Felder (vorerst ein, zwei Freitextfelder als Platzhalter — Struktur so,
|
||||
dass Felder später leicht ergänzt werden). Nichts davon Pflicht.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [ ] Profilbild hochladbar
|
||||
- [ ] Optionale Felder vorhanden, nichts Pflicht
|
||||
- [ ] Struktur erlaubt späteres Ergänzen von Feldern
|
||||
|
||||
---
|
||||
|
||||
## Session 13 · In-App-Benachrichtigungen 💻
|
||||
|
||||
**Ziel:** Dezente in-App-Hinweise (neuer Dienst, offener Dienst, neue Nachricht).
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/models.py
|
||||
@file app/templates/base.html
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. Einfaches Benachrichtigungsmodell + Anzeige (Glocke in der Topbar mit
|
||||
Zähler, Liste beim Antippen). Auslöser: neue Zuteilung, neuer offener Dienst, neue
|
||||
Nachricht. Als gelesen markierbar. Kein Push, keine E-Mail.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [ ] Benachrichtigungen werden bei den drei Auslösern erzeugt
|
||||
- [ ] Glocke zeigt ungelesene an, Liste antippbar
|
||||
- [ ] Als gelesen markierbar
|
||||
|
||||
---
|
||||
|
||||
## Session 14 · Gesamt-Review 🏛
|
||||
|
||||
**Ziel:** Durchsicht des fertigen Stands gegen CLAUDE.md — Korrektheit, Konsistenz,
|
||||
Barrierearmut, Dunkelmodus.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file CLAUDE.md
|
||||
@file app/models.py
|
||||
@file app/routes/
|
||||
|
||||
Review den aktuellen Stand gegen CLAUDE.md. Korrektheitsprobleme zuerst, dann
|
||||
Konsistenz und Wartbarkeit, dann Barrierearmut und Dunkelmodus-Lücken. Nichts
|
||||
umschreiben — alles als nummerierte Liste mit Fundstelle.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [ ] Review-Liste erstellt
|
||||
- [ ] Gefundene Korrektheitsprobleme in 💻-Folgeschritten behoben
|
||||
- [ ] A11y- und Dark-Mode-Lücken geschlossen
|
||||
|
||||
---
|
||||
|
||||
## Session 15 · Deployment auf den VPS 🛠
|
||||
|
||||
**Ziel:** App läuft auf dem EU-VPS hinter einem produktiven Server.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
Lies CLAUDE.md. Hilf mir beim Deployment auf einen EU-VPS: WSGI-Server (z. B.
|
||||
gunicorn) hinter nginx, Umgebungsvariablen/Secrets, Persistenz für SQLite und
|
||||
Uploads, einfaches Backup der DB, HTTPS. Gib mir die Schritte als Fish-Befehle,
|
||||
wo Terminal nötig ist. Plan zuerst.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [ ] App läuft unter gunicorn hinter nginx
|
||||
- [ ] HTTPS aktiv
|
||||
- [ ] Secrets über Umgebungsvariablen, nicht im Code
|
||||
- [ ] DB + Uploads persistent, einfaches DB-Backup eingerichtet
|
||||
|
||||
---
|
||||
|
||||
## Future Log (nicht in v1)
|
||||
|
||||
1:1-Chat · Push-/E-Mail-Benachrichtigungen · E-Mail+Code-Login (Magic Link) ·
|
||||
automatische Fairness-Verteilung · weitere Rollen ·
|
||||
Laptop-Spiegelung / Desktop-Fallback über LAN ·
|
||||
komfortablere Zeit-Auswahl (Rad-/Scroll-Picker für Startzeit & Dauer statt Dropdown).
|
||||
|
||||
## Härtung (vor Produktiv-Deploy / Session 15)
|
||||
|
||||
- [x] **CSRF-Schutz für alle POST-Formulare.** Erledigt (nach Session 3): eigenes,
|
||||
Session-gebundenes Token in `app/csrf.py`, ohne Flask-WTF. `csrf_token()` als
|
||||
Jinja-Global, Prüfung aller unsicheren Methoden via `before_request`.
|
||||
**Pflicht für alle künftigen POST-Formulare:** verstecktes Feld
|
||||
`<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">` einbauen.
|
||||
- [ ] **Brand-Tokens im `<head>` werden mit `|safe` ausgegeben** (`base.html`,
|
||||
wegen der Anführungszeichen im Font). Quelle ist aktuell nur Admin/Seed, daher
|
||||
für v1 vertretbar. **Sobald eine UI zum Bearbeiten der Brand-Felder entsteht:**
|
||||
Werte serverseitig validieren/escapen (CSS-Injection / `</style>`-Ausbruch
|
||||
verhindern) – z. B. Hex-Farben per Regex prüfen, Font auf Whitelist begrenzen.
|
||||
431
TASKS.md
Normal file
431
TASKS.md
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
# TASKS.md — Build-Checkliste CheckPoint Ehrenamt App
|
||||
|
||||
> Eine Aufgabe pro Session. Nach Abhängigkeit geordnet — von oben nach unten abarbeiten.
|
||||
> Pro Session: **Tool**, **Ziel**, **Session-Prompt** (zum Einfügen), **Deliverables**.
|
||||
> Nach jeder Session: alle Häkchen setzen, committen, erst dann die nächste starten.
|
||||
>
|
||||
> Tool-Legende: 🛠 Claude Code · 💻 Continue + Qwen · 🏛 Continue + Mistral
|
||||
> Modell wechseln: `llama-switch` → [1] Architect / [2] Coder
|
||||
|
||||
---
|
||||
|
||||
## Session 1 · Scaffold & Projektgerüst 🛠
|
||||
|
||||
**Ziel:** Lauffähiges Flask-Grundgerüst mit App-Factory, SQLite, zwei leeren
|
||||
Shell-Templates, Static-Einbindung und Team-Scoping-Stub.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
Lies CLAUDE.md. Erstelle das Projektgerüst gemäß der vorgeschlagenen Struktur:
|
||||
Flask-App-Factory, SQLAlchemy mit SQLite (instance/), requirements.txt,
|
||||
zwei minimale Shell-Templates base_user.html (Klasse cp-shell-user) und
|
||||
base_admin.html (Klasse cp-shell-admin), jeweils mit eingebundener tokens.css
|
||||
und ihrem jeweiligen Komponenten-CSS, sowie ein leeres Team-Scoping (Helper,
|
||||
der das aktive Team aus der Session liest). Zeig mir zuerst den Plan und die
|
||||
Dateiliste, bevor du schreibst.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] App startet lokal (`flask run`) und zeigt eine leere Startseite
|
||||
- [x] `base_user.html` und `base_admin.html` existieren, je mit korrekter Shell-Klasse
|
||||
- [x] `tokens.css` in beiden eingebunden; `components.css` nur in `base_user.html`, `components-admin.css` nur in `base_admin.html`
|
||||
- [x] SQLite initialisiert, `instance/` in `.gitignore`
|
||||
- [x] Team-Scoping-Helper vorhanden (noch ohne echte Logik)
|
||||
- [x] `requirements.txt` minimal gehalten
|
||||
|
||||
---
|
||||
|
||||
## Session 2 · Datenmodell & Mandanten 💻
|
||||
|
||||
**Ziel:** Alle Modelle aus CLAUDE.md, jedes mit `team_id`. Seed für das erste Team
|
||||
(CheckPoint) inkl. Brand-Tokens und den zwei Chat-Kanälen.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/models.py
|
||||
@file CLAUDE.md
|
||||
|
||||
Erst den Ansatz erklären, dann implementieren — ein Modell nach dem anderen.
|
||||
Baue die Modelle: team, user, planungszeitraum, einsatz, verfuegbarkeit,
|
||||
zuteilung, dokument, kanal, nachricht. Jedes mit team_id. Danach ein Seed-Skript,
|
||||
das das CheckPoint-Team mit Brand-Tokens und den Kanälen "ankuendigungen" und
|
||||
"team" anlegt. Halte dich an die Feldvorgaben in CLAUDE.md.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Alle Modelle vorhanden, jedes mit `team_id`
|
||||
- [x] Beziehungen sauber (Einsatz↔Planungszeitraum, Zuteilung↔User/Einsatz)
|
||||
- [x] Seed legt CheckPoint-Team + 2 Kanäle an
|
||||
- [x] DB lässt sich anlegen, Seed läuft ohne Fehler
|
||||
|
||||
---
|
||||
|
||||
## Session 3 · Auth & Admin-Anlage 💻
|
||||
|
||||
**Ziel:** Login (Nutzername + Passwort, gehasht), Session, Logout, Team-Scoping aktiv,
|
||||
Admin kann Nutzer anlegen. Keine Selbstregistrierung.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/auth.py
|
||||
@file app/models.py
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. Baue: Login mit Nutzername+Passwort (werkzeug-Hashing), Session,
|
||||
Logout. Aktiviere das Team-Scoping (aktives Team aus der Session). Admin-geschützte
|
||||
Route zum Anlegen neuer Nutzer (Rolle wählbar). Geschützte Routen leiten ohne
|
||||
Login zur Anmeldung um. Ein Stück nach dem anderen.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Login/Logout funktioniert, Passwörter gehasht
|
||||
- [x] Geschützte Routen ohne Login → Redirect
|
||||
- [x] Admin kann Nutzer mit Rolle anlegen
|
||||
- [x] Alle Queries laufen team-gescoped
|
||||
- [x] 🏛 Mini-Review (Mistral): Auth gegen CLAUDE.md prüfen — als Liste, nicht umschreiben
|
||||
|
||||
---
|
||||
|
||||
## Session 4a · User-Shell: Base-Layout, Theming & Dark-Mode-Fix 💻
|
||||
|
||||
**Ziel:** `base_user.html` final (Topbar, Bottom-Nav Start/Termine/Team/Profil,
|
||||
FAB-Slot), User-Brand-Tokens pro Team in den `<head>` injizieren, Mockup-CSS auf
|
||||
`--cp-user-*`-Tokens umstellen.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/templates/base_user.html
|
||||
@file app/static/components.css
|
||||
@file app/static/tokens.css
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. 1) base_user.html mit Topbar (Logo, Glocke, Avatar), Body-Klasse
|
||||
cp-shell-user und Bottom-Nav (Start, Termine, Team, Profil). 2) User-Brand-Tokens
|
||||
(--cp-user-brand-*) des aktiven Teams serverseitig in den <head> schreiben.
|
||||
3) Im components.css alle fest verdrahteten Weißwerte (Topbar, Bottom-Nav,
|
||||
.cp-page-Verlauf) auf --cp-user-*-Tokens umstellen, damit der Dunkelmodus greift.
|
||||
Erst die Token-Umstellung erklären, dann umsetzen.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] `base_user.html` mit korrekter Bottom-Nav (4 Punkte, „Termine"), Klasse `cp-shell-user`
|
||||
- [x] User-Brand-Tokens kommen pro Team aus der DB in den `<head>`
|
||||
- [x] Keine festen Weiß-/Schwarzwerte mehr im `components.css`
|
||||
- [x] Hell- und Dunkelmodus optisch sauber (System-Umschaltung testen)
|
||||
|
||||
---
|
||||
|
||||
## Session 4b · Admin-Shell: Sidebar, Off-Canvas & Theming 💻
|
||||
|
||||
**Ziel:** `base_admin.html` mit Desktop-Sidebar (Start, Planung, Dienste, Freigaben,
|
||||
Team, Chat-Verwaltung, Profil, Abmelden), Admin-Brand-Tokens pro Team in den `<head>`,
|
||||
mobiles Off-Canvas-Menü per Hamburger-Icon und kleinem Vanilla-JS.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/templates/base_admin.html
|
||||
@file app/static/components-admin.css
|
||||
@file app/static/tokens.css
|
||||
@file app/static/sidebar.js
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. 1) base_admin.html mit Body-Klasse cp-shell-admin: Sidebar links
|
||||
(Logo oben, Navigationspunkte Start/Planung/Dienste/Freigaben/Team/Chat-Verwaltung/
|
||||
Profil, Abmelden unten abgetrennt) und Hauptbereich rechts. 2) Admin-Brand-Tokens
|
||||
(--cp-admin-brand-*) serverseitig in den <head> schreiben. 3) components-admin.css
|
||||
ausschließlich mit --cp-admin-*-Tokens aufbauen (Cards, Tabellen, Buttons gemäß
|
||||
CLAUDE.md-Regeln). 4) Unter dem Breakpoint: Sidebar kollabiert zu einer schmalen
|
||||
Topbar mit Hamburger-Icon; sidebar.js togglet eine Klasse, die die Sidebar als
|
||||
Off-Canvas-Overlay einblendet, schließt bei Klick auf Overlay-Fläche oder einen
|
||||
Menüpunkt. Vanilla, keine Bibliothek. Erst den Ansatz fürs Off-Canvas-Verhalten
|
||||
erklären, dann umsetzen.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] `base_admin.html` mit Sidebar (alle 7 Punkte + Abmelden), Klasse `cp-shell-admin`
|
||||
- [x] Admin-Brand-Tokens kommen pro Team aus der DB in den `<head>`
|
||||
- [x] `components-admin.css` nutzt ausschließlich `--cp-admin-*`-Tokens, keine Vermischung mit User-Tokens
|
||||
- [x] Sidebar kollabiert unter dem Breakpoint zu Hamburger + Off-Canvas-Overlay
|
||||
- [x] Hell- und Dunkelmodus der Arbeitsfläche sauber (Sidebar bleibt bewusst dunkel in beiden Modi)
|
||||
- [x] Touch-Ziele auch im Admin-Bereich ≥ 44px
|
||||
|
||||
---
|
||||
|
||||
## Session 5 · Planungszeitraum & Einsätze (Admin) 💻
|
||||
|
||||
**Ziel:** Admin legt Planungszeiträume an und trägt Einsätze ein (Datum, Zeit, Art).
|
||||
Läuft in der Admin-Shell.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/routes/planung.py
|
||||
@file app/templates/base_admin.html
|
||||
@file app/models.py
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. Admin-Ansichten (erweitern base_admin.html): Planungszeitraum
|
||||
anlegen (Status in_planung) und darin Einsätze hinzufügen/bearbeiten/löschen
|
||||
(Datum, Start/Ende, Art/Ort, je 2 Haupt- + 1 Springerplatz). Reine POST-Formulare,
|
||||
kein JS. Liste der Einsätze eines Zeitraums als Cards/Tabelle im Admin-Stil.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Admin kann Zeitraum anlegen
|
||||
- [x] Admin kann Einsätze anlegen/bearbeiten/löschen
|
||||
- [x] Einsatz kennt seine Platzstruktur (2 + Springer)
|
||||
- [x] Alles team-gescoped, ohne JavaScript, in der Admin-Shell
|
||||
|
||||
---
|
||||
|
||||
## Session 6 · Verfügbarkeit melden (Ehrenamtliche) 💻
|
||||
|
||||
**Ziel:** Ehrenamtliche melden pro Einsatz „kann / kann nicht", solange `in_planung`.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/routes/planung.py
|
||||
@file app/models.py
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. Ansicht für Ehrenamtliche: Liste der Einsätze im laufenden Zeitraum
|
||||
mit Umschaltung kann/kann_nicht pro Einsatz (POST-Formular). Änderung nur bei Status
|
||||
in_planung. Übersichtlich für mobile.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Ehrenamtliche sehen die Einsätze des aktuellen Zeitraums
|
||||
- [x] kann/kann_nicht wird gespeichert und ist änderbar (nur in_planung)
|
||||
- [x] Nach Veröffentlichung gesperrt
|
||||
- [x] Mobile sauber bedienbar (44px-Ziele)
|
||||
|
||||
---
|
||||
|
||||
## Session 7 · Dienstplan bauen (Admin) 💻
|
||||
|
||||
**Ziel:** Admin verteilt manuell 2 Haupt + 1 Springer pro Einsatz, mit Fairness-Anzeige.
|
||||
Läuft in der Admin-Shell (Tabellen-Stil).
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/routes/dienste.py
|
||||
@file app/templates/base_admin.html
|
||||
@file app/models.py
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. Admin-Ansicht pro Einsatz (in der Admin-Shell, Tabelle im Stil aus
|
||||
CLAUDE.md: Navy-Tabellenkopf, Statusspalten mit Text+Farbe): verfügbare Personen
|
||||
auswählen und auf Haupt/Springer setzen. Pro Person anzeigen: Anzahl bisheriger
|
||||
Einsätze und letzter Einsatz (Fairness-Hilfe). Einsatz als „unterbesetzt" markieren,
|
||||
wenn zu wenige. Keine automatische Verteilung — nur Anzeige + manuelle Auswahl.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Admin kann Haupt- und Springerplätze besetzen
|
||||
- [x] Fairness-Anzeige (Anzahl + letzter Einsatz) pro Person sichtbar
|
||||
- [x] Unterbesetzte Einsätze klar markiert
|
||||
- [x] Tabelle nutzt ausschließlich `--cp-admin-*`-Tokens
|
||||
- [x] 🏛 Review (Mistral): Zuteilungslogik gegen CLAUDE.md prüfen
|
||||
|
||||
---
|
||||
|
||||
## Session 8 · Veröffentlichen & persönliche Terminübersicht 💻
|
||||
|
||||
**Ziel:** Admin veröffentlicht den Zeitraum; jede:r sieht „Meine Termine" als Liste.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/routes/dienste.py
|
||||
@file app/templates/
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. 1) Admin-Aktion „Veröffentlichen" (Status veroeffentlicht, sperrt
|
||||
Verfügbarkeit). 2) Ansicht „Meine Termine": Liste der eigenen zugeteilten Einsätze,
|
||||
chronologisch, mit Rolle (Haupt/Springer) und Status. Keine Kalenderansicht.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Veröffentlichen funktioniert und sperrt die Verfügbarkeit
|
||||
- [x] „Meine Termine" zeigt eigene Einsätze als Liste
|
||||
- [x] Haupt/Springer und Status erkennbar (nicht nur über Farbe)
|
||||
|
||||
---
|
||||
|
||||
## Session 9 · Absage & Übernahme 💻
|
||||
|
||||
**Ziel:** Absage einer Hauptperson → Springer rückt automatisch nach → Springerplatz
|
||||
wird offen → „Übernehmen" für alle.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/routes/dienste.py
|
||||
@file app/models.py
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. Logik laut CLAUDE.md: Sagt eine Hauptperson ab, rückt der Springer
|
||||
automatisch nach (status uebernommen) und der Springerplatz wird offen. Offene
|
||||
Plätze erscheinen mit „Übernehmen"-Button; wer zuerst klickt, bekommt ihn (sauber
|
||||
gegen Doppel-Klicks absichern). Kein Springer → Platz direkt offen.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Absage einer Hauptperson → Springer rückt automatisch nach
|
||||
- [x] Frei gewordener Springerplatz wird offen zum Übernehmen
|
||||
- [x] „Übernehmen" sicher gegen gleichzeitige Klicks
|
||||
- [x] Fall „kein Springer" korrekt behandelt
|
||||
- [x] 🏛 Review (Mistral): Übernahme-Logik prüfen (Randfälle!)
|
||||
|
||||
---
|
||||
|
||||
## Session 10 · Dokumente & Freigabe 💻
|
||||
|
||||
**Ziel:** Upload (alle), Freigabe-Warteschlange (Admin), Liste Aktuell/Archiv.
|
||||
**Hinweis — gemischte Shell:** Die Upload-/Listenansicht für Ehrenamtliche läuft in
|
||||
der User-Shell (`base_user.html`), die Freigaben-Ansicht ausschließlich in der
|
||||
Admin-Shell (`base_admin.html`). Beide Templates entsprechend ansprechen, nicht
|
||||
vermischen.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/routes/dokumente.py
|
||||
@file app/templates/base_user.html
|
||||
@file app/templates/base_admin.html
|
||||
@file app/models.py
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. 1) Upload-Ansicht in der User-Shell (PDF/Word/Bild) mit Pflicht-Titel
|
||||
→ Status wartet_auf_freigabe (nur Admin + Uploader sichtbar), dort auch die Liste
|
||||
Aktuell/Archiv für Ehrenamtliche. 2) Freigaben-Ansicht in der Admin-Shell:
|
||||
freigeben/ablehnen (optional Grund), Admin-Uploads direkt freigegeben, Verschieben
|
||||
Aktuell↔Archiv. Dateigrößen-Limit und Dateityp-Prüfung serverseitig.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Upload landet in wartet_auf_freigabe (nicht öffentlich sichtbar), in der User-Shell
|
||||
- [x] Admin kann freigeben/ablehnen in der Admin-Shell, Admin-Uploads direkt sichtbar
|
||||
- [x] Liste Aktuell + Archiv (User-Shell), Verschieben durch Admin (Admin-Shell)
|
||||
- [x] Server prüft Dateityp und -größe
|
||||
|
||||
---
|
||||
|
||||
## Session 11 · Chat (2 Kanäle, Polling) 💻
|
||||
|
||||
**Ziel:** Ankündigungen (nur Admin postet) + Team (alle). Nur Text. Neue Nachrichten
|
||||
per `fetch`-Polling (einzige v1-JS-Ausnahme).
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/routes/chat.py
|
||||
@file app/models.py
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. Zwei Kanäle: ankuendigungen (nur Admin darf posten, alle lesen) und
|
||||
team (alle posten). Nur Text. Nachrichten chronologisch. Dazu ein kleiner
|
||||
JSON-Endpunkt „neue Nachrichten seit X" und ~30 Zeilen Vanilla-JS, das im
|
||||
Hintergrund pollt und neue Nachrichten anhängt. Kein Framework, kein WebSocket.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Beide Kanäle funktionieren, Rechte korrekt (Ankündigungen nur Admin)
|
||||
- [x] Nachrichten werden gespeichert und angezeigt (nur Text)
|
||||
- [x] Polling lädt neue Nachrichten ohne Seiten-Reload
|
||||
- [x] JS ist minimal und vanilla
|
||||
|
||||
---
|
||||
|
||||
## Session 12 · Profil 💻
|
||||
|
||||
**Ziel:** Profilbild-Upload + Platzhalter für optionale freiwillige Angaben.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/routes/profil.py
|
||||
@file app/models.py
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. Profil-Ansicht: Profilbild hochladen/ändern, Anzeigename, optionale
|
||||
freiwillige Felder (vorerst ein, zwei Freitextfelder als Platzhalter — Struktur so,
|
||||
dass Felder später leicht ergänzt werden). Nichts davon Pflicht.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Profilbild hochladbar
|
||||
- [x] Optionale Felder vorhanden, nichts Pflicht
|
||||
- [x] Struktur erlaubt späteres Ergänzen von Feldern
|
||||
|
||||
---
|
||||
|
||||
## Session 13 · In-App-Benachrichtigungen 💻
|
||||
|
||||
**Ziel:** Dezente in-App-Hinweise (neuer Dienst, offener Dienst, neue Nachricht) in
|
||||
beiden Shells.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file app/models.py
|
||||
@file app/templates/base_user.html
|
||||
@file app/templates/base_admin.html
|
||||
@file CLAUDE.md
|
||||
|
||||
Ansatz zuerst. Einfaches Benachrichtigungsmodell + Anzeige: Glocke in der Topbar
|
||||
der User-Shell mit Zähler, Liste beim Antippen; in der Admin-Shell entsprechend in
|
||||
der Sidebar/Kopfbereich. Auslöser: neue Zuteilung, neuer offener Dienst, neue
|
||||
Nachricht. Als gelesen markierbar. Kein Push, keine E-Mail.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Benachrichtigungen werden bei den drei Auslösern erzeugt
|
||||
- [x] Glocke/Hinweis zeigt ungelesene an, in beiden Shells passend platziert
|
||||
- [x] Als gelesen markierbar
|
||||
|
||||
---
|
||||
|
||||
## Session 14 · Gesamt-Review 🏛
|
||||
|
||||
**Ziel:** Durchsicht des fertigen Stands gegen CLAUDE.md — Korrektheit, Konsistenz,
|
||||
Barrierearmut, Dunkelmodus.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
@file CLAUDE.md
|
||||
@file app/models.py
|
||||
@file app/routes/
|
||||
|
||||
Review den aktuellen Stand gegen CLAUDE.md. Korrektheitsprobleme zuerst, dann
|
||||
Konsistenz und Wartbarkeit, dann Barrierearmut und Dunkelmodus-Lücken. Nichts
|
||||
umschreiben — alles als nummerierte Liste mit Fundstelle.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [x] Review-Liste erstellt
|
||||
- [x] Gefundene Korrektheitsprobleme behoben (Lokalzeit-Filter, Offen-Benachrichtigung
|
||||
ohne bereits Besetzte; offene Struktur-Divergenzen als Entscheidungen vermerkt)
|
||||
- [x] A11y-Lücke geschlossen (Body-Scroll-Lock bei offenem Off-Canvas); keine echten
|
||||
Dark-Mode-Lücken gefunden
|
||||
|
||||
---
|
||||
|
||||
## Session 15 · Deployment auf den VPS 🛠
|
||||
|
||||
**Ziel:** App läuft auf dem EU-VPS hinter einem produktiven Server.
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
Lies CLAUDE.md. Hilf mir beim Deployment auf einen EU-VPS: WSGI-Server (z. B.
|
||||
gunicorn) hinter nginx, Umgebungsvariablen/Secrets, Persistenz für SQLite und
|
||||
Uploads, einfaches Backup der DB, HTTPS. Gib mir die Schritte als Fish-Befehle,
|
||||
wo Terminal nötig ist. Plan zuerst.
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- [ ] App läuft unter gunicorn hinter nginx
|
||||
- [ ] HTTPS aktiv
|
||||
- [ ] Secrets über Umgebungsvariablen, nicht im Code
|
||||
- [ ] DB + Uploads persistent, einfaches DB-Backup eingerichtet
|
||||
|
||||
---
|
||||
|
||||
## Future Log (nicht in v1)
|
||||
|
||||
1:1-Chat · Push-/E-Mail-Benachrichtigungen · E-Mail+Code-Login (Magic Link) ·
|
||||
automatische Fairness-Verteilung · weitere Rollen ·
|
||||
Laptop-Spiegelung / Desktop-Fallback über LAN.
|
||||
124
app/__init__.py
Normal file
124
app/__init__.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""App-Factory für die CheckPoint-Ehrenamt-App.
|
||||
|
||||
Baut die Flask-App zusammen, initialisiert die Datenbank (SQLite in instance/)
|
||||
und registriert die Blueprints. Mandantenfähigkeit wird über das Team-Scoping
|
||||
(app/team_scope.py) vorbereitet – die echte Logik folgt in späteren Sessions.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
_BERLIN = ZoneInfo("Europe/Berlin")
|
||||
|
||||
|
||||
@event.listens_for(Engine, "connect")
|
||||
def _sqlite_pragmas(dbapi_connection, connection_record):
|
||||
"""Nur für SQLite: WAL + busy_timeout machen parallele gunicorn-Worker
|
||||
verträglich (statt „database is locked"), Fremdschlüssel werden erzwungen.
|
||||
Bei anderen Engines (z. B. späteres PostgreSQL) ein No-op."""
|
||||
import sqlite3
|
||||
|
||||
if isinstance(dbapi_connection, sqlite3.Connection):
|
||||
cur = dbapi_connection.cursor()
|
||||
cur.execute("PRAGMA journal_mode=WAL")
|
||||
cur.execute("PRAGMA busy_timeout=5000")
|
||||
cur.execute("PRAGMA foreign_keys=ON")
|
||||
cur.close()
|
||||
|
||||
|
||||
def _lokalzeit(dt, fmt="%d.%m.%Y %H:%M"):
|
||||
"""Jinja-Filter: UTC-Zeitstempel (in SQLite naiv) → Europe/Berlin formatiert."""
|
||||
if dt is None:
|
||||
return ""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(_BERLIN).strftime(fmt)
|
||||
|
||||
# Eine zentrale DB-Instanz, die die Modelle (Session 2) importieren.
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
def create_app(test_config=None):
|
||||
# instance_relative_config: Pfade wie die SQLite-DB liegen in instance/.
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
|
||||
# CP_ENV=production schaltet die Härtung scharf (sichere Cookies, SECRET_KEY Pflicht).
|
||||
ist_produktion = os.environ.get("CP_ENV") == "production"
|
||||
secret = os.environ.get("SECRET_KEY")
|
||||
if ist_produktion and not secret:
|
||||
raise RuntimeError(
|
||||
"SECRET_KEY muss in Produktion gesetzt sein (CP_ENV=production)."
|
||||
)
|
||||
|
||||
app.config.from_mapping(
|
||||
# In Produktion über Umgebungsvariable setzen, nie hart im Code.
|
||||
SECRET_KEY=secret or "dev-only-change-me",
|
||||
SQLALCHEMY_DATABASE_URI="sqlite:///"
|
||||
+ os.path.join(app.instance_path, "checkpoint.sqlite"),
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS=False,
|
||||
# Dokument-Uploads (Session 10): außerhalb von static/, max. 10 MB.
|
||||
UPLOAD_FOLDER=os.path.join(app.instance_path, "uploads"),
|
||||
MAX_CONTENT_LENGTH=10 * 1024 * 1024,
|
||||
# Cookie-Härtung. Secure nur in Produktion (sonst bricht http-Login lokal).
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
SESSION_COOKIE_SECURE=ist_produktion,
|
||||
)
|
||||
|
||||
if test_config is not None:
|
||||
app.config.from_mapping(test_config)
|
||||
|
||||
# instance/ + Upload-Ordner sicherstellen (für DB + Dokumente).
|
||||
os.makedirs(app.instance_path, exist_ok=True)
|
||||
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
|
||||
|
||||
# Hinter nginx: X-Forwarded-Proto/-For/-Host auswerten (korrektes https-Schema).
|
||||
if ist_produktion:
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||
|
||||
db.init_app(app)
|
||||
|
||||
# CSRF-Schutz für alle POST-Formulare.
|
||||
from .csrf import init_csrf
|
||||
|
||||
init_csrf(app)
|
||||
|
||||
# Lokalzeit-Filter (einheitliche Anzeige in Europe/Berlin).
|
||||
app.jinja_env.filters["lokalzeit"] = _lokalzeit
|
||||
|
||||
# Modelle importieren, damit sie bei create_all() bekannt sind.
|
||||
# (Noch leer – die Tabellen kommen in Session 2.)
|
||||
from . import models # noqa: F401
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
# Blueprints registrieren.
|
||||
from .routes import main_bp
|
||||
from .routes.planung import bp as planung_bp
|
||||
from .routes.dienste import bp as dienste_bp
|
||||
from .routes.dokumente import bp as dokumente_bp
|
||||
from .routes.chat import bp as chat_bp
|
||||
from .routes.profil import bp as profil_bp
|
||||
from .auth import bp as auth_bp, register_cli
|
||||
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(planung_bp)
|
||||
app.register_blueprint(dienste_bp)
|
||||
app.register_blueprint(dokumente_bp)
|
||||
app.register_blueprint(chat_bp)
|
||||
app.register_blueprint(profil_bp)
|
||||
|
||||
# CLI-Befehle (z. B. flask create-admin).
|
||||
register_cli(app)
|
||||
|
||||
return app
|
||||
215
app/auth.py
Normal file
215
app/auth.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"""Authentifizierung & Admin-Anlage.
|
||||
|
||||
- Login (Nutzername + Passwort, werkzeug-Hashing), Logout, Session.
|
||||
- Team-Scoping wird beim Login aktiviert (Team in die Session).
|
||||
- Admin-geschützte Route zum Anlegen neuer Nutzer (Rolle wählbar).
|
||||
- Keine Selbstregistrierung: den ersten Admin legt der CLI-Befehl create-admin an.
|
||||
"""
|
||||
|
||||
import functools
|
||||
|
||||
import click
|
||||
from flask.cli import with_appcontext
|
||||
from flask import (
|
||||
Blueprint,
|
||||
flash,
|
||||
g,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from . import db
|
||||
from .models import Team, User
|
||||
from .team_scope import (
|
||||
SESSION_USER_KEY,
|
||||
clear_session,
|
||||
current_team,
|
||||
get_active_team_id,
|
||||
set_active_session,
|
||||
team_query,
|
||||
)
|
||||
|
||||
bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Nutzer pro Request laden
|
||||
# -----------------------------------------------------------------------------
|
||||
@bp.before_app_request
|
||||
def load_logged_in_user():
|
||||
"""g.user pro Request setzen – team-gescoped, damit Session und Team passen."""
|
||||
user_id = session.get(SESSION_USER_KEY)
|
||||
team_id = get_active_team_id()
|
||||
if user_id is None or team_id is None:
|
||||
g.user = None
|
||||
return
|
||||
|
||||
# Bewusst über team_query: ein manipuliertes Session-Cookie kann so keinen
|
||||
# Nutzer aus einem fremden Team laden.
|
||||
g.user = team_query(User).filter_by(id=user_id).first()
|
||||
if g.user is None:
|
||||
# Session verweist ins Leere (z. B. Nutzer gelöscht) -> aufräumen.
|
||||
clear_session()
|
||||
|
||||
|
||||
def current_user():
|
||||
return getattr(g, "user", None)
|
||||
|
||||
|
||||
@bp.app_context_processor
|
||||
def inject_user_and_team():
|
||||
"""current_user und current_team in allen Templates verfügbar machen
|
||||
(Topbar, Bottom-Nav, Brand-Token-Injektion in <head>)."""
|
||||
return {"current_user": current_user(), "current_team": current_team()}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Dekoratoren
|
||||
# -----------------------------------------------------------------------------
|
||||
def login_required(view):
|
||||
@functools.wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if current_user() is None:
|
||||
return redirect(url_for("auth.login", next=request.path))
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def admin_required(view):
|
||||
@functools.wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
user = current_user()
|
||||
if user is None:
|
||||
return redirect(url_for("auth.login", next=request.path))
|
||||
if not user.ist_admin:
|
||||
return render_template("403.html"), 403
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Login / Logout
|
||||
# -----------------------------------------------------------------------------
|
||||
@bp.route("/login", methods=("GET", "POST"))
|
||||
def login():
|
||||
if current_user() is not None:
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
# Nutzername ist nur PRO Team eindeutig → der Login muss team-scoped sein.
|
||||
mehrere_teams = Team.query.count() > 1
|
||||
|
||||
if request.method == "POST":
|
||||
nutzername = (request.form.get("nutzername") or "").strip()
|
||||
passwort = request.form.get("passwort") or ""
|
||||
team_kuerzel = (request.form.get("team") or "").strip().lower()
|
||||
|
||||
# Team bestimmen: per Kürzel; ohne Kürzel nur, wenn es genau eins gibt.
|
||||
if team_kuerzel:
|
||||
team = Team.query.filter_by(slug=team_kuerzel).first()
|
||||
elif not mehrere_teams:
|
||||
team = Team.query.first()
|
||||
else:
|
||||
team = None
|
||||
|
||||
user = None
|
||||
if team is not None:
|
||||
user = User.query.filter_by(team_id=team.id, nutzername=nutzername).first()
|
||||
|
||||
if user is None or not check_password_hash(user.passwort_hash, passwort):
|
||||
# Gleiche Meldung für alle Fälle (kein Enumerieren von Team/Nutzer/Passwort).
|
||||
flash("Anmeldedaten stimmen nicht.")
|
||||
else:
|
||||
set_active_session(user.id, user.team_id)
|
||||
next_url = request.args.get("next")
|
||||
# Nur lokale Pfade zulassen (kein offener Redirect).
|
||||
if next_url and next_url.startswith("/"):
|
||||
return redirect(next_url)
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
return render_template("login.html", mehrere_teams=mehrere_teams)
|
||||
|
||||
|
||||
@bp.route("/logout", methods=("POST",))
|
||||
def logout():
|
||||
clear_session()
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Admin: Nutzer anlegen
|
||||
# -----------------------------------------------------------------------------
|
||||
@bp.route("/admin/nutzer", methods=("GET", "POST"))
|
||||
@admin_required
|
||||
def nutzer_anlegen():
|
||||
if request.method == "POST":
|
||||
nutzername = (request.form.get("nutzername") or "").strip()
|
||||
anzeigename = (request.form.get("anzeigename") or "").strip()
|
||||
passwort = request.form.get("passwort") or ""
|
||||
rolle = request.form.get("rolle") or User.ROLLE_EHRENAMT
|
||||
|
||||
fehler = None
|
||||
if not nutzername or not anzeigename or not passwort:
|
||||
fehler = "Nutzername, Anzeigename und Passwort sind nötig."
|
||||
elif rolle not in (User.ROLLE_ADMIN, User.ROLLE_EHRENAMT):
|
||||
fehler = "Unbekannte Rolle."
|
||||
elif team_query(User).filter_by(nutzername=nutzername).first() is not None:
|
||||
fehler = f"Nutzername „{nutzername}“ ist im Team schon vergeben."
|
||||
|
||||
if fehler:
|
||||
flash(fehler)
|
||||
else:
|
||||
user = User(
|
||||
team_id=get_active_team_id(),
|
||||
rolle=rolle,
|
||||
nutzername=nutzername,
|
||||
anzeigename=anzeigename,
|
||||
passwort_hash=generate_password_hash(passwort),
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
flash(f"Nutzer „{anzeigename}“ angelegt.")
|
||||
return redirect(url_for("auth.nutzer_anlegen"))
|
||||
|
||||
nutzer = team_query(User).order_by(User.anzeigename).all()
|
||||
return render_template("nutzer_neu.html", nutzer=nutzer)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# CLI: ersten Admin anlegen (Bootstrap, da keine Selbstregistrierung)
|
||||
# -----------------------------------------------------------------------------
|
||||
@click.command("create-admin")
|
||||
@click.argument("nutzername")
|
||||
@click.argument("anzeigename")
|
||||
@click.argument("passwort")
|
||||
@click.option("--team", "team_name", default="CheckPoint", help="Teamname (Default: CheckPoint).")
|
||||
@with_appcontext
|
||||
def create_admin_command(nutzername, anzeigename, passwort, team_name):
|
||||
"""Legt einen Admin im genannten Team an (Team muss existieren – siehe seed.py)."""
|
||||
team = Team.query.filter_by(name=team_name).first()
|
||||
if team is None:
|
||||
raise click.ClickException(
|
||||
f"Team „{team_name}“ existiert nicht. Erst `python seed.py` ausführen."
|
||||
)
|
||||
if User.query.filter_by(team_id=team.id, nutzername=nutzername).first() is not None:
|
||||
raise click.ClickException(f"Nutzername „{nutzername}“ ist in „{team_name}“ schon vergeben.")
|
||||
|
||||
user = User(
|
||||
team_id=team.id,
|
||||
rolle=User.ROLLE_ADMIN,
|
||||
nutzername=nutzername,
|
||||
anzeigename=anzeigename,
|
||||
passwort_hash=generate_password_hash(passwort),
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
click.echo(f"Admin „{anzeigename}“ in Team „{team_name}“ angelegt.")
|
||||
|
||||
|
||||
def register_cli(app):
|
||||
app.cli.add_command(create_admin_command)
|
||||
50
app/csrf.py
Normal file
50
app/csrf.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""CSRF-Schutz – leichtgewichtig, ohne Flask-WTF (siehe CLAUDE.md: „so wenig wie möglich").
|
||||
|
||||
Ein Session-gebundenes Token wird in jedes POST-Formular geschrieben und bei jeder
|
||||
unsicheren Anfrage geprüft. Reicht für eine interne App mit Session-Cookies.
|
||||
|
||||
Nutzung im Template:
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from flask import abort, render_template, request, session
|
||||
|
||||
CSRF_SESSION_KEY = "_csrf_token"
|
||||
CSRF_FORM_FIELD = "csrf_token"
|
||||
CSRF_HEADER = "X-CSRFToken"
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"}
|
||||
|
||||
|
||||
def generate_csrf():
|
||||
"""Token der aktuellen Session zurückgeben (beim ersten Aufruf erzeugen)."""
|
||||
if CSRF_SESSION_KEY not in session:
|
||||
session[CSRF_SESSION_KEY] = secrets.token_urlsafe(32)
|
||||
return session[CSRF_SESSION_KEY]
|
||||
|
||||
|
||||
def _validate_csrf():
|
||||
erwartet = session.get(CSRF_SESSION_KEY)
|
||||
gesendet = request.form.get(CSRF_FORM_FIELD) or request.headers.get(CSRF_HEADER)
|
||||
# compare_digest: konstante Laufzeit, verträgt aber keine None-Werte.
|
||||
if not erwartet or not gesendet or not hmac.compare_digest(erwartet, gesendet):
|
||||
abort(400, description="csrf")
|
||||
|
||||
|
||||
def init_csrf(app):
|
||||
@app.before_request
|
||||
def csrf_protect():
|
||||
if request.method not in SAFE_METHODS:
|
||||
_validate_csrf()
|
||||
|
||||
@app.errorhandler(400)
|
||||
def csrf_fehler(err):
|
||||
# Nur die CSRF-Fälle bekommen die freundliche Seite; andere 400 unverändert.
|
||||
if getattr(err, "description", None) == "csrf":
|
||||
return render_template("400_csrf.html"), 400
|
||||
return err
|
||||
|
||||
# Im Template als csrf_token() verfügbar machen.
|
||||
app.jinja_env.globals["csrf_token"] = generate_csrf
|
||||
375
app/models.py
Normal file
375
app/models.py
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
"""SQLAlchemy-Modelle der CheckPoint-Ehrenamt-App.
|
||||
|
||||
Regeln (siehe CLAUDE.md):
|
||||
- Mandantenfähig ab Tag 1: JEDES Modell trägt eine team_id. Jede Abfrage filtert
|
||||
später nach dem Team des eingeloggten Nutzers.
|
||||
- SQLite für v1, aber portabel halten (kein SQLite-spezifischer Typ), damit ein
|
||||
späterer Umstieg auf PostgreSQL möglich bleibt.
|
||||
- Status-/Typ-Felder sind Strings mit Konstanten (unten je Modell), keine DB-Enums.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from . import db
|
||||
|
||||
|
||||
def _now():
|
||||
"""Zeitzonenbewusster UTC-Zeitstempel (portabel, ohne SQLite-Defaults)."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# team — der Mandant. Das CheckPoint-Team ist das erste von perspektivisch mehreren.
|
||||
# =============================================================================
|
||||
class Team(db.Model):
|
||||
__tablename__ = "team"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(120), nullable=False)
|
||||
# Kurzes, eindeutiges Kürzel für den teambezogenen Login (z. B. "checkpoint").
|
||||
slug = db.Column(db.String(40), nullable=False, unique=True, index=True)
|
||||
|
||||
# Brand-Tokens (werden pro Team in den <head> injiziert, Session 4).
|
||||
brand_primary = db.Column(db.String(32), nullable=False, default="#21a8ad")
|
||||
brand_primary_strong = db.Column(db.String(32), nullable=False, default="#147f84")
|
||||
brand_accent = db.Column(db.String(32), nullable=False, default="#ef1823")
|
||||
brand_accent_strong = db.Column(db.String(32), nullable=False, default="#bd0f18")
|
||||
brand_font = db.Column(
|
||||
db.String(200),
|
||||
nullable=False,
|
||||
default='"DM Sans", Arial, Helvetica, sans-serif',
|
||||
)
|
||||
logo_pfad = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# Beziehungen (alle Kinder gehören zu genau diesem Team).
|
||||
users = db.relationship("User", back_populates="team", cascade="all, delete-orphan")
|
||||
planungszeitraeume = db.relationship(
|
||||
"Planungszeitraum", back_populates="team", cascade="all, delete-orphan"
|
||||
)
|
||||
kanaele = db.relationship(
|
||||
"Kanal", back_populates="team", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Team {self.id} {self.name!r}>"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# user — Admin oder Ehrenamtliche:r. Keine Selbstregistrierung (Admin legt an).
|
||||
# =============================================================================
|
||||
class User(db.Model):
|
||||
__tablename__ = "user"
|
||||
# Nutzername eindeutig pro Team, nicht global (Mandantenfähigkeit).
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("team_id", "nutzername", name="uq_user_team_nutzername"),
|
||||
)
|
||||
|
||||
ROLLE_ADMIN = "admin"
|
||||
ROLLE_EHRENAMT = "ehrenamt"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
team_id = db.Column(
|
||||
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
|
||||
)
|
||||
|
||||
rolle = db.Column(db.String(16), nullable=False, default=ROLLE_EHRENAMT)
|
||||
nutzername = db.Column(db.String(80), nullable=False)
|
||||
passwort_hash = db.Column(db.String(255), nullable=False)
|
||||
anzeigename = db.Column(db.String(120), nullable=False)
|
||||
profilbild_pfad = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# Optionale freiwillige Angaben (Platzhalter, Session 12 erweitert die Struktur).
|
||||
freiwillige_angaben = db.Column(db.Text, nullable=True)
|
||||
|
||||
team = db.relationship("Team", back_populates="users")
|
||||
verfuegbarkeiten = db.relationship(
|
||||
"Verfuegbarkeit", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
zuteilungen = db.relationship(
|
||||
"Zuteilung", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
@property
|
||||
def ist_admin(self):
|
||||
return self.rolle == self.ROLLE_ADMIN
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User {self.id} {self.nutzername!r} ({self.rolle})>"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# planungszeitraum — Block von mind. 2 Monaten. Enthält die kuratierten Einsätze.
|
||||
# =============================================================================
|
||||
class Planungszeitraum(db.Model):
|
||||
__tablename__ = "planungszeitraum"
|
||||
|
||||
STATUS_IN_PLANUNG = "in_planung"
|
||||
STATUS_VEROEFFENTLICHT = "veroeffentlicht"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
team_id = db.Column(
|
||||
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
|
||||
)
|
||||
|
||||
name = db.Column(db.String(120), nullable=False)
|
||||
start_datum = db.Column(db.Date, nullable=False)
|
||||
end_datum = db.Column(db.Date, nullable=False)
|
||||
status = db.Column(db.String(20), nullable=False, default=STATUS_IN_PLANUNG)
|
||||
|
||||
team = db.relationship("Team", back_populates="planungszeitraeume")
|
||||
einsaetze = db.relationship(
|
||||
"Einsatz",
|
||||
back_populates="planungszeitraum",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Einsatz.datum, Einsatz.start_zeit",
|
||||
)
|
||||
|
||||
@property
|
||||
def ist_veroeffentlicht(self):
|
||||
return self.status == self.STATUS_VEROEFFENTLICHT
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Planungszeitraum {self.id} {self.name!r} ({self.status})>"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# einsatz — gehört zu einem Planungszeitraum. Braucht 2 Haupt- + 1 Springerplatz.
|
||||
# =============================================================================
|
||||
class Einsatz(db.Model):
|
||||
__tablename__ = "einsatz"
|
||||
|
||||
# Platzstruktur ist fix (CLAUDE.md): 2 Haupt + 1 Springer. Belegung über Zuteilungen.
|
||||
HAUPT_PLAETZE = 2
|
||||
SPRINGER_PLAETZE = 1
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
team_id = db.Column(
|
||||
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
|
||||
)
|
||||
# Optional: ein Einsatz kann zu einem Planungszeitraum gehören ODER ein
|
||||
# eigenständiger Einzeltermin sein (planungszeitraum_id = NULL).
|
||||
planungszeitraum_id = db.Column(
|
||||
db.Integer, db.ForeignKey("planungszeitraum.id"), nullable=True, index=True
|
||||
)
|
||||
|
||||
datum = db.Column(db.Date, nullable=False)
|
||||
start_zeit = db.Column(db.Time, nullable=False)
|
||||
# Erwartete Dauer in Minuten (30er-Schritte). Optional: leer = offenes Ende.
|
||||
dauer_minuten = db.Column(db.Integer, nullable=True)
|
||||
art = db.Column(db.String(80), nullable=False) # z. B. Tour, Party, Sonderveranstaltung
|
||||
ort = db.Column(db.String(120), nullable=True) # z. B. Altstadt
|
||||
unterbesetzt = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
planungszeitraum = db.relationship("Planungszeitraum", back_populates="einsaetze")
|
||||
verfuegbarkeiten = db.relationship(
|
||||
"Verfuegbarkeit", back_populates="einsatz", cascade="all, delete-orphan"
|
||||
)
|
||||
zuteilungen = db.relationship(
|
||||
"Zuteilung", back_populates="einsatz", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
@property
|
||||
def end_zeit(self):
|
||||
"""Aus Start + erwarteter Dauer abgeleitet. None, wenn keine Dauer gesetzt."""
|
||||
if self.start_zeit is None or not self.dauer_minuten:
|
||||
return None
|
||||
basis = datetime.combine(date.min, self.start_zeit)
|
||||
return (basis + timedelta(minutes=self.dauer_minuten)).time()
|
||||
|
||||
@property
|
||||
def dauer_text(self):
|
||||
"""Menschliche Dauer, z. B. 'ca. 1,5 Std' oder 'ca. 30 Min'. None ohne Dauer."""
|
||||
if not self.dauer_minuten:
|
||||
return None
|
||||
stunden, minuten = divmod(self.dauer_minuten, 60)
|
||||
if stunden and minuten == 30:
|
||||
return f"ca. {stunden},5 Std"
|
||||
if stunden:
|
||||
return f"ca. {stunden} Std"
|
||||
return f"ca. {minuten} Min"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Einsatz {self.id} {self.art!r} {self.datum}>"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# verfuegbarkeit — (user × einsatz) → kann | kann_nicht.
|
||||
# =============================================================================
|
||||
class Verfuegbarkeit(db.Model):
|
||||
__tablename__ = "verfuegbarkeit"
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("user_id", "einsatz_id", name="uq_verfuegbarkeit_user_einsatz"),
|
||||
)
|
||||
|
||||
WERT_KANN = "kann"
|
||||
WERT_KANN_NICHT = "kann_nicht"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
team_id = db.Column(
|
||||
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
|
||||
)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
|
||||
einsatz_id = db.Column(
|
||||
db.Integer, db.ForeignKey("einsatz.id"), nullable=False, index=True
|
||||
)
|
||||
|
||||
wert = db.Column(db.String(16), nullable=False)
|
||||
|
||||
user = db.relationship("User", back_populates="verfuegbarkeiten")
|
||||
einsatz = db.relationship("Einsatz", back_populates="verfuegbarkeiten")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Verfuegbarkeit u{self.user_id} e{self.einsatz_id} {self.wert}>"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# zuteilung — (user × einsatz) mit platztyp (haupt|springer) und status.
|
||||
# =============================================================================
|
||||
class Zuteilung(db.Model):
|
||||
__tablename__ = "zuteilung"
|
||||
|
||||
PLATZ_HAUPT = "haupt"
|
||||
PLATZ_SPRINGER = "springer"
|
||||
|
||||
STATUS_ZUGETEILT = "zugeteilt"
|
||||
STATUS_ABGESAGT = "abgesagt"
|
||||
STATUS_OFFEN = "offen"
|
||||
STATUS_UEBERNOMMEN = "uebernommen"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
team_id = db.Column(
|
||||
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
|
||||
)
|
||||
einsatz_id = db.Column(
|
||||
db.Integer, db.ForeignKey("einsatz.id"), nullable=False, index=True
|
||||
)
|
||||
# Bei offenem Platz kann (noch) keine Person zugeordnet sein.
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True, index=True)
|
||||
|
||||
platztyp = db.Column(db.String(16), nullable=False)
|
||||
status = db.Column(db.String(16), nullable=False, default=STATUS_ZUGETEILT)
|
||||
|
||||
einsatz = db.relationship("Einsatz", back_populates="zuteilungen")
|
||||
user = db.relationship("User", back_populates="zuteilungen")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Zuteilung e{self.einsatz_id} u{self.user_id} {self.platztyp}/{self.status}>"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# dokument — Datei (PDF/Word/Bild) mit Freigabe-Workflow.
|
||||
# =============================================================================
|
||||
class Dokument(db.Model):
|
||||
__tablename__ = "dokument"
|
||||
|
||||
STATUS_WARTET = "wartet_auf_freigabe"
|
||||
STATUS_FREIGEGEBEN = "freigegeben"
|
||||
STATUS_ABGELEHNT = "abgelehnt"
|
||||
|
||||
ABLAGE_AKTUELL = "aktuell"
|
||||
ABLAGE_ARCHIV = "archiv"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
team_id = db.Column(
|
||||
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
|
||||
)
|
||||
uploader_id = db.Column(
|
||||
db.Integer, db.ForeignKey("user.id"), nullable=False, index=True
|
||||
)
|
||||
|
||||
titel = db.Column(db.String(200), nullable=False) # Pflicht
|
||||
datei_pfad = db.Column(db.String(255), nullable=False)
|
||||
status = db.Column(db.String(24), nullable=False, default=STATUS_WARTET)
|
||||
ablage = db.Column(db.String(16), nullable=False, default=ABLAGE_AKTUELL)
|
||||
ablehnungsgrund = db.Column(db.Text, nullable=True)
|
||||
erstellt_am = db.Column(db.DateTime(timezone=True), nullable=False, default=_now)
|
||||
|
||||
uploader = db.relationship("User")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Dokument {self.id} {self.titel!r} ({self.status})>"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# kanal — pro Team zwei feste: ankuendigungen (nur Admin postet), team (alle).
|
||||
# =============================================================================
|
||||
class Kanal(db.Model):
|
||||
__tablename__ = "kanal"
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("team_id", "art", name="uq_kanal_team_art"),
|
||||
)
|
||||
|
||||
ART_ANKUENDIGUNGEN = "ankuendigungen"
|
||||
ART_TEAM = "team"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
team_id = db.Column(
|
||||
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
|
||||
)
|
||||
art = db.Column(db.String(20), nullable=False)
|
||||
|
||||
team = db.relationship("Team", back_populates="kanaele")
|
||||
nachrichten = db.relationship(
|
||||
"Nachricht",
|
||||
back_populates="kanal",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Nachricht.erstellt_am",
|
||||
)
|
||||
|
||||
@property
|
||||
def nur_admin_darf_posten(self):
|
||||
return self.art == self.ART_ANKUENDIGUNGEN
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Kanal {self.id} {self.art!r} team{self.team_id}>"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# nachricht — Kanal, Autor, Text, Zeitstempel. Nur Text.
|
||||
# =============================================================================
|
||||
class Nachricht(db.Model):
|
||||
__tablename__ = "nachricht"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
team_id = db.Column(
|
||||
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
|
||||
)
|
||||
kanal_id = db.Column(db.Integer, db.ForeignKey("kanal.id"), nullable=False, index=True)
|
||||
autor_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
|
||||
|
||||
text = db.Column(db.Text, nullable=False)
|
||||
erstellt_am = db.Column(db.DateTime(timezone=True), nullable=False, default=_now)
|
||||
|
||||
kanal = db.relationship("Kanal", back_populates="nachrichten")
|
||||
autor = db.relationship("User")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Nachricht {self.id} kanal{self.kanal_id} u{self.autor_id}>"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# benachrichtigung — In-App-Hinweis pro Empfänger (v1: kein Push/E-Mail).
|
||||
# Ausgelöst von: neuer Dienst, offener Dienst, neue Nachricht.
|
||||
# =============================================================================
|
||||
class Benachrichtigung(db.Model):
|
||||
__tablename__ = "benachrichtigung"
|
||||
|
||||
TYP_DIENST = "dienst" # neue/eigene Zuteilung
|
||||
TYP_OFFEN = "offener_dienst" # ein Platz ist frei geworden
|
||||
TYP_NACHRICHT = "nachricht" # neue Chat-Nachricht
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
team_id = db.Column(
|
||||
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
|
||||
)
|
||||
# Empfänger der Benachrichtigung.
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
|
||||
|
||||
typ = db.Column(db.String(20), nullable=False)
|
||||
text = db.Column(db.String(255), nullable=False)
|
||||
url = db.Column(db.String(255), nullable=True) # Ziel beim Antippen (optional)
|
||||
gelesen = db.Column(db.Boolean, nullable=False, default=False, index=True)
|
||||
erstellt_am = db.Column(db.DateTime(timezone=True), nullable=False, default=_now)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Benachrichtigung {self.id} u{self.user_id} {self.typ} gelesen={self.gelesen}>"
|
||||
40
app/notifications.py
Normal file
40
app/notifications.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""In-App-Benachrichtigungen erzeugen (v1: kein Push/E-Mail, siehe CLAUDE.md).
|
||||
|
||||
Kleine Helfer, die Benachrichtigungs-Datensätze anlegen. Sie committen NICHT –
|
||||
das übernimmt der aufrufende Request, damit die Auslöser-Aktion und ihre Hinweise
|
||||
in einer Transaktion landen. Immer team-gescoped.
|
||||
"""
|
||||
|
||||
from . import db
|
||||
from .models import Benachrichtigung, User
|
||||
from .team_scope import get_active_team_id, team_query
|
||||
|
||||
|
||||
def benachrichtigen(user_id, text, url=None, typ=Benachrichtigung.TYP_DIENST):
|
||||
"""Einen Hinweis an genau eine Person anlegen."""
|
||||
db.session.add(
|
||||
Benachrichtigung(
|
||||
team_id=get_active_team_id(),
|
||||
user_id=user_id,
|
||||
typ=typ,
|
||||
text=text,
|
||||
url=url,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def benachrichtigen_team(text, url=None, typ=Benachrichtigung.TYP_NACHRICHT, ausser_user_ids=()):
|
||||
"""Einen Hinweis an alle Team-Mitglieder anlegen (optional einige auslassen)."""
|
||||
ausgenommen = set(ausser_user_ids)
|
||||
for user in team_query(User).all():
|
||||
if user.id in ausgenommen:
|
||||
continue
|
||||
db.session.add(
|
||||
Benachrichtigung(
|
||||
team_id=user.team_id,
|
||||
user_id=user.id,
|
||||
typ=typ,
|
||||
text=text,
|
||||
url=url,
|
||||
)
|
||||
)
|
||||
111
app/routes/__init__.py
Normal file
111
app/routes/__init__.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"""Start-Blueprint.
|
||||
|
||||
Hält die Startseite und – vorerst – schlanke Platzhalter für die übrigen
|
||||
Navigationsziele (Termine, Team, Profil, Benachrichtigungen). Diese Platzhalter
|
||||
werden in späteren Sessions durch die echten Ansichten/Blueprints ersetzt
|
||||
(planung, dienste, dokumente, chat, profil). Interne App: alles login_required.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, current_app, redirect, render_template, request, url_for
|
||||
|
||||
from .. import db
|
||||
from ..auth import current_user, login_required
|
||||
from ..models import Benachrichtigung
|
||||
from ..team_scope import team_query
|
||||
|
||||
main_bp = Blueprint("main", __name__)
|
||||
|
||||
# Theme-Modi (serverseitig, ohne JS): "system" folgt der Systemeinstellung,
|
||||
# "hell"/"dunkel" überschreiben sie fest. Gespeichert in einem Cookie.
|
||||
THEME_MODI = ("system", "hell", "dunkel")
|
||||
|
||||
|
||||
@main_bp.app_context_processor
|
||||
def inject_theme():
|
||||
"""Aktiven Theme-Modus aus dem Cookie in allen Templates verfügbar machen."""
|
||||
modus = request.cookies.get("theme", "system")
|
||||
return {"current_theme": modus if modus in THEME_MODI else "system"}
|
||||
|
||||
|
||||
@main_bp.app_context_processor
|
||||
def inject_shell_base():
|
||||
"""Wählt die Layout-Shell je Rolle: Admins bekommen die Sidebar-Shell, alle
|
||||
anderen die mobile Bottom-Nav-Shell. Templates: {% extends shell_base %}."""
|
||||
user = current_user()
|
||||
basis = "base_admin.html" if (user and user.ist_admin) else "base_user.html"
|
||||
return {"shell_base": basis}
|
||||
|
||||
|
||||
@main_bp.route("/")
|
||||
@login_required
|
||||
def index():
|
||||
return render_template("index.html", user=current_user())
|
||||
|
||||
|
||||
@main_bp.route("/theme", methods=("POST",))
|
||||
def theme_setzen():
|
||||
"""Theme-Modus setzen (Cookie) und zur Ausgangsseite zurück. Kein Login nötig,
|
||||
damit die Einstellung überall greift; CSRF-geschützt wie alle POST-Formulare."""
|
||||
modus = request.form.get("modus", "system")
|
||||
if modus not in THEME_MODI:
|
||||
modus = "system"
|
||||
|
||||
# Nur lokale Pfade als Rücksprungziel zulassen (kein Open-Redirect).
|
||||
ziel = request.form.get("next", "")
|
||||
if not ziel.startswith("/"):
|
||||
ziel = url_for("main.index")
|
||||
|
||||
antwort = redirect(ziel)
|
||||
if modus == "system":
|
||||
antwort.delete_cookie("theme")
|
||||
else:
|
||||
antwort.set_cookie(
|
||||
"theme", modus, max_age=60 * 60 * 24 * 365,
|
||||
samesite="Lax", httponly=True,
|
||||
secure=current_app.config.get("SESSION_COOKIE_SECURE", False),
|
||||
)
|
||||
return antwort
|
||||
|
||||
|
||||
# --- Platzhalter, damit die Bottom-Nav schon vollständig funktioniert ---------
|
||||
# „Termine" zeigt auf planung.verfuegbarkeit (Session 6).
|
||||
@main_bp.route("/team")
|
||||
@login_required
|
||||
def team():
|
||||
return render_template("team.html")
|
||||
|
||||
|
||||
@main_bp.app_context_processor
|
||||
def inject_benachrichtigungen_zahl():
|
||||
"""Anzahl ungelesener Benachrichtigungen für die Glocke (0, wenn nicht eingeloggt)."""
|
||||
user = current_user()
|
||||
if user is None:
|
||||
return {"benachrichtigungen_ungelesen": 0}
|
||||
anzahl = (
|
||||
team_query(Benachrichtigung)
|
||||
.filter_by(user_id=user.id, gelesen=False)
|
||||
.count()
|
||||
)
|
||||
return {"benachrichtigungen_ungelesen": anzahl}
|
||||
|
||||
|
||||
@main_bp.route("/benachrichtigungen")
|
||||
@login_required
|
||||
def benachrichtigungen():
|
||||
eintraege = (
|
||||
team_query(Benachrichtigung)
|
||||
.filter_by(user_id=current_user().id)
|
||||
.order_by(Benachrichtigung.erstellt_am.desc())
|
||||
.all()
|
||||
)
|
||||
return render_template("benachrichtigungen.html", eintraege=eintraege)
|
||||
|
||||
|
||||
@main_bp.route("/benachrichtigungen/gelesen", methods=("POST",))
|
||||
@login_required
|
||||
def benachrichtigungen_gelesen():
|
||||
team_query(Benachrichtigung).filter_by(
|
||||
user_id=current_user().id, gelesen=False
|
||||
).update({"gelesen": True}, synchronize_session=False)
|
||||
db.session.commit()
|
||||
return redirect(url_for("main.benachrichtigungen"))
|
||||
135
app/routes/chat.py
Normal file
135
app/routes/chat.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"""Chat: zwei feste Kanäle pro Team (CLAUDE.md).
|
||||
|
||||
- ankuendigungen: nur Admin postet, alle lesen.
|
||||
- team: alle posten.
|
||||
|
||||
Nur Text. Nachrichten chronologisch. Neue Nachrichten holt der Client per
|
||||
kleinem fetch-Polling (JSON-Endpunkt /chat/<art>/seit/<id>) – das ist die einzige
|
||||
in v1 erlaubte JavaScript-Ausnahme. Kein WebSocket, kein Framework. Alles
|
||||
team-gescoped (team_query) und CSRF-geschützt.
|
||||
"""
|
||||
|
||||
from datetime import timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
abort,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
url_for,
|
||||
)
|
||||
|
||||
from .. import db
|
||||
from ..auth import current_user, login_required
|
||||
from ..models import Benachrichtigung, Kanal, Nachricht
|
||||
from ..notifications import benachrichtigen_team
|
||||
from ..team_scope import get_active_team_id, team_query
|
||||
|
||||
bp = Blueprint("chat", __name__, url_prefix="/chat")
|
||||
|
||||
LABELS = {Kanal.ART_ANKUENDIGUNGEN: "Ankündigungen", Kanal.ART_TEAM: "Team"}
|
||||
MAX_LAENGE = 2000
|
||||
_BERLIN = ZoneInfo("Europe/Berlin")
|
||||
|
||||
|
||||
def _zeit(dt):
|
||||
"""erstellt_am (in UTC, aus SQLite meist naiv) → 'DD.MM. HH:MM' in Berliner Zeit."""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(_BERLIN).strftime("%d.%m. %H:%M")
|
||||
|
||||
|
||||
def _kanal_holen(art):
|
||||
if art not in (Kanal.ART_ANKUENDIGUNGEN, Kanal.ART_TEAM):
|
||||
abort(404)
|
||||
kanal = team_query(Kanal).filter_by(art=art).first()
|
||||
if kanal is None:
|
||||
abort(404)
|
||||
return kanal
|
||||
|
||||
|
||||
def _serialisieren(nachricht, user_id):
|
||||
return {
|
||||
"id": nachricht.id,
|
||||
"autor": nachricht.autor.anzeigename,
|
||||
"zeit": _zeit(nachricht.erstellt_am),
|
||||
"text": nachricht.text,
|
||||
"eigene": nachricht.autor_id == user_id,
|
||||
}
|
||||
|
||||
|
||||
@bp.route("/<art>")
|
||||
@login_required
|
||||
def kanal(art):
|
||||
kanal = _kanal_holen(art)
|
||||
nachrichten = (
|
||||
team_query(Nachricht)
|
||||
.filter_by(kanal_id=kanal.id)
|
||||
.order_by(Nachricht.erstellt_am)
|
||||
.all()
|
||||
)
|
||||
darf_posten = (not kanal.nur_admin_darf_posten) or current_user().ist_admin
|
||||
letzte_id = nachrichten[-1].id if nachrichten else 0
|
||||
return render_template(
|
||||
"chat.html",
|
||||
kanal=kanal,
|
||||
label=LABELS[kanal.art],
|
||||
nachrichten=nachrichten,
|
||||
zeit=_zeit,
|
||||
darf_posten=darf_posten,
|
||||
letzte_id=letzte_id,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/<art>", methods=("POST",))
|
||||
@login_required
|
||||
def posten(art):
|
||||
kanal = _kanal_holen(art)
|
||||
# Ankündigungen: nur Admin darf posten (alle dürfen lesen).
|
||||
if kanal.nur_admin_darf_posten and not current_user().ist_admin:
|
||||
abort(403)
|
||||
|
||||
text = (request.form.get("text") or "").strip()
|
||||
if not text:
|
||||
flash("Bitte einen Text eingeben.")
|
||||
return redirect(url_for("chat.kanal", art=art))
|
||||
if len(text) > MAX_LAENGE:
|
||||
flash(f"Nachricht zu lang (max. {MAX_LAENGE} Zeichen).")
|
||||
return redirect(url_for("chat.kanal", art=art))
|
||||
|
||||
db.session.add(
|
||||
Nachricht(
|
||||
team_id=get_active_team_id(),
|
||||
kanal_id=kanal.id,
|
||||
autor_id=current_user().id,
|
||||
text=text,
|
||||
)
|
||||
)
|
||||
# Alle außer dem Autor über die neue Nachricht informieren.
|
||||
benachrichtigen_team(
|
||||
f"Neue Nachricht in „{LABELS[kanal.art]}“ von {current_user().anzeigename}",
|
||||
url=url_for("chat.kanal", art=art),
|
||||
typ=Benachrichtigung.TYP_NACHRICHT,
|
||||
ausser_user_ids={current_user().id},
|
||||
)
|
||||
db.session.commit()
|
||||
return redirect(url_for("chat.kanal", art=art))
|
||||
|
||||
|
||||
@bp.route("/<art>/seit/<int:seit>")
|
||||
@login_required
|
||||
def neu(art, seit):
|
||||
"""JSON: alle Nachrichten dieses Kanals mit id > seit (für das Polling)."""
|
||||
kanal = _kanal_holen(art)
|
||||
user_id = current_user().id
|
||||
nachrichten = (
|
||||
team_query(Nachricht)
|
||||
.filter(Nachricht.kanal_id == kanal.id, Nachricht.id > seit)
|
||||
.order_by(Nachricht.erstellt_am)
|
||||
.all()
|
||||
)
|
||||
return jsonify(nachrichten=[_serialisieren(n, user_id) for n in nachrichten])
|
||||
429
app/routes/dienste.py
Normal file
429
app/routes/dienste.py
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
"""Dienstplan bauen (Admin).
|
||||
|
||||
Manuelle Zuteilung: pro Einsatz 2 Haupt + 1 Springer. Keine automatische
|
||||
Verteilung – die App zeigt nur die verfügbaren Personen samt Fairness-Hilfe
|
||||
(Anzahl bisheriger Einsätze, letzter Einsatz) an; auswählen tut der Admin.
|
||||
|
||||
Alles admin-geschützt und team-gescoped.
|
||||
"""
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
abort,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
url_for,
|
||||
)
|
||||
from sqlalchemy import func, or_
|
||||
|
||||
from .. import db
|
||||
from ..auth import admin_required, current_user, login_required
|
||||
from ..models import Benachrichtigung, Einsatz, Planungszeitraum, User, Verfuegbarkeit, Zuteilung
|
||||
from ..notifications import benachrichtigen, benachrichtigen_team
|
||||
from ..team_scope import get_active_team_id, team_query
|
||||
|
||||
bp = Blueprint("dienste", __name__, url_prefix="/dienste")
|
||||
|
||||
# Status, die einen Platz tatsächlich belegen.
|
||||
AKTIVE_STATUS = (Zuteilung.STATUS_ZUGETEILT, Zuteilung.STATUS_UEBERNOMMEN)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Hilfen
|
||||
# -----------------------------------------------------------------------------
|
||||
def _einsatz_holen(einsatz_id):
|
||||
einsatz = team_query(Einsatz).filter_by(id=einsatz_id).first()
|
||||
if einsatz is None:
|
||||
abort(404)
|
||||
return einsatz
|
||||
|
||||
|
||||
def _belegung(einsatz):
|
||||
"""Aktive Zuteilungen des Einsatzes, getrennt nach Platztyp."""
|
||||
haupt, springer = [], []
|
||||
for z in einsatz.zuteilungen:
|
||||
if z.status not in AKTIVE_STATUS:
|
||||
continue
|
||||
if z.platztyp == Zuteilung.PLATZ_HAUPT:
|
||||
haupt.append(z)
|
||||
elif z.platztyp == Zuteilung.PLATZ_SPRINGER:
|
||||
springer.append(z)
|
||||
return haupt, springer
|
||||
|
||||
|
||||
def _fairness_map():
|
||||
"""{user_id: (anzahl_einsaetze, letztes_datum)} über das ganze Team.
|
||||
|
||||
Grundlage: aktive Zuteilungen. Hilft dem Admin, fair zu verteilen.
|
||||
"""
|
||||
rows = (
|
||||
db.session.query(
|
||||
Zuteilung.user_id,
|
||||
func.count(Zuteilung.id),
|
||||
func.max(Einsatz.datum),
|
||||
)
|
||||
.join(Einsatz, Zuteilung.einsatz_id == Einsatz.id)
|
||||
.filter(Zuteilung.team_id == get_active_team_id())
|
||||
.filter(Zuteilung.user_id.isnot(None))
|
||||
.filter(Zuteilung.status.in_(AKTIVE_STATUS))
|
||||
.group_by(Zuteilung.user_id)
|
||||
.all()
|
||||
)
|
||||
return {uid: (anzahl, letztes) for uid, anzahl, letztes in rows}
|
||||
|
||||
|
||||
def _kandidaten(einsatz, belegt_ids):
|
||||
"""Personen mit „Kann" für diesen Einsatz, die noch nicht zugeteilt sind."""
|
||||
q = (
|
||||
team_query(User)
|
||||
.join(Verfuegbarkeit, Verfuegbarkeit.user_id == User.id)
|
||||
.filter(Verfuegbarkeit.einsatz_id == einsatz.id)
|
||||
.filter(Verfuegbarkeit.wert == Verfuegbarkeit.WERT_KANN)
|
||||
)
|
||||
if belegt_ids:
|
||||
q = q.filter(User.id.notin_(belegt_ids))
|
||||
return q.order_by(User.anzeigename).all()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Übersichten
|
||||
# -----------------------------------------------------------------------------
|
||||
@bp.route("/")
|
||||
@admin_required
|
||||
def liste():
|
||||
zeitraeume = team_query(Planungszeitraum).order_by(
|
||||
Planungszeitraum.start_datum.desc()
|
||||
).all()
|
||||
einzeltermine = (
|
||||
team_query(Einsatz)
|
||||
.filter_by(planungszeitraum_id=None)
|
||||
.order_by(Einsatz.datum, Einsatz.start_zeit)
|
||||
.all()
|
||||
)
|
||||
return render_template(
|
||||
"dienste_liste.html", zeitraeume=zeitraeume, einzeltermine=einzeltermine
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/zeitraum/<int:zeitraum_id>/veroeffentlichen", methods=("POST",))
|
||||
@admin_required
|
||||
def veroeffentlichen(zeitraum_id):
|
||||
zr = team_query(Planungszeitraum).filter_by(id=zeitraum_id).first()
|
||||
if zr is None:
|
||||
abort(404)
|
||||
|
||||
if zr.ist_veroeffentlicht:
|
||||
flash("Der Zeitraum ist bereits veröffentlicht.")
|
||||
else:
|
||||
zr.status = Planungszeitraum.STATUS_VEROEFFENTLICHT
|
||||
# Jede zugeteilte Person über ihre Dienste informieren.
|
||||
zuteilungen = (
|
||||
team_query(Zuteilung)
|
||||
.join(Einsatz, Zuteilung.einsatz_id == Einsatz.id)
|
||||
.filter(Einsatz.planungszeitraum_id == zr.id)
|
||||
.filter(Zuteilung.user_id.isnot(None))
|
||||
.filter(Zuteilung.status.in_(AKTIVE_STATUS))
|
||||
.all()
|
||||
)
|
||||
for z in zuteilungen:
|
||||
rolle = "Haupt" if z.platztyp == Zuteilung.PLATZ_HAUPT else "Springer"
|
||||
benachrichtigen(
|
||||
z.user_id,
|
||||
f"Neuer Dienst: {z.einsatz.art} am {z.einsatz.datum.strftime('%d.%m.%Y')} ({rolle})",
|
||||
url=url_for("dienste.meine_termine"),
|
||||
typ=Benachrichtigung.TYP_DIENST,
|
||||
)
|
||||
db.session.commit()
|
||||
flash(f"„{zr.name}“ veröffentlicht. Alle sehen jetzt ihren Dienstplan.")
|
||||
return redirect(url_for("dienste.zeitraum", zeitraum_id=zr.id))
|
||||
|
||||
|
||||
@bp.route("/zeitraum/<int:zeitraum_id>")
|
||||
@admin_required
|
||||
def zeitraum(zeitraum_id):
|
||||
zr = team_query(Planungszeitraum).filter_by(id=zeitraum_id).first()
|
||||
if zr is None:
|
||||
abort(404)
|
||||
|
||||
# Besetzungsstand je Einsatz für die Übersicht.
|
||||
stand = {}
|
||||
for e in zr.einsaetze:
|
||||
haupt, springer = _belegung(e)
|
||||
stand[e.id] = (len(haupt), len(springer))
|
||||
|
||||
return render_template("dienste_zeitraum.html", zeitraum=zr, stand=stand)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Bau-Ansicht pro Einsatz
|
||||
# -----------------------------------------------------------------------------
|
||||
@bp.route("/einsatz/<int:einsatz_id>")
|
||||
@admin_required
|
||||
def einsatz(einsatz_id):
|
||||
e = _einsatz_holen(einsatz_id)
|
||||
haupt, springer = _belegung(e)
|
||||
belegt_ids = [z.user_id for z in haupt + springer]
|
||||
kandidaten = _kandidaten(e, belegt_ids)
|
||||
fairness = _fairness_map()
|
||||
|
||||
return render_template(
|
||||
"dienste_einsatz.html",
|
||||
einsatz=e,
|
||||
haupt=haupt,
|
||||
springer=springer,
|
||||
haupt_frei=Einsatz.HAUPT_PLAETZE - len(haupt),
|
||||
springer_frei=Einsatz.SPRINGER_PLAETZE - len(springer),
|
||||
kandidaten=kandidaten,
|
||||
fairness=fairness,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/einsatz/<int:einsatz_id>/zuteilen", methods=("POST",))
|
||||
@admin_required
|
||||
def zuteilen(einsatz_id):
|
||||
e = _einsatz_holen(einsatz_id)
|
||||
platztyp = request.form.get("platztyp")
|
||||
user_id = request.form.get("user_id", type=int)
|
||||
|
||||
if platztyp not in (Zuteilung.PLATZ_HAUPT, Zuteilung.PLATZ_SPRINGER):
|
||||
flash("Ungültiger Platztyp.")
|
||||
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
|
||||
|
||||
person = team_query(User).filter_by(id=user_id).first()
|
||||
if person is None:
|
||||
abort(404)
|
||||
|
||||
# Person muss „Kann" gemeldet haben.
|
||||
kann = team_query(Verfuegbarkeit).filter_by(
|
||||
user_id=person.id, einsatz_id=e.id, wert=Verfuegbarkeit.WERT_KANN
|
||||
).first()
|
||||
if kann is None:
|
||||
flash(f"{person.anzeigename} hat für diesen Einsatz kein „Kann“ gemeldet.")
|
||||
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
|
||||
|
||||
haupt, springer = _belegung(e)
|
||||
belegt_ids = [z.user_id for z in haupt + springer]
|
||||
if person.id in belegt_ids:
|
||||
flash(f"{person.anzeigename} ist diesem Einsatz schon zugeteilt.")
|
||||
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
|
||||
|
||||
if platztyp == Zuteilung.PLATZ_HAUPT and len(haupt) >= Einsatz.HAUPT_PLAETZE:
|
||||
flash("Die Hauptplätze sind bereits voll.")
|
||||
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
|
||||
if platztyp == Zuteilung.PLATZ_SPRINGER and len(springer) >= Einsatz.SPRINGER_PLAETZE:
|
||||
flash("Der Springerplatz ist bereits besetzt.")
|
||||
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
|
||||
|
||||
db.session.add(
|
||||
Zuteilung(
|
||||
team_id=get_active_team_id(),
|
||||
einsatz_id=e.id,
|
||||
user_id=person.id,
|
||||
platztyp=platztyp,
|
||||
status=Zuteilung.STATUS_ZUGETEILT,
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
flash(f"{person.anzeigename} zugeteilt.")
|
||||
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
|
||||
|
||||
|
||||
@bp.route("/einsatz/<int:einsatz_id>/entfernen", methods=("POST",))
|
||||
@admin_required
|
||||
def entfernen(einsatz_id):
|
||||
e = _einsatz_holen(einsatz_id)
|
||||
zuteilung_id = request.form.get("zuteilung_id", type=int)
|
||||
z = team_query(Zuteilung).filter_by(id=zuteilung_id, einsatz_id=e.id).first()
|
||||
if z is None:
|
||||
abort(404)
|
||||
|
||||
db.session.delete(z)
|
||||
db.session.commit()
|
||||
flash("Zuteilung entfernt.")
|
||||
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
|
||||
|
||||
|
||||
@bp.route("/einsatz/<int:einsatz_id>/unterbesetzt", methods=("POST",))
|
||||
@admin_required
|
||||
def unterbesetzt(einsatz_id):
|
||||
e = _einsatz_holen(einsatz_id)
|
||||
e.unterbesetzt = not e.unterbesetzt
|
||||
db.session.commit()
|
||||
flash("Als unterbesetzt markiert." if e.unterbesetzt else "Markierung entfernt.")
|
||||
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Sichtbarkeitsfilter: nur veröffentlichte Zeiträume oder Einzeltermine
|
||||
# -----------------------------------------------------------------------------
|
||||
def _sichtbar_filter(query):
|
||||
return query.filter(
|
||||
or_(
|
||||
Einsatz.planungszeitraum_id.is_(None),
|
||||
Planungszeitraum.status == Planungszeitraum.STATUS_VEROEFFENTLICHT,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Meine Termine (alle eingeloggten Nutzer) – eigene Zuteilungen + offene Dienste
|
||||
# -----------------------------------------------------------------------------
|
||||
@bp.route("/meine-termine")
|
||||
@login_required
|
||||
def meine_termine():
|
||||
rows = _sichtbar_filter(
|
||||
db.session.query(Zuteilung, Einsatz)
|
||||
.join(Einsatz, Zuteilung.einsatz_id == Einsatz.id)
|
||||
.outerjoin(Planungszeitraum, Einsatz.planungszeitraum_id == Planungszeitraum.id)
|
||||
.filter(Zuteilung.team_id == get_active_team_id())
|
||||
.filter(Zuteilung.user_id == current_user().id)
|
||||
.filter(Zuteilung.status.in_(AKTIVE_STATUS))
|
||||
).order_by(Einsatz.datum, Einsatz.start_zeit).all()
|
||||
|
||||
# Offene Dienste (nach einer Absage) – für alle zum Übernehmen.
|
||||
offene = _sichtbar_filter(
|
||||
db.session.query(Zuteilung, Einsatz)
|
||||
.join(Einsatz, Zuteilung.einsatz_id == Einsatz.id)
|
||||
.outerjoin(Planungszeitraum, Einsatz.planungszeitraum_id == Planungszeitraum.id)
|
||||
.filter(Zuteilung.team_id == get_active_team_id())
|
||||
.filter(Zuteilung.status == Zuteilung.STATUS_OFFEN)
|
||||
).order_by(Einsatz.datum, Einsatz.start_zeit).all()
|
||||
|
||||
# Einsätze, auf denen man selbst schon aktiv ist – dort kein zweiter Platz.
|
||||
eigene_einsatz_ids = {e.id for _, e in rows}
|
||||
|
||||
# Gibt es gerade etwas zu melden? (für den Link zur Verfügbarkeit)
|
||||
offene_planung = (
|
||||
team_query(Planungszeitraum)
|
||||
.filter_by(status=Planungszeitraum.STATUS_IN_PLANUNG)
|
||||
.first()
|
||||
is not None
|
||||
or team_query(Einsatz).filter_by(planungszeitraum_id=None).first() is not None
|
||||
)
|
||||
return render_template(
|
||||
"meine_termine.html",
|
||||
termine=rows,
|
||||
offene=offene,
|
||||
eigene_einsatz_ids=eigene_einsatz_ids,
|
||||
offene_planung=offene_planung,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Absage & Übernahme
|
||||
# -----------------------------------------------------------------------------
|
||||
def _offen_anlegen(einsatz, platztyp):
|
||||
db.session.add(
|
||||
Zuteilung(
|
||||
team_id=get_active_team_id(),
|
||||
einsatz_id=einsatz.id,
|
||||
user_id=None,
|
||||
platztyp=platztyp,
|
||||
status=Zuteilung.STATUS_OFFEN,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/zuteilung/<int:zuteilung_id>/absagen", methods=("POST",))
|
||||
@login_required
|
||||
def absagen(zuteilung_id):
|
||||
z = team_query(Zuteilung).filter_by(id=zuteilung_id).first()
|
||||
if z is None:
|
||||
abort(404)
|
||||
# Nur die eigene Zuteilung absagen.
|
||||
if z.user_id != current_user().id:
|
||||
abort(403)
|
||||
if z.status not in AKTIVE_STATUS:
|
||||
flash("Dieser Dienst ist nicht (mehr) aktiv.")
|
||||
return redirect(url_for("dienste.meine_termine"))
|
||||
|
||||
einsatz = z.einsatz
|
||||
war_haupt = z.platztyp == Zuteilung.PLATZ_HAUPT
|
||||
z.status = Zuteilung.STATUS_ABGESAGT
|
||||
|
||||
if war_haupt:
|
||||
# Springer rückt nach, falls vorhanden.
|
||||
springer = next(
|
||||
(
|
||||
s
|
||||
for s in einsatz.zuteilungen
|
||||
if s.platztyp == Zuteilung.PLATZ_SPRINGER
|
||||
and s.status in AKTIVE_STATUS
|
||||
and s.user_id is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
if springer is not None:
|
||||
springer.platztyp = Zuteilung.PLATZ_HAUPT
|
||||
springer.status = Zuteilung.STATUS_UEBERNOMMEN
|
||||
_offen_anlegen(einsatz, Zuteilung.PLATZ_SPRINGER)
|
||||
benachrichtigen(
|
||||
springer.user_id,
|
||||
f"Du bist nachgerückt: {einsatz.art} am {einsatz.datum.strftime('%d.%m.%Y')} (jetzt Haupt)",
|
||||
url=url_for("dienste.meine_termine"),
|
||||
typ=Benachrichtigung.TYP_DIENST,
|
||||
)
|
||||
else:
|
||||
_offen_anlegen(einsatz, Zuteilung.PLATZ_HAUPT)
|
||||
else:
|
||||
# Springer sagt ab -> Springerplatz wird offen.
|
||||
_offen_anlegen(einsatz, Zuteilung.PLATZ_SPRINGER)
|
||||
|
||||
# Offener Platz: alle zum Übernehmen einladen – außer wer schon auf diesem
|
||||
# Einsatz steht (z. B. der gerade nachgerückte Springer) und der absagenden Person.
|
||||
schon_dabei = {
|
||||
s.user_id
|
||||
for s in einsatz.zuteilungen
|
||||
if s.user_id is not None and s.status in AKTIVE_STATUS
|
||||
}
|
||||
schon_dabei.add(current_user().id)
|
||||
benachrichtigen_team(
|
||||
f"Offener Dienst: {einsatz.art} am {einsatz.datum.strftime('%d.%m.%Y')} – jetzt übernehmen",
|
||||
url=url_for("dienste.meine_termine"),
|
||||
typ=Benachrichtigung.TYP_OFFEN,
|
||||
ausser_user_ids=schon_dabei,
|
||||
)
|
||||
db.session.commit()
|
||||
flash("Abgesagt. Der frei gewordene Platz ist jetzt offen.")
|
||||
return redirect(url_for("dienste.meine_termine"))
|
||||
|
||||
|
||||
@bp.route("/zuteilung/<int:zuteilung_id>/uebernehmen", methods=("POST",))
|
||||
@login_required
|
||||
def uebernehmen(zuteilung_id):
|
||||
user = current_user()
|
||||
|
||||
# Vorprüfung: nicht zweimal auf denselben Einsatz.
|
||||
z = team_query(Zuteilung).filter_by(id=zuteilung_id).first()
|
||||
if z is None:
|
||||
abort(404)
|
||||
schon_dabei = team_query(Zuteilung).filter(
|
||||
Zuteilung.einsatz_id == z.einsatz_id,
|
||||
Zuteilung.user_id == user.id,
|
||||
Zuteilung.status.in_(AKTIVE_STATUS),
|
||||
).first()
|
||||
if schon_dabei is not None:
|
||||
flash("Du bist diesem Einsatz schon zugeteilt.")
|
||||
return redirect(url_for("dienste.meine_termine"))
|
||||
|
||||
# Atomar gegen gleichzeitiges Übernehmen: nur, solange wirklich noch offen.
|
||||
getroffen = (
|
||||
Zuteilung.query.filter_by(
|
||||
id=zuteilung_id,
|
||||
team_id=get_active_team_id(),
|
||||
status=Zuteilung.STATUS_OFFEN,
|
||||
user_id=None,
|
||||
).update(
|
||||
{"user_id": user.id, "status": Zuteilung.STATUS_UEBERNOMMEN},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
if getroffen == 1:
|
||||
flash("Übernommen.")
|
||||
else:
|
||||
flash("Dieser Dienst wurde gerade schon übernommen.")
|
||||
return redirect(url_for("dienste.meine_termine"))
|
||||
236
app/routes/dokumente.py
Normal file
236
app/routes/dokumente.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
"""Dokumente: Upload mit Freigabe-Workflow (CLAUDE.md).
|
||||
|
||||
Alle dürfen hochladen; der Upload startet als wartet_auf_freigabe und ist nur
|
||||
für Admin + Uploader sichtbar. Der Admin gibt frei oder lehnt ab (optional mit
|
||||
Grund); Admin-Uploads sind direkt freigegeben. Freigegebene Dokumente sehen alle
|
||||
im Team, getrennt in Aktuell und Archiv (Verschieben macht der Admin).
|
||||
|
||||
Dateien liegen außerhalb von static/ (instance/uploads/) und werden nur über die
|
||||
geschützte Route /dokumente/<id>/datei ausgeliefert. Reine POST-Formulare, kein JS.
|
||||
Alles team-gescoped (team_query) und CSRF-geschützt.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
abort,
|
||||
current_app,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_from_directory,
|
||||
url_for,
|
||||
)
|
||||
|
||||
from .. import db
|
||||
from ..auth import admin_required, current_user, login_required
|
||||
from ..models import Dokument
|
||||
from ..team_scope import get_active_team_id, team_query
|
||||
|
||||
bp = Blueprint("dokumente", __name__, url_prefix="/dokumente")
|
||||
|
||||
# Endungs-Whitelist (CLAUDE.md: PDF/Word/Bild) + Tabellen.
|
||||
ERLAUBTE_ENDUNGEN = {
|
||||
"pdf",
|
||||
"doc",
|
||||
"docx",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
}
|
||||
|
||||
|
||||
def _endung(dateiname):
|
||||
"""Kleingeschriebene Dateiendung ohne Punkt (oder '' ohne Endung)."""
|
||||
if not dateiname or "." not in dateiname:
|
||||
return ""
|
||||
return dateiname.rsplit(".", 1)[1].lower()
|
||||
|
||||
|
||||
def _darf_sehen(dok, user):
|
||||
"""Sichtbarkeit eines einzelnen Dokuments für den aktuellen Nutzer."""
|
||||
if dok.status == Dokument.STATUS_FREIGEGEBEN:
|
||||
return True
|
||||
# Wartend oder abgelehnt: nur Admin oder der Uploader.
|
||||
return user.ist_admin or dok.uploader_id == user.id
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Liste (alle): freigegeben in Aktuell/Archiv + eigene wartende Uploads
|
||||
# -----------------------------------------------------------------------------
|
||||
@bp.route("/")
|
||||
@login_required
|
||||
def liste():
|
||||
user = current_user()
|
||||
|
||||
freigegeben = (
|
||||
team_query(Dokument)
|
||||
.filter_by(status=Dokument.STATUS_FREIGEGEBEN)
|
||||
.order_by(Dokument.erstellt_am.desc())
|
||||
.all()
|
||||
)
|
||||
aktuell = [d for d in freigegeben if d.ablage == Dokument.ABLAGE_AKTUELL]
|
||||
archiv = [d for d in freigegeben if d.ablage == Dokument.ABLAGE_ARCHIV]
|
||||
|
||||
# Eigene noch nicht freigegebene Uploads (nur für den Uploader sichtbar).
|
||||
eigene_wartend = (
|
||||
team_query(Dokument)
|
||||
.filter(Dokument.uploader_id == user.id)
|
||||
.filter(Dokument.status != Dokument.STATUS_FREIGEGEBEN)
|
||||
.order_by(Dokument.erstellt_am.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
offene_freigaben = 0
|
||||
if user.ist_admin:
|
||||
offene_freigaben = (
|
||||
team_query(Dokument).filter_by(status=Dokument.STATUS_WARTET).count()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"dokumente.html",
|
||||
aktuell=aktuell,
|
||||
archiv=archiv,
|
||||
eigene_wartend=eigene_wartend,
|
||||
offene_freigaben=offene_freigaben,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Upload (alle): Pflicht-Titel + Datei, serverseitige Typ-/Größenprüfung
|
||||
# -----------------------------------------------------------------------------
|
||||
@bp.route("/upload", methods=("POST",))
|
||||
@login_required
|
||||
def upload():
|
||||
user = current_user()
|
||||
titel = (request.form.get("titel") or "").strip()
|
||||
datei = request.files.get("datei")
|
||||
|
||||
if not titel:
|
||||
flash("Bitte einen Titel angeben.")
|
||||
return redirect(url_for("dokumente.liste"))
|
||||
if datei is None or datei.filename == "":
|
||||
flash("Bitte eine Datei auswählen.")
|
||||
return redirect(url_for("dokumente.liste"))
|
||||
|
||||
endung = _endung(datei.filename)
|
||||
if endung not in ERLAUBTE_ENDUNGEN:
|
||||
flash("Dieser Dateityp ist nicht erlaubt. Erlaubt: PDF, Word, Excel, Bilder.")
|
||||
return redirect(url_for("dokumente.liste"))
|
||||
|
||||
# Kollisionsfreier, sicherer Speichername. Der Originalname dient nur als
|
||||
# Titel-Hilfe; gespeichert wird unter einem zufälligen Namen.
|
||||
speichername = f"{uuid.uuid4().hex}.{endung}"
|
||||
datei.save(os.path.join(current_app.config["UPLOAD_FOLDER"], speichername))
|
||||
|
||||
# Admin-Uploads sind direkt freigegeben, sonst Warteschlange.
|
||||
status = Dokument.STATUS_FREIGEGEBEN if user.ist_admin else Dokument.STATUS_WARTET
|
||||
dok = Dokument(
|
||||
team_id=get_active_team_id(),
|
||||
uploader_id=user.id,
|
||||
titel=titel,
|
||||
datei_pfad=speichername,
|
||||
status=status,
|
||||
)
|
||||
db.session.add(dok)
|
||||
db.session.commit()
|
||||
|
||||
if user.ist_admin:
|
||||
flash("Dokument hochgeladen und freigegeben.")
|
||||
else:
|
||||
flash("Dokument hochgeladen. Es ist sichtbar, sobald der Admin es freigibt.")
|
||||
return redirect(url_for("dokumente.liste"))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Datei ausliefern (geschützt, mit Sichtbarkeitsprüfung)
|
||||
# -----------------------------------------------------------------------------
|
||||
@bp.route("/<int:dok_id>/datei")
|
||||
@login_required
|
||||
def datei(dok_id):
|
||||
dok = team_query(Dokument).filter_by(id=dok_id).first()
|
||||
if dok is None:
|
||||
abort(404)
|
||||
if not _darf_sehen(dok, current_user()):
|
||||
abort(403)
|
||||
return send_from_directory(
|
||||
current_app.config["UPLOAD_FOLDER"],
|
||||
dok.datei_pfad,
|
||||
download_name=f"{dok.titel}.{_endung(dok.datei_pfad)}",
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Freigabe-Warteschlange (Admin)
|
||||
# -----------------------------------------------------------------------------
|
||||
@bp.route("/freigaben")
|
||||
@admin_required
|
||||
def freigaben():
|
||||
wartend = (
|
||||
team_query(Dokument)
|
||||
.filter_by(status=Dokument.STATUS_WARTET)
|
||||
.order_by(Dokument.erstellt_am.asc())
|
||||
.all()
|
||||
)
|
||||
return render_template("dokumente_freigaben.html", wartend=wartend)
|
||||
|
||||
|
||||
@bp.route("/<int:dok_id>/freigeben", methods=("POST",))
|
||||
@admin_required
|
||||
def freigeben(dok_id):
|
||||
dok = team_query(Dokument).filter_by(id=dok_id).first()
|
||||
if dok is None:
|
||||
abort(404)
|
||||
dok.status = Dokument.STATUS_FREIGEGEBEN
|
||||
dok.ablehnungsgrund = None
|
||||
db.session.commit()
|
||||
flash("Dokument freigegeben.")
|
||||
return redirect(url_for("dokumente.freigaben"))
|
||||
|
||||
|
||||
@bp.route("/<int:dok_id>/ablehnen", methods=("POST",))
|
||||
@admin_required
|
||||
def ablehnen(dok_id):
|
||||
dok = team_query(Dokument).filter_by(id=dok_id).first()
|
||||
if dok is None:
|
||||
abort(404)
|
||||
dok.status = Dokument.STATUS_ABGELEHNT
|
||||
dok.ablehnungsgrund = (request.form.get("grund") or "").strip() or None
|
||||
db.session.commit()
|
||||
flash("Dokument abgelehnt.")
|
||||
return redirect(url_for("dokumente.freigaben"))
|
||||
|
||||
|
||||
@bp.route("/<int:dok_id>/verschieben", methods=("POST",))
|
||||
@admin_required
|
||||
def verschieben(dok_id):
|
||||
dok = team_query(Dokument).filter_by(id=dok_id).first()
|
||||
if dok is None:
|
||||
abort(404)
|
||||
if dok.status != Dokument.STATUS_FREIGEGEBEN:
|
||||
flash("Nur freigegebene Dokumente lassen sich verschieben.")
|
||||
return redirect(url_for("dokumente.liste"))
|
||||
dok.ablage = (
|
||||
Dokument.ABLAGE_ARCHIV
|
||||
if dok.ablage == Dokument.ABLAGE_AKTUELL
|
||||
else Dokument.ABLAGE_AKTUELL
|
||||
)
|
||||
db.session.commit()
|
||||
flash("Ins Archiv verschoben." if dok.ablage == Dokument.ABLAGE_ARCHIV else "Zurück zu Aktuell.")
|
||||
return redirect(url_for("dokumente.liste"))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Zu große Datei: freundliche Meldung statt nackter 413-Seite
|
||||
# -----------------------------------------------------------------------------
|
||||
@bp.app_errorhandler(413)
|
||||
def datei_zu_gross(e):
|
||||
flash("Die Datei ist zu groß (max. 10 MB).")
|
||||
return redirect(url_for("dokumente.liste"))
|
||||
339
app/routes/planung.py
Normal file
339
app/routes/planung.py
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
"""Planung (Admin): Planungszeiträume anlegen und darin Einsätze pflegen.
|
||||
|
||||
Reine POST-Formulare, kein JavaScript. Alles admin-geschützt und team-gescoped
|
||||
(team_query). Strukturelle Änderungen an Einsätzen sind nur erlaubt, solange der
|
||||
Zeitraum in_planung ist – ein veröffentlichter Plan bleibt unangetastet.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
abort,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
url_for,
|
||||
)
|
||||
|
||||
from .. import db
|
||||
from ..auth import admin_required, current_user, login_required
|
||||
from ..models import Einsatz, Planungszeitraum, Verfuegbarkeit
|
||||
from ..team_scope import get_active_team_id, team_query
|
||||
|
||||
bp = Blueprint("planung", __name__, url_prefix="/planung")
|
||||
|
||||
# Typische Einsatzarten (CLAUDE.md). art ist im Modell ein freier String,
|
||||
# wir bieten hier eine kuratierte Auswahl an.
|
||||
ARTEN = ["Tour", "Party", "Sonderveranstaltung"]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Hilfen
|
||||
# -----------------------------------------------------------------------------
|
||||
def _parse_datum(wert):
|
||||
try:
|
||||
return datetime.strptime(wert, "%Y-%m-%d").date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_zeit(wert):
|
||||
try:
|
||||
return datetime.strptime(wert, "%H:%M").time()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _einsatz_gesperrt(einsatz):
|
||||
"""Einsätze eines veröffentlichten Zeitraums sind gesperrt.
|
||||
Einzeltermine (ohne Zeitraum) sind nie gesperrt."""
|
||||
zr = einsatz.planungszeitraum
|
||||
return zr is not None and zr.ist_veroeffentlicht
|
||||
|
||||
|
||||
def _einsatz_redirect(einsatz):
|
||||
"""Nach Bearbeiten/Löschen zurück zur passenden Liste."""
|
||||
if einsatz.planungszeitraum_id:
|
||||
return redirect(url_for("planung.detail", zeitraum_id=einsatz.planungszeitraum_id))
|
||||
return redirect(url_for("planung.einzeltermine"))
|
||||
|
||||
|
||||
def _zeitraum_holen(zeitraum_id):
|
||||
"""Zeitraum des aktiven Teams oder 404."""
|
||||
zr = team_query(Planungszeitraum).filter_by(id=zeitraum_id).first()
|
||||
if zr is None:
|
||||
abort(404)
|
||||
return zr
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Planungszeiträume: Liste + Anlegen
|
||||
# -----------------------------------------------------------------------------
|
||||
@bp.route("/", methods=("GET", "POST"))
|
||||
@admin_required
|
||||
def liste():
|
||||
if request.method == "POST":
|
||||
name = (request.form.get("name") or "").strip()
|
||||
start = _parse_datum(request.form.get("start_datum"))
|
||||
ende = _parse_datum(request.form.get("end_datum"))
|
||||
|
||||
fehler = None
|
||||
if not name:
|
||||
fehler = "Bitte einen Namen angeben."
|
||||
elif start is None or ende is None:
|
||||
fehler = "Bitte Start- und Enddatum angeben."
|
||||
elif ende <= start:
|
||||
fehler = "Das Ende muss nach dem Start liegen."
|
||||
|
||||
if fehler:
|
||||
flash(fehler)
|
||||
else:
|
||||
zr = Planungszeitraum(
|
||||
team_id=get_active_team_id(),
|
||||
name=name,
|
||||
start_datum=start,
|
||||
end_datum=ende,
|
||||
status=Planungszeitraum.STATUS_IN_PLANUNG,
|
||||
)
|
||||
db.session.add(zr)
|
||||
db.session.commit()
|
||||
flash(f"Planungszeitraum „{name}“ angelegt.")
|
||||
return redirect(url_for("planung.detail", zeitraum_id=zr.id))
|
||||
|
||||
zeitraeume = team_query(Planungszeitraum).order_by(
|
||||
Planungszeitraum.start_datum.desc()
|
||||
).all()
|
||||
return render_template("planung_liste.html", zeitraeume=zeitraeume)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Zeitraum-Detail + Einsatz anlegen
|
||||
# -----------------------------------------------------------------------------
|
||||
@bp.route("/<int:zeitraum_id>", methods=("GET", "POST"))
|
||||
@admin_required
|
||||
def detail(zeitraum_id):
|
||||
zr = _zeitraum_holen(zeitraum_id)
|
||||
|
||||
if request.method == "POST":
|
||||
if zr.ist_veroeffentlicht:
|
||||
flash("Der Zeitraum ist veröffentlicht – Einsätze sind gesperrt.")
|
||||
return redirect(url_for("planung.detail", zeitraum_id=zr.id))
|
||||
|
||||
fehler, daten = _einsatz_formular_lesen()
|
||||
if fehler:
|
||||
flash(fehler)
|
||||
else:
|
||||
einsatz = Einsatz(
|
||||
team_id=get_active_team_id(),
|
||||
planungszeitraum_id=zr.id,
|
||||
**daten,
|
||||
)
|
||||
db.session.add(einsatz)
|
||||
db.session.commit()
|
||||
flash("Einsatz angelegt.")
|
||||
return redirect(url_for("planung.detail", zeitraum_id=zr.id))
|
||||
|
||||
return render_template(
|
||||
"planung_detail.html",
|
||||
zeitraum=zr,
|
||||
einsaetze=zr.einsaetze,
|
||||
arten=ARTEN,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Einsatz bearbeiten / löschen
|
||||
# -----------------------------------------------------------------------------
|
||||
@bp.route("/einsatz/<int:einsatz_id>/bearbeiten", methods=("GET", "POST"))
|
||||
@admin_required
|
||||
def einsatz_bearbeiten(einsatz_id):
|
||||
einsatz = team_query(Einsatz).filter_by(id=einsatz_id).first()
|
||||
if einsatz is None:
|
||||
abort(404)
|
||||
|
||||
if request.method == "POST":
|
||||
if _einsatz_gesperrt(einsatz):
|
||||
flash("Der Zeitraum ist veröffentlicht – Einsätze sind gesperrt.")
|
||||
return _einsatz_redirect(einsatz)
|
||||
|
||||
fehler, daten = _einsatz_formular_lesen()
|
||||
if fehler:
|
||||
flash(fehler)
|
||||
else:
|
||||
einsatz.datum = daten["datum"]
|
||||
einsatz.start_zeit = daten["start_zeit"]
|
||||
einsatz.dauer_minuten = daten["dauer_minuten"]
|
||||
einsatz.art = daten["art"]
|
||||
einsatz.ort = daten["ort"]
|
||||
db.session.commit()
|
||||
flash("Einsatz aktualisiert.")
|
||||
return _einsatz_redirect(einsatz)
|
||||
|
||||
return render_template(
|
||||
"einsatz_bearbeiten.html", zeitraum=einsatz.planungszeitraum, einsatz=einsatz, arten=ARTEN
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/einsatz/<int:einsatz_id>/loeschen", methods=("POST",))
|
||||
@admin_required
|
||||
def einsatz_loeschen(einsatz_id):
|
||||
einsatz = team_query(Einsatz).filter_by(id=einsatz_id).first()
|
||||
if einsatz is None:
|
||||
abort(404)
|
||||
|
||||
if _einsatz_gesperrt(einsatz):
|
||||
flash("Der Zeitraum ist veröffentlicht – Einsätze sind gesperrt.")
|
||||
return _einsatz_redirect(einsatz)
|
||||
|
||||
ziel = _einsatz_redirect(einsatz)
|
||||
db.session.delete(einsatz)
|
||||
db.session.commit()
|
||||
flash("Einsatz gelöscht.")
|
||||
return ziel
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Einzeltermine (Admin): eigenständige Einsätze ohne Planungszeitraum
|
||||
# -----------------------------------------------------------------------------
|
||||
@bp.route("/einzeltermine", methods=("GET", "POST"))
|
||||
@admin_required
|
||||
def einzeltermine():
|
||||
if request.method == "POST":
|
||||
fehler, daten = _einsatz_formular_lesen()
|
||||
if fehler:
|
||||
flash(fehler)
|
||||
else:
|
||||
einsatz = Einsatz(
|
||||
team_id=get_active_team_id(),
|
||||
planungszeitraum_id=None,
|
||||
**daten,
|
||||
)
|
||||
db.session.add(einsatz)
|
||||
db.session.commit()
|
||||
flash("Einzeltermin angelegt.")
|
||||
return redirect(url_for("planung.einzeltermine"))
|
||||
|
||||
termine = (
|
||||
team_query(Einsatz)
|
||||
.filter_by(planungszeitraum_id=None)
|
||||
.order_by(Einsatz.datum, Einsatz.start_zeit)
|
||||
.all()
|
||||
)
|
||||
return render_template("einzeltermine.html", termine=termine, arten=ARTEN)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Verfügbarkeit melden (Ehrenamtliche) – kann / kann_nicht je Einsatz
|
||||
# -----------------------------------------------------------------------------
|
||||
def _laufender_zeitraum():
|
||||
"""Aktueller Zeitraum in Planung (neuester nach Startdatum) oder None."""
|
||||
return (
|
||||
team_query(Planungszeitraum)
|
||||
.filter_by(status=Planungszeitraum.STATUS_IN_PLANUNG)
|
||||
.order_by(Planungszeitraum.start_datum.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/verfuegbarkeit")
|
||||
@login_required
|
||||
def verfuegbarkeit():
|
||||
zr = _laufender_zeitraum()
|
||||
|
||||
# Meldbar sind: Einzeltermine (ohne Zeitraum) + die Einsätze des laufenden Zeitraums.
|
||||
einzeltermine = (
|
||||
team_query(Einsatz)
|
||||
.filter_by(planungszeitraum_id=None)
|
||||
.order_by(Einsatz.datum, Einsatz.start_zeit)
|
||||
.all()
|
||||
)
|
||||
einsaetze = list(einzeltermine) + (list(zr.einsaetze) if zr else [])
|
||||
|
||||
eigene = {}
|
||||
if einsaetze:
|
||||
ids = [e.id for e in einsaetze]
|
||||
rows = (
|
||||
team_query(Verfuegbarkeit)
|
||||
.filter_by(user_id=current_user().id)
|
||||
.filter(Verfuegbarkeit.einsatz_id.in_(ids))
|
||||
.all()
|
||||
)
|
||||
eigene = {r.einsatz_id: r.wert for r in rows}
|
||||
|
||||
return render_template(
|
||||
"verfuegbarkeit.html",
|
||||
zeitraum=zr,
|
||||
einsaetze=einsaetze,
|
||||
eigene=eigene,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/verfuegbarkeit/<int:einsatz_id>", methods=("POST",))
|
||||
@login_required
|
||||
def verfuegbarkeit_setzen(einsatz_id):
|
||||
einsatz = team_query(Einsatz).filter_by(id=einsatz_id).first()
|
||||
if einsatz is None:
|
||||
abort(404)
|
||||
|
||||
if _einsatz_gesperrt(einsatz):
|
||||
flash("Der Zeitraum ist veröffentlicht – Verfügbarkeit ist gesperrt.")
|
||||
return redirect(url_for("planung.verfuegbarkeit"))
|
||||
|
||||
wert = request.form.get("wert")
|
||||
if wert not in (Verfuegbarkeit.WERT_KANN, Verfuegbarkeit.WERT_KANN_NICHT):
|
||||
flash("Ungültige Auswahl.")
|
||||
return redirect(url_for("planung.verfuegbarkeit"))
|
||||
|
||||
eintrag = team_query(Verfuegbarkeit).filter_by(
|
||||
user_id=current_user().id, einsatz_id=einsatz.id
|
||||
).first()
|
||||
if eintrag is None:
|
||||
eintrag = Verfuegbarkeit(
|
||||
team_id=get_active_team_id(),
|
||||
user_id=current_user().id,
|
||||
einsatz_id=einsatz.id,
|
||||
wert=wert,
|
||||
)
|
||||
db.session.add(eintrag)
|
||||
else:
|
||||
eintrag.wert = wert
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("planung.verfuegbarkeit"))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Gemeinsames Einlesen + Validieren des Einsatz-Formulars
|
||||
# -----------------------------------------------------------------------------
|
||||
def _einsatz_formular_lesen():
|
||||
"""Liest die Einsatz-Felder aus request.form.
|
||||
|
||||
Rückgabe: (fehler_text_oder_None, daten_dict).
|
||||
"""
|
||||
datum = _parse_datum(request.form.get("datum"))
|
||||
start_zeit = _parse_zeit(request.form.get("start_zeit"))
|
||||
dauer_roh = (request.form.get("dauer_minuten") or "").strip()
|
||||
art = (request.form.get("art") or "").strip()
|
||||
ort = (request.form.get("ort") or "").strip() or None
|
||||
|
||||
if datum is None:
|
||||
return "Bitte ein gültiges Datum angeben.", None
|
||||
if start_zeit is None:
|
||||
return "Bitte eine Startzeit angeben.", None
|
||||
dauer_minuten = None
|
||||
if dauer_roh:
|
||||
if not dauer_roh.isdigit() or int(dauer_roh) <= 0:
|
||||
return "Bitte eine gültige Dauer wählen.", None
|
||||
dauer_minuten = int(dauer_roh)
|
||||
if art not in ARTEN:
|
||||
return "Bitte eine Art auswählen.", None
|
||||
|
||||
return None, {
|
||||
"datum": datum,
|
||||
"start_zeit": start_zeit,
|
||||
"dauer_minuten": dauer_minuten,
|
||||
"art": art,
|
||||
"ort": ort,
|
||||
}
|
||||
110
app/routes/profil.py
Normal file
110
app/routes/profil.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"""Profil: Anzeigename, Profilbild und optionale freiwillige Angaben.
|
||||
|
||||
Nichts außer dem (ohnehin vorhandenen) Anzeigenamen ist Pflicht. Die freiwilligen
|
||||
Felder sind über die Liste FREIWILLIGE_FELDER definiert und werden als JSON in
|
||||
user.freiwillige_angaben abgelegt – ein neues Feld ist damit eine Zeile mehr in
|
||||
der Liste, ohne DB-Migration. Läuft in beiden Shells (shell_base je Rolle).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
abort,
|
||||
current_app,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_from_directory,
|
||||
url_for,
|
||||
)
|
||||
|
||||
from .. import db
|
||||
from ..auth import current_user, login_required
|
||||
from ..models import User
|
||||
from ..team_scope import team_query
|
||||
|
||||
bp = Blueprint("profil", __name__, url_prefix="/profil")
|
||||
|
||||
# Erlaubte Bildtypen fürs Profilbild (serverseitig geprüft).
|
||||
BILD_ENDUNGEN = {"png", "jpg", "jpeg", "gif", "webp"}
|
||||
|
||||
# Optionale freiwillige Angaben. Neues Feld = ein Eintrag mehr; "mehrzeilig" für
|
||||
# Textarea. Werte landen als JSON-Objekt in user.freiwillige_angaben.
|
||||
FREIWILLIGE_FELDER = [
|
||||
{"key": "pronomen", "label": "Pronomen", "placeholder": "z. B. sie/ihr, er/ihm"},
|
||||
{"key": "ueber_mich", "label": "Über mich", "placeholder": "Ein, zwei Sätze – ganz freiwillig", "mehrzeilig": True},
|
||||
]
|
||||
_GUELTIGE_KEYS = {f["key"] for f in FREIWILLIGE_FELDER}
|
||||
|
||||
|
||||
def _endung(dateiname):
|
||||
if not dateiname or "." not in dateiname:
|
||||
return ""
|
||||
return dateiname.rsplit(".", 1)[1].lower()
|
||||
|
||||
|
||||
def _angaben_lesen(user):
|
||||
"""freiwillige_angaben als Dict (robust gegen leer/kaputt)."""
|
||||
if not user.freiwillige_angaben:
|
||||
return {}
|
||||
try:
|
||||
daten = json.loads(user.freiwillige_angaben)
|
||||
return daten if isinstance(daten, dict) else {}
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
@bp.route("/", methods=("GET", "POST"))
|
||||
@login_required
|
||||
def index():
|
||||
user = current_user()
|
||||
|
||||
if request.method == "POST":
|
||||
anzeigename = (request.form.get("anzeigename") or "").strip()
|
||||
if not anzeigename:
|
||||
flash("Bitte einen Anzeigenamen angeben.")
|
||||
return redirect(url_for("profil.index"))
|
||||
user.anzeigename = anzeigename
|
||||
|
||||
# Freiwillige Felder: nur bekannte Keys, leere Werte fallen raus.
|
||||
angaben = {}
|
||||
for feld in FREIWILLIGE_FELDER:
|
||||
wert = (request.form.get(feld["key"]) or "").strip()
|
||||
if wert:
|
||||
angaben[feld["key"]] = wert
|
||||
user.freiwillige_angaben = json.dumps(angaben, ensure_ascii=False) if angaben else None
|
||||
|
||||
# Profilbild optional. Nur bei tatsächlichem Upload anfassen.
|
||||
datei = request.files.get("profilbild")
|
||||
if datei and datei.filename:
|
||||
endung = _endung(datei.filename)
|
||||
if endung not in BILD_ENDUNGEN:
|
||||
flash("Dieses Bildformat ist nicht erlaubt. Erlaubt: PNG, JPG, GIF, WebP.")
|
||||
return redirect(url_for("profil.index"))
|
||||
name = f"profil_{uuid.uuid4().hex}.{endung}"
|
||||
datei.save(os.path.join(current_app.config["UPLOAD_FOLDER"], name))
|
||||
user.profilbild_pfad = name
|
||||
|
||||
db.session.commit()
|
||||
flash("Profil gespeichert.")
|
||||
return redirect(url_for("profil.index"))
|
||||
|
||||
return render_template(
|
||||
"profil.html",
|
||||
felder=FREIWILLIGE_FELDER,
|
||||
angaben=_angaben_lesen(user),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/bild/<int:user_id>")
|
||||
@login_required
|
||||
def bild(user_id):
|
||||
"""Profilbild eines Team-Mitglieds ausliefern (team-gescoped)."""
|
||||
user = team_query(User).filter_by(id=user_id).first()
|
||||
if user is None or not user.profilbild_pfad:
|
||||
abort(404)
|
||||
return send_from_directory(current_app.config["UPLOAD_FOLDER"], user.profilbild_pfad)
|
||||
397
app/static/components-admin.css
Normal file
397
app/static/components-admin.css
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
/* =============================================================================
|
||||
CheckPoint Ehrenamt · components-admin.css
|
||||
Komponenten der ADMIN-Shell (Sidebar links, mobil Off-Canvas). Nutzt
|
||||
AUSSCHLIESSLICH --cp-admin-*-Tokens aus tokens.css – niemals --cp-user-*.
|
||||
Geteilte Klassennamen mit der User-Shell (.cp-card, .cp-btn, …), aber eigenes
|
||||
Aussehen: jede Shell lädt nur ihr Komponenten-CSS, daher keine Kollision.
|
||||
Desktop-first; unter 900px kollabiert die Sidebar zum Off-Canvas-Overlay.
|
||||
============================================================================= */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after { box-sizing: border-box; }
|
||||
|
||||
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--cp-admin-bg);
|
||||
color: var(--cp-admin-text);
|
||||
font-family: var(--cp-admin-font-ui);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--cp-admin-teal);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Layout: Sidebar + Hauptbereich
|
||||
============================================================================= */
|
||||
.cp-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: var(--cp-admin-sidebar-width);
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cp-admin-space-2);
|
||||
padding: var(--cp-admin-space-4);
|
||||
overflow-y: auto;
|
||||
background: var(--cp-admin-sidebar-bg);
|
||||
color: var(--cp-admin-sidebar-text);
|
||||
}
|
||||
|
||||
.cp-sidebar__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--cp-admin-space-2);
|
||||
font-family: var(--cp-admin-font-display);
|
||||
font-size: 1.3rem;
|
||||
color: var(--cp-admin-on-brand);
|
||||
padding: var(--cp-admin-space-3) var(--cp-admin-space-2) var(--cp-admin-space-5);
|
||||
}
|
||||
.cp-sidebar__bell { color: var(--cp-admin-sidebar-text); flex: none; }
|
||||
.cp-admin-topbar__bell { margin-left: auto; color: var(--cp-admin-on-brand); }
|
||||
|
||||
.cp-sidebar__nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cp-admin-space-1);
|
||||
}
|
||||
|
||||
.cp-navlink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cp-admin-space-3);
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0 var(--cp-admin-space-3);
|
||||
border: 0;
|
||||
border-radius: var(--cp-admin-radius-md);
|
||||
background: none;
|
||||
color: var(--cp-admin-sidebar-text-muted);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cp-navlink__icon { flex: none; color: var(--cp-admin-sidebar-icon); }
|
||||
|
||||
.cp-navlink:hover {
|
||||
background: var(--cp-admin-sidebar-hover);
|
||||
color: var(--cp-admin-sidebar-text);
|
||||
}
|
||||
|
||||
.cp-navlink[aria-current="page"] {
|
||||
background: var(--cp-admin-sidebar-active-bg);
|
||||
color: var(--cp-admin-sidebar-active-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cp-sidebar__footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cp-admin-space-1);
|
||||
padding-top: var(--cp-admin-space-4);
|
||||
border-top: 1px solid var(--cp-admin-brand-navy-soft);
|
||||
}
|
||||
|
||||
.cp-sidebar__footer form { margin: 0; }
|
||||
|
||||
/* Theme-Umschalter im (dunklen) Sidebar-Fuß. */
|
||||
.cp-iconbtn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 0;
|
||||
border-radius: var(--cp-admin-radius-md);
|
||||
background: none;
|
||||
color: var(--cp-admin-sidebar-text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.cp-topbar__theme { margin: 0; display: inline-flex; }
|
||||
|
||||
/* Zähler-Badge auf der Glocke (ungelesene Benachrichtigungen). */
|
||||
.cp-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--cp-admin-red);
|
||||
color: var(--cp-admin-on-brand);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
/* Ungelesene Benachrichtigung: Akzentstreifen (zusätzlich zum „Neu"-Tag). */
|
||||
.cp-card--ungelesen { border-left: 3px solid var(--cp-admin-teal); }
|
||||
|
||||
/* Mobile-Topbar (nur unter dem Breakpoint sichtbar). */
|
||||
.cp-admin-topbar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
z-index: 20;
|
||||
align-items: center;
|
||||
gap: var(--cp-admin-space-3);
|
||||
padding: 0 var(--cp-admin-space-4);
|
||||
background: var(--cp-admin-sidebar-bg);
|
||||
color: var(--cp-admin-on-brand);
|
||||
}
|
||||
|
||||
.cp-admin-topbar__title {
|
||||
font-family: var(--cp-admin-font-display);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.cp-hamburger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--cp-admin-on-brand);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cp-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 25;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.cp-overlay[hidden] { display: none; }
|
||||
|
||||
.cp-admin-main {
|
||||
margin-left: var(--cp-admin-sidebar-width);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.cp-page {
|
||||
width: 100%;
|
||||
max-width: 1080px;
|
||||
margin-inline: auto;
|
||||
padding: var(--cp-admin-space-6);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Off-Canvas unter dem Breakpoint
|
||||
============================================================================= */
|
||||
@media (max-width: 899px) {
|
||||
.cp-admin-topbar { display: flex; }
|
||||
|
||||
.cp-sidebar {
|
||||
width: min(86vw, 320px);
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--cp-admin-transition);
|
||||
box-shadow: var(--cp-admin-shadow-soft);
|
||||
}
|
||||
body.cp-sidebar-open .cp-sidebar { transform: translateX(0); }
|
||||
/* Hintergrund nicht mitscrollen, solange das Overlay offen ist. */
|
||||
body.cp-sidebar-open { overflow: hidden; }
|
||||
|
||||
.cp-admin-main {
|
||||
margin-left: 0;
|
||||
padding-top: 56px;
|
||||
}
|
||||
.cp-page { padding: var(--cp-admin-space-4); }
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Typografie
|
||||
============================================================================= */
|
||||
h1 {
|
||||
margin: 0 0 var(--cp-admin-space-4);
|
||||
font-family: var(--cp-admin-font-display);
|
||||
font-weight: 400;
|
||||
font-size: 1.7rem;
|
||||
line-height: 1.15;
|
||||
color: var(--cp-admin-heading);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: var(--cp-admin-space-5) 0 var(--cp-admin-space-3);
|
||||
font-weight: 700;
|
||||
font-size: 1.2rem;
|
||||
color: var(--cp-admin-heading);
|
||||
}
|
||||
|
||||
.cp-muted { color: var(--cp-admin-muted); }
|
||||
|
||||
/* Profilbild auf der Profilseite. */
|
||||
.cp-profilbild {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.cp-profilbild--leer {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--cp-admin-teal-soft);
|
||||
color: var(--cp-admin-teal-dark);
|
||||
font-weight: 700;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Formulare
|
||||
============================================================================= */
|
||||
.cp-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cp-admin-space-4);
|
||||
margin-block: var(--cp-admin-space-5);
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.cp-field { display: flex; flex-direction: column; gap: var(--cp-admin-space-2); }
|
||||
.cp-field label { font-weight: 600; color: var(--cp-admin-text); }
|
||||
|
||||
.cp-field input,
|
||||
.cp-field select,
|
||||
.cp-field textarea {
|
||||
min-height: 44px;
|
||||
padding: 0 var(--cp-admin-space-3);
|
||||
border: 1px solid var(--cp-admin-line);
|
||||
border-radius: var(--cp-admin-radius-sm);
|
||||
background: var(--cp-admin-surface);
|
||||
color: var(--cp-admin-text);
|
||||
font: inherit;
|
||||
}
|
||||
.cp-field textarea { padding: var(--cp-admin-space-3); min-height: 88px; resize: vertical; }
|
||||
|
||||
/* =============================================================================
|
||||
Buttons
|
||||
============================================================================= */
|
||||
.cp-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 0 var(--cp-admin-space-5);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--cp-admin-radius-md);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cp-btn--primary { background: var(--cp-admin-teal-ui); color: var(--cp-admin-on-brand); box-shadow: var(--cp-admin-shadow-action); }
|
||||
.cp-btn--urgent { background: var(--cp-admin-red); color: var(--cp-admin-on-brand); }
|
||||
.cp-btn--ghost { background: var(--cp-admin-surface); color: var(--cp-admin-text); border-color: var(--cp-admin-line); }
|
||||
|
||||
/* =============================================================================
|
||||
Flash, Liste, Tags, Karten, Aktionen
|
||||
============================================================================= */
|
||||
.cp-flash {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--cp-admin-space-4);
|
||||
padding: var(--cp-admin-space-3) var(--cp-admin-space-4);
|
||||
border: 1px solid var(--cp-admin-line);
|
||||
border-radius: var(--cp-admin-radius-md);
|
||||
background: var(--cp-admin-surface-soft);
|
||||
color: var(--cp-admin-text);
|
||||
}
|
||||
|
||||
.cp-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cp-admin-space-3);
|
||||
}
|
||||
|
||||
.cp-tag {
|
||||
display: inline-block;
|
||||
margin-left: var(--cp-admin-space-2);
|
||||
padding: 2px var(--cp-admin-space-2);
|
||||
border-radius: var(--cp-admin-radius-sm);
|
||||
background: var(--cp-admin-status-neutral-bg);
|
||||
color: var(--cp-admin-status-neutral-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cp-tag--urgent { background: var(--cp-admin-status-error-bg); color: var(--cp-admin-status-error-text); margin-left: 0; }
|
||||
|
||||
.cp-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cp-admin-space-2);
|
||||
padding: var(--cp-admin-space-5);
|
||||
border: 1px solid var(--cp-admin-line);
|
||||
border-radius: var(--cp-admin-radius-lg);
|
||||
background: var(--cp-admin-surface);
|
||||
box-shadow: var(--cp-admin-shadow-soft);
|
||||
}
|
||||
.cp-card--leer { border-style: dashed; background: var(--cp-admin-surface-soft); box-shadow: none; }
|
||||
.cp-card a { color: var(--cp-admin-teal-dark); text-decoration: none; font-weight: 600; }
|
||||
|
||||
.cp-actions { display: flex; flex-wrap: wrap; gap: var(--cp-admin-space-2); margin-top: var(--cp-admin-space-2); }
|
||||
.cp-actions form { margin: 0; }
|
||||
|
||||
/* =============================================================================
|
||||
Tabellen (Kopf in Navy, Statusspalten mit Text – nie nur Farbe)
|
||||
============================================================================= */
|
||||
.cp-table { width: 100%; border-collapse: collapse; background: var(--cp-admin-surface); border-radius: var(--cp-admin-radius-md); overflow: hidden; }
|
||||
.cp-table th { padding: var(--cp-admin-space-3); background: var(--cp-admin-brand-navy); color: var(--cp-admin-on-brand); text-align: left; font-weight: 700; }
|
||||
.cp-table td { padding: var(--cp-admin-space-3); border-top: 1px solid var(--cp-admin-line-soft); }
|
||||
|
||||
/* =============================================================================
|
||||
Zwei-Knopf-Umschalter (kann / kann_nicht)
|
||||
============================================================================= */
|
||||
.cp-toggle { display: flex; gap: var(--cp-admin-space-2); margin: var(--cp-admin-space-2) 0 0; }
|
||||
.cp-toggle__btn {
|
||||
flex: 1;
|
||||
min-height: 44px;
|
||||
padding: 0 var(--cp-admin-space-3);
|
||||
border: 1px solid var(--cp-admin-line);
|
||||
border-radius: var(--cp-admin-radius-sm);
|
||||
background: var(--cp-admin-surface);
|
||||
color: var(--cp-admin-text);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cp-toggle__btn.is-active {
|
||||
background: var(--cp-admin-teal-soft);
|
||||
border-color: var(--cp-admin-teal);
|
||||
color: var(--cp-admin-teal-dark);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Chat
|
||||
============================================================================= */
|
||||
.cp-chat { list-style: none; margin: 0 0 var(--cp-admin-space-4); padding: 0; display: flex; flex-direction: column; gap: var(--cp-admin-space-2); }
|
||||
.cp-msg { padding: var(--cp-admin-space-2) var(--cp-admin-space-3); border: 1px solid var(--cp-admin-line); border-radius: var(--cp-admin-radius-md); background: var(--cp-admin-surface); }
|
||||
.cp-msg--eigen { background: var(--cp-admin-teal-soft); border-color: transparent; align-self: flex-end; }
|
||||
.cp-msg__meta { font-size: 0.78rem; color: var(--cp-admin-muted); margin-bottom: 2px; }
|
||||
.cp-msg__text { white-space: pre-wrap; word-break: break-word; }
|
||||
417
app/static/components.css
Normal file
417
app/static/components.css
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
/* =============================================================================
|
||||
CheckPoint Ehrenamt · components.css
|
||||
Komponenten- und Grund-CSS. Baut ausschließlich auf den Tokens aus tokens.css
|
||||
auf – nirgends feste Weiß-/Schwarzwerte, damit der Dunkelmodus greift.
|
||||
|
||||
Session 1: nur Reset + Grundtypografie. Topbar, Bottom-Nav, Karten und der
|
||||
.cp-page-Verlauf folgen in Session 4 (dort konsequent auf Tokens umgestellt).
|
||||
============================================================================= */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--cp-user-bg);
|
||||
color: var(--cp-user-text);
|
||||
font-family: var(--cp-user-font);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Sichtbarer Fokus ist Pflicht (Barrierearmut, siehe CLAUDE.md). */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--cp-user-teal-dark);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Mobile-first Seitencontainer (App-Breite 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 <button> nutzbar (Theme-Umschalter): Default-Chrome entfernen. */
|
||||
background: none;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Zähler-Badge auf der Glocke (ungelesene Benachrichtigungen). */
|
||||
.cp-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: var(--cp-user-radius-pill);
|
||||
background: var(--cp-user-red);
|
||||
color: var(--cp-user-on-brand);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
/* Ungelesene Benachrichtigung: Akzentstreifen (zusätzlich zum „Neu"-Tag). */
|
||||
.cp-card--ungelesen { border-left: 3px solid var(--cp-user-teal); }
|
||||
|
||||
/* Theme-Umschalter ist ein Formular in der Topbar – kein Eigenabstand. */
|
||||
.cp-topbar__theme {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.cp-avatar {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--cp-user-radius-pill);
|
||||
background: var(--cp-user-teal-soft);
|
||||
color: var(--cp-user-teal-dark);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cp-avatar__img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
/* Profilbild auf der Profilseite. */
|
||||
.cp-profilbild {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: var(--cp-user-radius-pill);
|
||||
object-fit: cover;
|
||||
}
|
||||
.cp-profilbild--leer {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--cp-user-teal-soft);
|
||||
color: var(--cp-user-teal-dark);
|
||||
font-weight: 700;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Bottom-Navigation (fix unten) – 4 Punkte, Tokens statt festem Weiß.
|
||||
============================================================================= */
|
||||
.cp-bottomnav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
background: var(--cp-user-surface);
|
||||
border-top: 1px solid var(--cp-user-line);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.cp-navitem {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-height: 56px;
|
||||
padding: var(--cp-user-space-2) 0;
|
||||
color: var(--cp-user-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Aktiver Punkt: nicht nur über Farbe – auch fett + Markenton. */
|
||||
.cp-navitem[aria-current="page"] {
|
||||
color: var(--cp-user-teal-dark);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--cp-user-space-3);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cp-muted {
|
||||
color: var(--cp-user-muted);
|
||||
}
|
||||
|
||||
/* --- Formulare (Labels immer sichtbar, Felder >= 44px) -------------------- */
|
||||
.cp-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cp-user-space-4);
|
||||
margin-block: var(--cp-user-space-5);
|
||||
}
|
||||
|
||||
.cp-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cp-user-space-2);
|
||||
}
|
||||
|
||||
.cp-field label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cp-field input,
|
||||
.cp-field select,
|
||||
.cp-field textarea {
|
||||
min-height: 44px;
|
||||
padding: 0 var(--cp-user-space-3);
|
||||
border: 1px solid var(--cp-user-line);
|
||||
border-radius: var(--cp-user-radius-md);
|
||||
background: var(--cp-user-surface);
|
||||
color: var(--cp-user-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.cp-field textarea {
|
||||
padding: var(--cp-user-space-3);
|
||||
min-height: 88px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* --- Buttons (Primär = Türkis/weiß · Ghost = Fläche mit feinem Rahmen) ---- */
|
||||
.cp-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 0 var(--cp-user-space-5);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--cp-user-radius-md);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cp-btn--primary {
|
||||
background: var(--cp-user-teal);
|
||||
color: var(--cp-user-on-brand);
|
||||
}
|
||||
|
||||
.cp-btn--urgent {
|
||||
background: var(--cp-user-red);
|
||||
color: var(--cp-user-on-brand);
|
||||
}
|
||||
|
||||
.cp-btn--ghost {
|
||||
background: var(--cp-user-surface);
|
||||
color: var(--cp-user-text);
|
||||
border-color: var(--cp-user-line);
|
||||
}
|
||||
|
||||
/* --- Flash-Hinweise ------------------------------------------------------ */
|
||||
.cp-flash {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--cp-user-space-4);
|
||||
padding: var(--cp-user-space-3) var(--cp-user-space-4);
|
||||
border-radius: var(--cp-user-radius-md);
|
||||
background: var(--cp-user-surface-alt);
|
||||
color: var(--cp-user-text);
|
||||
}
|
||||
|
||||
/* --- Liste & Tag --------------------------------------------------------- */
|
||||
.cp-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cp-user-space-2);
|
||||
}
|
||||
|
||||
.cp-tag {
|
||||
display: inline-block;
|
||||
margin-left: var(--cp-user-space-2);
|
||||
padding: 0 var(--cp-user-space-2);
|
||||
border-radius: var(--cp-user-radius-pill);
|
||||
background: var(--cp-user-status-neutral-bg);
|
||||
color: var(--cp-user-status-neutral-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* --- Karte (Radius 12px, Innenabstand 16px, keine Verschachtelung) ------- */
|
||||
.cp-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cp-user-space-1);
|
||||
padding: var(--cp-user-space-4);
|
||||
border: 1px solid var(--cp-user-line);
|
||||
border-radius: var(--cp-user-radius-lg);
|
||||
background: var(--cp-user-surface);
|
||||
}
|
||||
|
||||
/* Leerer Platz (noch nicht besetzt): gestrichelt, abgesetzt. */
|
||||
.cp-card--leer {
|
||||
border-style: dashed;
|
||||
background: var(--cp-user-surface-alt);
|
||||
}
|
||||
|
||||
/* Dringender Status (Vertretung/unterbesetzt) – Rot statt Neutral. */
|
||||
.cp-tag--urgent {
|
||||
background: var(--cp-user-status-urgent-bg);
|
||||
color: var(--cp-user-status-urgent-text);
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.cp-card a {
|
||||
color: var(--cp-user-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* --- Aktionsreihe (Buttons/Formulare nebeneinander) ---------------------- */
|
||||
.cp-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--cp-user-space-2);
|
||||
margin-top: var(--cp-user-space-2);
|
||||
}
|
||||
|
||||
.cp-actions form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* --- Zwei-Knopf-Umschalter (kann / kann_nicht) --------------------------- */
|
||||
.cp-toggle {
|
||||
display: flex;
|
||||
gap: var(--cp-user-space-2);
|
||||
margin: var(--cp-user-space-2) 0 0;
|
||||
}
|
||||
|
||||
.cp-toggle__btn {
|
||||
flex: 1;
|
||||
min-height: 44px;
|
||||
padding: 0 var(--cp-user-space-3);
|
||||
border: 1px solid var(--cp-user-line);
|
||||
border-radius: var(--cp-user-radius-md);
|
||||
background: var(--cp-user-surface);
|
||||
color: var(--cp-user-text);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Aktiver Zustand: Markenton + fett + „✓" im Text (nicht nur Farbe). */
|
||||
.cp-toggle__btn.is-active {
|
||||
background: var(--cp-user-teal-soft);
|
||||
border-color: var(--cp-user-teal-dark);
|
||||
color: var(--cp-user-teal-dark);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* --- Chat (zwei Kanäle, Text-only) --------------------------------------- */
|
||||
.cp-chat {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--cp-user-space-4);
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cp-user-space-2);
|
||||
}
|
||||
|
||||
.cp-msg {
|
||||
padding: var(--cp-user-space-2) var(--cp-user-space-3);
|
||||
border: 1px solid var(--cp-user-line);
|
||||
border-radius: var(--cp-user-radius-md);
|
||||
background: var(--cp-user-surface);
|
||||
}
|
||||
|
||||
/* Eigene Nachrichten abgesetzt (nicht nur Farbe: auch rechtsbündig). */
|
||||
.cp-msg--eigen {
|
||||
background: var(--cp-user-teal-soft);
|
||||
border-color: transparent;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.cp-msg__meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--cp-user-muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.cp-msg__text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
93
app/static/fonts.css
Normal file
93
app/static/fonts.css
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/* CheckPoint Ehrenamt · fonts.css
|
||||
Selbst gehostete Schriften (SIL Open Font License) – kein externer
|
||||
Request an Google (DSGVO). Subset: latin (deckt deutsche Umlaute/ß ab).
|
||||
Erzeugt aus den Google-Fonts-CSS2-Antworten, src auf lokale Dateien umgebogen. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(fonts/dm-sans-400.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(fonts/dm-sans-500.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(fonts/dm-sans-600.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(fonts/dm-sans-700.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url(fonts/dm-sans-800.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Roboto Condensed';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(fonts/roboto-condensed-400.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Roboto Condensed';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(fonts/roboto-condensed-500.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Roboto Condensed';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(fonts/roboto-condensed-700.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(fonts/roboto-mono-400.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(fonts/roboto-mono-500.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Alfa Slab One';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(fonts/alfa-slab-one-400.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
BIN
app/static/fonts/alfa-slab-one-400.woff2
Normal file
BIN
app/static/fonts/alfa-slab-one-400.woff2
Normal file
Binary file not shown.
BIN
app/static/fonts/dm-sans-400.woff2
Normal file
BIN
app/static/fonts/dm-sans-400.woff2
Normal file
Binary file not shown.
BIN
app/static/fonts/dm-sans-500.woff2
Normal file
BIN
app/static/fonts/dm-sans-500.woff2
Normal file
Binary file not shown.
BIN
app/static/fonts/dm-sans-600.woff2
Normal file
BIN
app/static/fonts/dm-sans-600.woff2
Normal file
Binary file not shown.
BIN
app/static/fonts/dm-sans-700.woff2
Normal file
BIN
app/static/fonts/dm-sans-700.woff2
Normal file
Binary file not shown.
BIN
app/static/fonts/dm-sans-800.woff2
Normal file
BIN
app/static/fonts/dm-sans-800.woff2
Normal file
Binary file not shown.
BIN
app/static/fonts/roboto-condensed-400.woff2
Normal file
BIN
app/static/fonts/roboto-condensed-400.woff2
Normal file
Binary file not shown.
BIN
app/static/fonts/roboto-condensed-500.woff2
Normal file
BIN
app/static/fonts/roboto-condensed-500.woff2
Normal file
Binary file not shown.
BIN
app/static/fonts/roboto-condensed-700.woff2
Normal file
BIN
app/static/fonts/roboto-condensed-700.woff2
Normal file
Binary file not shown.
BIN
app/static/fonts/roboto-mono-400.woff2
Normal file
BIN
app/static/fonts/roboto-mono-400.woff2
Normal file
Binary file not shown.
BIN
app/static/fonts/roboto-mono-500.woff2
Normal file
BIN
app/static/fonts/roboto-mono-500.woff2
Normal file
Binary file not shown.
24
app/static/sidebar.js
Normal file
24
app/static/sidebar.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/* Admin-Sidebar: mobiles Off-Canvas-Verhalten (zweite v1-JS-Ausnahme, CLAUDE.md).
|
||||
Togglet die Klasse cp-sidebar-open am <body>; das CSS schiebt die Sidebar ein.
|
||||
Schließt bei Klick auf das Overlay oder auf einen Menüpunkt. Kein Framework. */
|
||||
(function () {
|
||||
var oeffner = document.querySelector("[data-sidebar-open]");
|
||||
var overlay = document.querySelector("[data-sidebar-close]");
|
||||
var sidebar = document.getElementById("cp-sidebar");
|
||||
if (!oeffner || !sidebar) return;
|
||||
|
||||
function setOffen(offen) {
|
||||
document.body.classList.toggle("cp-sidebar-open", offen);
|
||||
oeffner.setAttribute("aria-expanded", offen ? "true" : "false");
|
||||
if (overlay) overlay.hidden = !offen;
|
||||
}
|
||||
|
||||
oeffner.addEventListener("click", function () { setOffen(true); });
|
||||
if (overlay) overlay.addEventListener("click", function () { setOffen(false); });
|
||||
sidebar.addEventListener("click", function (e) {
|
||||
if (e.target.closest("a")) setOffen(false);
|
||||
});
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape") setOffen(false);
|
||||
});
|
||||
})();
|
||||
340
app/static/tokens.css
Normal file
340
app/static/tokens.css
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
/* =============================================================================
|
||||
CheckPoint Ehrenamt · tokens.css
|
||||
Mandantenfähiges Token-System mit Hell- und Dunkelmodus.
|
||||
ZWEI ROLLEN-THEMES IN EINER DATEI: User (Mobile, Bottom-Nav) und
|
||||
Admin (Desktop-Sidebar, mobil per Off-Canvas). Jedes Theme ist für sich
|
||||
mandantenfähig — ein neues Team tauscht beide Brand-Blöcke.
|
||||
|
||||
AUFBAU (pro Theme dieselben drei Schichten):
|
||||
1) BRAND – pro Team austauschbar. Ein zweites Team ändert NUR diese Werte.
|
||||
2) BASE – theme-unabhängig: Abstände, Radien, Schrift, Transitions.
|
||||
3) SEMANTIK – hell + dunkel in einem, gelöst über die CSS-Funktion light-dark().
|
||||
|
||||
NAMENSRAUM:
|
||||
--cp-user-* → Token für die Ehrenamtlichen-Oberfläche (Bottom-Nav-Shell)
|
||||
--cp-admin-* → Token für die Admin-Oberfläche (Sidebar-Shell)
|
||||
Komponenten-CSS wählt je Shell den passenden Namensraum. Es gibt keine
|
||||
gemeinsamen Farb-Tokens mehr ohne Präfix — das verhindert versehentliches
|
||||
Mischen der beiden Looks.
|
||||
|
||||
THEME-STEUERUNG (kein JavaScript nötig):
|
||||
- Standard folgt der Systemeinstellung (prefers-color-scheme) automatisch.
|
||||
- Optionaler manueller Override später: <html data-theme="dark"> bzw. "light".
|
||||
|
||||
Hinweis: Diese Datei definiert nur Variablen. Komponenten-CSS muss diese
|
||||
Tokens verwenden (z. B. background: var(--cp-user-surface)), damit der
|
||||
Dunkelmodus wirklich greift – nirgends feste Weiß-/Schwarzwerte verdrahten.
|
||||
============================================================================= */
|
||||
|
||||
/* Schriften selbst gehostet (DSGVO: kein externer Request an Google).
|
||||
@font-face-Regeln in fonts.css, Dateien unter static/fonts/. */
|
||||
@import url("fonts.css");
|
||||
|
||||
/* #############################################################################
|
||||
TEIL A · USER-THEME (Ehrenamtliche, Mobile, Bottom-Nav)
|
||||
############################################################################# */
|
||||
/* =============================================================================
|
||||
A.1 · BRAND (pro Mandant überschreiben — User-Theme)
|
||||
----------------------------------------------------------------------------
|
||||
Für ein weiteres Team genügt es, diesen Block zu ersetzen – am besten
|
||||
serverseitig pro Team eingespielt, z. B.:
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--cp-user-brand-primary: #21a8ad;
|
||||
--cp-user-brand-primary-strong: #147f84;
|
||||
--cp-user-brand-accent: #ef1823;
|
||||
--cp-user-brand-accent-strong: #bd0f18;
|
||||
--cp-user-brand-font: "DM Sans", Arial, Helvetica, sans-serif;
|
||||
}
|
||||
</style>
|
||||
|
||||
Alle getönten Flächen, Fokus-Ringe und Status-Farben werden aus diesen
|
||||
wenigen Werten abgeleitet – das Team muss nichts weiter pflegen.
|
||||
============================================================================= */
|
||||
:root {
|
||||
--cp-user-brand-primary: #21a8ad; /* Türkis – Hauptfarbe */
|
||||
--cp-user-brand-primary-strong: #147f84; /* Türkis dunkel – Links, starke Akzente */
|
||||
--cp-user-brand-accent: #ef1823; /* Rot – dringende Aktionen, Warnungen */
|
||||
--cp-user-brand-accent-strong: #bd0f18; /* Rot dunkel – Hover, starke Akzente */
|
||||
--cp-user-brand-font: "Roboto Condensed", Arial, Helvetica, sans-serif;
|
||||
--cp-user-on-brand: #fff; /* Schrift auf Voll-Marken-Flächen,
|
||||
themeunabhängig (Türkis/Rot in beiden
|
||||
Modi gleich). Pro Team überschreibbar. */
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
A.2 · BASE (theme-unabhängig — User-Theme)
|
||||
============================================================================= */
|
||||
:root {
|
||||
--cp-user-font: var(--cp-user-brand-font);
|
||||
|
||||
--cp-user-radius-sm: 6px;
|
||||
--cp-user-radius-md: 8px;
|
||||
--cp-user-radius-lg: 12px;
|
||||
--cp-user-radius-xl: 16px;
|
||||
--cp-user-radius-pill: 999px;
|
||||
|
||||
--cp-user-space-1: 0.25rem;
|
||||
--cp-user-space-2: 0.5rem;
|
||||
--cp-user-space-3: 0.75rem;
|
||||
--cp-user-space-4: 1rem;
|
||||
--cp-user-space-5: 1.25rem;
|
||||
--cp-user-space-6: 1.5rem;
|
||||
--cp-user-space-8: 2rem;
|
||||
|
||||
--cp-user-transition: 160ms ease;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
A.3 · SEMANTIK (hell + dunkel via light-dark — User-Theme)
|
||||
----------------------------------------------------------------------------
|
||||
light-dark(HELL, DUNKEL) liefert automatisch den passenden Wert, sobald
|
||||
color-scheme aktiv ist. Reihenfolge der Argumente: immer HELL zuerst.
|
||||
============================================================================= */
|
||||
:root {
|
||||
/* --- Flächen & Hintergründe ------------------------------------------- */
|
||||
--cp-user-bg: light-dark(#f5f7f8, #0f1518); /* Seitenhintergrund */
|
||||
--cp-user-surface: light-dark(#ffffff, #161f22); /* Karten, Header, Formulare */
|
||||
--cp-user-surface-alt: light-dark(#f0f5f6, #1d272b); /* neutrale Chips, abgesetzt */
|
||||
|
||||
/* --- Text -------------------------------------------------------------- */
|
||||
--cp-user-text: light-dark(#253033, #e8eef0); /* Haupttext */
|
||||
--cp-user-muted: light-dark(#66777c, #9fb1b5); /* Zusatz-/Meta-Text */
|
||||
--cp-user-faint: light-dark(#9aabad, #6b7c80); /* inaktiv, sehr leise */
|
||||
|
||||
/* --- Linien ------------------------------------------------------------ */
|
||||
--cp-user-line: light-dark(rgba(37, 48, 51, 0.13), rgba(232, 238, 240, 0.14));
|
||||
|
||||
/* --- Markenfarben (Buttons, aktive Zustände) -------------------------- */
|
||||
--cp-user-teal: var(--cp-user-brand-primary);
|
||||
--cp-user-red: var(--cp-user-brand-accent);
|
||||
|
||||
/* "dark"-Varianten = starke Akzent-/Textfarbe. Im Dunkelmodus heller,
|
||||
damit sie auf dunklem Grund lesbar bleibt. */
|
||||
--cp-user-teal-dark: light-dark(var(--cp-user-brand-primary-strong), #5fd0d4);
|
||||
--cp-user-red-dark: light-dark(var(--cp-user-brand-accent-strong), #ff6b73);
|
||||
|
||||
/* --- Getönte Flächen (aus der Markenfarbe abgeleitet) ----------------- */
|
||||
--cp-user-teal-soft: light-dark(
|
||||
color-mix(in srgb, var(--cp-user-brand-primary) 14%, white),
|
||||
color-mix(in srgb, var(--cp-user-brand-primary) 26%, transparent)
|
||||
);
|
||||
--cp-user-red-soft: light-dark(
|
||||
color-mix(in srgb, var(--cp-user-brand-accent) 12%, white),
|
||||
color-mix(in srgb, var(--cp-user-brand-accent) 26%, transparent)
|
||||
);
|
||||
|
||||
/* --- Info (sekundäre Infoflächen) -------------------------------------- */
|
||||
--cp-user-info: light-dark(#2b86c5, #6fb7e6);
|
||||
--cp-user-blue-soft: light-dark(#dff4fb, color-mix(in srgb, #4aa3dd 22%, transparent));
|
||||
|
||||
/* --- Fokus-Ring (aus der Markenfarbe abgeleitet) ---------------------- */
|
||||
--cp-user-focus: light-dark(
|
||||
color-mix(in srgb, var(--cp-user-brand-primary) 34%, transparent),
|
||||
color-mix(in srgb, var(--cp-user-brand-primary) 55%, transparent)
|
||||
);
|
||||
|
||||
/* --- Schatten --------------------------------------------------------- */
|
||||
--cp-user-shadow-color: light-dark(rgba(20, 52, 58, 0.12), rgba(0, 0, 0, 0.55));
|
||||
--cp-user-shadow-sm: 0 1px 3px var(--cp-user-shadow-color);
|
||||
--cp-user-shadow-md: 0 10px 28px var(--cp-user-shadow-color);
|
||||
|
||||
/* --- Status-Mapping (semantisch, für Badges/Hinweise) ----------------- */
|
||||
/* Türkis = offen/bestätigt/verfügbar · Rot = dringend/Vertretung ·
|
||||
Neutral = Info/intern/erledigt. */
|
||||
--cp-user-status-ok-bg: var(--cp-user-teal-soft);
|
||||
--cp-user-status-ok-text: var(--cp-user-teal-dark);
|
||||
--cp-user-status-urgent-bg: var(--cp-user-red-soft);
|
||||
--cp-user-status-urgent-text: var(--cp-user-red-dark);
|
||||
--cp-user-status-neutral-bg: var(--cp-user-surface-alt);
|
||||
--cp-user-status-neutral-text: var(--cp-user-muted);
|
||||
}
|
||||
|
||||
/* #############################################################################
|
||||
TEIL B · ADMIN-THEME (Sidebar-Verwaltungsoberfläche, Desktop + mobiles
|
||||
Off-Canvas-Menü). Eigener, vollständig getrennter Tokensatz — bewusst kein
|
||||
Vermischen mit den User-Tokens, damit ein Team beide Looks unabhängig
|
||||
pflegen oder austauschen kann.
|
||||
############################################################################# */
|
||||
|
||||
/* =============================================================================
|
||||
B.1 · BRAND (pro Mandant überschreiben — Admin-Theme)
|
||||
----------------------------------------------------------------------------
|
||||
Quelle: "Checkpoint Koeln UI Corporate Design". Ein zweites Team ersetzt
|
||||
nur diesen Block plus die Schrift-Imports oben in der Datei.
|
||||
============================================================================= */
|
||||
:root {
|
||||
--cp-admin-brand-navy: #1b1f30; /* Sidebar, Tabellenkopf */
|
||||
--cp-admin-brand-navy-soft: #252a3d; /* Sidebar-Trenner, dunkler Hover */
|
||||
--cp-admin-brand-teal: #01b2bb; /* Hauptmarkenfarbe, aktive Nav */
|
||||
--cp-admin-brand-teal-ui: #11b6c9; /* Primärbuttons, Floating Actions */
|
||||
--cp-admin-brand-teal-dark: #139ead; /* Hover, aktive Buttons */
|
||||
--cp-admin-brand-yellow: #fbef1f; /* Sidebar-Icons, Markenhighlight */
|
||||
--cp-admin-brand-pink: #e6037f; /* Logo-/Markenakzent */
|
||||
--cp-admin-brand-red: #e62143; /* Fehler, Abbruch, Warnung */
|
||||
--cp-admin-brand-red-dark: #c80f3d; /* Danger-Buttons, Hover */
|
||||
--cp-admin-brand-orange: #f59e18; /* sekundäre Hinweise */
|
||||
--cp-admin-brand-green: #168a2d; /* Erfolg, verfügbar, bestätigt */
|
||||
--cp-admin-brand-font-display: "Alfa Slab One", "Roboto Slab", Georgia, serif;
|
||||
--cp-admin-brand-font-ui: "Roboto Condensed", "Arial Narrow", Arial, sans-serif;
|
||||
--cp-admin-on-brand: #fff; /* Schrift auf Volltonflächen (Buttons,
|
||||
Tabellenkopf, Sidebar-Brand) */
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
B.2 · BASE (theme-unabhängig — Admin-Theme)
|
||||
============================================================================= */
|
||||
:root {
|
||||
--cp-admin-font-display: var(--cp-admin-brand-font-display);
|
||||
--cp-admin-font-ui: var(--cp-admin-brand-font-ui);
|
||||
--cp-admin-font-mono: "Roboto Mono", Consolas, monospace;
|
||||
|
||||
--cp-admin-radius-sm: 6px;
|
||||
--cp-admin-radius-md: 10px;
|
||||
--cp-admin-radius-lg: 14px;
|
||||
|
||||
--cp-admin-space-1: 4px;
|
||||
--cp-admin-space-2: 8px;
|
||||
--cp-admin-space-3: 12px;
|
||||
--cp-admin-space-4: 16px;
|
||||
--cp-admin-space-5: 24px;
|
||||
--cp-admin-space-6: 32px;
|
||||
|
||||
--cp-admin-sidebar-width: 260px;
|
||||
--cp-admin-transition: 160ms ease;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
B.3 · SEMANTIK (hell + dunkel via light-dark — Admin-Theme)
|
||||
----------------------------------------------------------------------------
|
||||
Das Quelldokument beschreibt nur einen hellen Look (helle Arbeitsfläche +
|
||||
dunkle Sidebar). Die Sidebar bleibt darum in BEIDEN Modi dunkel (Navy) —
|
||||
das ist so gewollt, sie ist ja schon das "dunkle Element" im Hellmodus.
|
||||
Im Dunkelmodus wird nur die Arbeitsfläche dunkler, die Sidebar bleibt das
|
||||
immer gleiche Marken-Navy. So muss am Logo/Sidebar-Look nichts wechseln.
|
||||
============================================================================= */
|
||||
:root {
|
||||
/* --- Sidebar (in beiden Themes gleich dunkel = Markenelement) ---------- */
|
||||
--cp-admin-sidebar-bg: var(--cp-admin-brand-navy);
|
||||
--cp-admin-sidebar-hover: var(--cp-admin-brand-navy-soft);
|
||||
--cp-admin-sidebar-text: light-dark(rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.92));
|
||||
--cp-admin-sidebar-text-muted: light-dark(rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.6));
|
||||
--cp-admin-sidebar-icon: var(--cp-admin-brand-yellow);
|
||||
--cp-admin-sidebar-active-bg: var(--cp-admin-brand-teal);
|
||||
--cp-admin-sidebar-active-text: #ffffff;
|
||||
|
||||
/* --- Arbeitsfläche & Flächen -------------------------------------------- */
|
||||
--cp-admin-bg: light-dark(#f5f7fa, #0e1320); /* Seitenhintergrund */
|
||||
--cp-admin-bg-cool: light-dark(#f5fafb, #0c1722); /* kühlerer Arbeitsbereich */
|
||||
--cp-admin-surface: light-dark(#ffffff, #161c2b); /* Karten, Panels, Formulare */
|
||||
--cp-admin-surface-soft: light-dark(#eef9fb, #16242c); /* Panel-Header, Akkordeons */
|
||||
|
||||
/* --- Text ---------------------------------------------------------------- */
|
||||
--cp-admin-text: light-dark(#1f2b37, #e6ecf1); /* Haupttext */
|
||||
--cp-admin-heading: light-dark(#090b10, #f5f8fa); /* Seitentitel, starke Headlines */
|
||||
--cp-admin-muted: light-dark(#6e8190, #93a3b0); /* Labels, Meta-Informationen */
|
||||
--cp-admin-muted-2: light-dark(#9ba7b1, #6b7a85); /* inaktive Icons, sehr leise */
|
||||
|
||||
/* --- Linien --------------------------------------------------------------- */
|
||||
--cp-admin-line: light-dark(#d6e6ec, rgba(214, 230, 236, 0.18)); /* Rahmen, Input-Borders */
|
||||
--cp-admin-line-soft: light-dark(#e8eef2, rgba(214, 230, 236, 0.1)); /* Tabellenlinien */
|
||||
|
||||
/* --- Markenfarben (Buttons, aktive Zustände, in beiden Themes gleich) --- */
|
||||
--cp-admin-teal: var(--cp-admin-brand-teal);
|
||||
--cp-admin-teal-ui: var(--cp-admin-brand-teal-ui);
|
||||
--cp-admin-teal-dark: light-dark(var(--cp-admin-brand-teal-dark), #5fd6e0);
|
||||
--cp-admin-teal-soft: light-dark(
|
||||
color-mix(in srgb, var(--cp-admin-brand-teal) 10%, white),
|
||||
color-mix(in srgb, var(--cp-admin-brand-teal) 24%, transparent)
|
||||
);
|
||||
|
||||
--cp-admin-pink: var(--cp-admin-brand-pink);
|
||||
|
||||
--cp-admin-red: var(--cp-admin-brand-red);
|
||||
--cp-admin-red-dark: light-dark(var(--cp-admin-brand-red-dark), #ff7a93);
|
||||
--cp-admin-red-soft: light-dark(
|
||||
color-mix(in srgb, var(--cp-admin-brand-red) 10%, white),
|
||||
color-mix(in srgb, var(--cp-admin-brand-red) 24%, transparent)
|
||||
);
|
||||
|
||||
--cp-admin-orange: var(--cp-admin-brand-orange);
|
||||
--cp-admin-orange-soft: light-dark(
|
||||
color-mix(in srgb, var(--cp-admin-brand-orange) 12%, white),
|
||||
color-mix(in srgb, var(--cp-admin-brand-orange) 24%, transparent)
|
||||
);
|
||||
|
||||
--cp-admin-green: var(--cp-admin-brand-green);
|
||||
--cp-admin-green-soft: light-dark(#e5f5e6, color-mix(in srgb, var(--cp-admin-brand-green) 24%, transparent));
|
||||
|
||||
/* --- Fokus-Ring ------------------------------------------------------------ */
|
||||
--cp-admin-focus: light-dark(
|
||||
color-mix(in srgb, var(--cp-admin-brand-teal) 34%, transparent),
|
||||
color-mix(in srgb, var(--cp-admin-brand-teal) 55%, transparent)
|
||||
);
|
||||
|
||||
/* --- Schatten ---------------------------------------------------------- */
|
||||
--cp-admin-shadow-color: light-dark(rgba(27, 31, 48, 0.06), rgba(0, 0, 0, 0.5));
|
||||
--cp-admin-shadow-soft: 0 2px 8px var(--cp-admin-shadow-color);
|
||||
--cp-admin-shadow-action: light-dark(0 4px 10px rgba(1, 178, 187, 0.25), 0 4px 10px rgba(1, 178, 187, 0.35));
|
||||
|
||||
/* --- Chip/Badge-Status (Text immer mitgeben, nie nur Farbe) ------------- */
|
||||
--cp-admin-status-neutral-bg: light-dark(#e9eef2, color-mix(in srgb, var(--cp-admin-muted-2) 22%, transparent));
|
||||
--cp-admin-status-neutral-text: var(--cp-admin-text);
|
||||
--cp-admin-status-success-bg: var(--cp-admin-green-soft);
|
||||
--cp-admin-status-success-text: var(--cp-admin-green);
|
||||
--cp-admin-status-info-bg: var(--cp-admin-teal-soft);
|
||||
--cp-admin-status-info-text: var(--cp-admin-teal-dark);
|
||||
--cp-admin-status-warning-bg: var(--cp-admin-orange-soft);
|
||||
--cp-admin-status-warning-text: light-dark(#8a5a0a, #ffc169);
|
||||
--cp-admin-status-error-bg: var(--cp-admin-red-soft);
|
||||
--cp-admin-status-error-text: var(--cp-admin-red-dark);
|
||||
}
|
||||
|
||||
/* #############################################################################
|
||||
TEIL C · GLOBALE STEUERUNG (gilt für beide Themes)
|
||||
############################################################################# */
|
||||
|
||||
/* =============================================================================
|
||||
C.1 · Hell/Dunkel aktivieren + manueller Override
|
||||
----------------------------------------------------------------------------
|
||||
Setzt das Attribut data-theme die color-scheme fest, "gewinnt" diese gegen
|
||||
die Systemeinstellung – ganz ohne doppelte Token-Definitionen.
|
||||
Der Topbar-Umschalter setzt data-theme serverseitig (theme-Cookie → <html ...>),
|
||||
also ohne JavaScript; "System" entfernt das Attribut wieder.
|
||||
============================================================================= */
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
:root[data-theme="light"] { color-scheme: light; }
|
||||
:root[data-theme="dark"] { color-scheme: dark; }
|
||||
|
||||
/* =============================================================================
|
||||
C.2 · Shell-Auswahl
|
||||
----------------------------------------------------------------------------
|
||||
Jede Shell-Vorlage setzt auf ihrem Wurzelelement eine der beiden Klassen.
|
||||
So lesen Komponenten in jeder Shell automatisch die richtigen Tokens, ohne
|
||||
dass irgendwo "user" oder "admin" hartkodiert werden muss:
|
||||
|
||||
<body class="cp-shell-user"> → in base_user.html
|
||||
<body class="cp-shell-admin"> → in base_admin.html
|
||||
|
||||
Innerhalb von components.css kann dadurch z. B. .cp-shell-user .cp-card
|
||||
bzw. .cp-shell-admin .cp-card jeweils ihre eigenen Tokens nutzen, falls
|
||||
geteilte Klassennamen je Shell unterschiedlich aussehen sollen.
|
||||
============================================================================= */
|
||||
.cp-shell-user {
|
||||
background: var(--cp-user-bg);
|
||||
color: var(--cp-user-text);
|
||||
font-family: var(--cp-user-font);
|
||||
}
|
||||
|
||||
.cp-shell-admin {
|
||||
background: var(--cp-admin-bg);
|
||||
color: var(--cp-admin-text);
|
||||
font-family: var(--cp-admin-font-ui);
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: inherit;
|
||||
}
|
||||
51
app/team_scope.py
Normal file
51
app/team_scope.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""Team-Scoping (Mandantenfähigkeit).
|
||||
|
||||
Jeder Datensatz gehört zu genau einem Team; jede Abfrage filtert nach dem Team
|
||||
des eingeloggten Nutzers (siehe CLAUDE.md). Das aktive Team wird beim Login in
|
||||
die Session geschrieben und hier zentral ausgelesen.
|
||||
|
||||
Faustregel für alle Routen: NIE Model.query direkt verwenden, sondern
|
||||
team_query(Model) — so kann keine Abfrage versehentlich Teamgrenzen überschreiten.
|
||||
"""
|
||||
|
||||
from flask import session
|
||||
|
||||
SESSION_TEAM_KEY = "team_id"
|
||||
SESSION_USER_KEY = "user_id"
|
||||
|
||||
|
||||
def get_active_team_id():
|
||||
"""team_id des aktiven Teams aus der Session (oder None)."""
|
||||
return session.get(SESSION_TEAM_KEY)
|
||||
|
||||
|
||||
def current_team():
|
||||
"""Das aktive Team-Objekt (oder None), abgeleitet aus der Session."""
|
||||
from .models import Team
|
||||
|
||||
team_id = get_active_team_id()
|
||||
if team_id is None:
|
||||
return None
|
||||
return Team.query.get(team_id)
|
||||
|
||||
|
||||
def team_query(model):
|
||||
"""Query auf ein Modell, fest auf das aktive Team eingegrenzt.
|
||||
|
||||
Wirft ohne aktives Team bewusst, damit team-vergessende Aufrufe früh auffallen.
|
||||
"""
|
||||
team_id = get_active_team_id()
|
||||
if team_id is None:
|
||||
raise RuntimeError("Kein aktives Team in der Session – Login erforderlich.")
|
||||
return model.query.filter_by(team_id=team_id)
|
||||
|
||||
|
||||
def set_active_session(user_id, team_id):
|
||||
"""Beim Login: Nutzer und sein Team in die Session schreiben."""
|
||||
session[SESSION_USER_KEY] = user_id
|
||||
session[SESSION_TEAM_KEY] = team_id
|
||||
|
||||
|
||||
def clear_session():
|
||||
"""Beim Logout: Session leeren."""
|
||||
session.clear()
|
||||
10
app/templates/400_csrf.html
Normal file
10
app/templates/400_csrf.html
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{% extends shell_base %}
|
||||
|
||||
{% block title %}Sitzung abgelaufen · CheckPoint Ehrenamt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Sitzung abgelaufen</h1>
|
||||
<p class="cp-muted">Das Formular war zu lange offen. Lade die Seite neu und
|
||||
sende es noch einmal ab.</p>
|
||||
<a class="cp-btn cp-btn--ghost" href="{{ url_for('main.index') }}">Zur Startseite</a>
|
||||
{% endblock %}
|
||||
9
app/templates/403.html
Normal file
9
app/templates/403.html
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{% extends shell_base %}
|
||||
|
||||
{% block title %}Kein Zugriff · CheckPoint Ehrenamt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Kein Zugriff</h1>
|
||||
<p class="cp-muted">Diese Seite ist nur für Admins. Geh zurück zur Startseite.</p>
|
||||
<a class="cp-btn cp-btn--ghost" href="{{ url_for('main.index') }}">Zur Startseite</a>
|
||||
{% endblock %}
|
||||
50
app/templates/_einsatz_felder.html
Normal file
50
app/templates/_einsatz_felder.html
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
{# Gemeinsame Einsatz-Felder für Anlegen und Bearbeiten.
|
||||
Optional vorbelegt aus `einsatz` (im Bearbeiten-Fall). #}
|
||||
{% set e = einsatz if einsatz is defined else None %}
|
||||
|
||||
<div class="cp-field">
|
||||
<label for="datum">Datum</label>
|
||||
<input type="date" id="datum" name="datum"
|
||||
value="{{ e.datum.strftime('%Y-%m-%d') if e else '' }}" required>
|
||||
</div>
|
||||
|
||||
<div class="cp-field">
|
||||
<label for="start_zeit">Startzeit</label>
|
||||
<select id="start_zeit" name="start_zeit" required>
|
||||
<option value="" disabled {{ 'selected' if not e else '' }}>Bitte wählen</option>
|
||||
{% for m in range(0, 1440, 30) %}
|
||||
{% set hhmm = '%02d:%02d'|format(m // 60, m % 60) %}
|
||||
<option value="{{ hhmm }}"
|
||||
{{ 'selected' if e and e.start_zeit.strftime('%H:%M') == hhmm else '' }}>{{ hhmm }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cp-field">
|
||||
<label for="dauer_minuten">Erwartete Dauer <span class="cp-muted">(optional)</span></label>
|
||||
<select id="dauer_minuten" name="dauer_minuten">
|
||||
<option value="" {{ 'selected' if not e or not e.dauer_minuten else '' }}>Offen / unklar</option>
|
||||
{% for d in range(30, 481, 30) %}
|
||||
{% set h = d // 60 %}{% set mm = d % 60 %}
|
||||
<option value="{{ d }}" {{ 'selected' if e and e.dauer_minuten == d else '' }}>
|
||||
{%- if h and mm %}{{ h }},5 Std{% elif h %}{{ h }} Std{% else %}{{ mm }} Min{% endif -%}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cp-field">
|
||||
<label for="art">Art</label>
|
||||
<select id="art" name="art" required>
|
||||
<option value="" disabled {{ 'selected' if not e else '' }}>Bitte wählen</option>
|
||||
{% for a in arten %}
|
||||
<option value="{{ a }}" {{ 'selected' if e and e.art == a else '' }}>{{ a }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cp-field">
|
||||
<label for="ort">Ort <span class="cp-muted">(optional)</span></label>
|
||||
<input type="text" id="ort" name="ort" value="{{ e.ort or '' if e else '' }}"
|
||||
placeholder="z. B. Altstadt">
|
||||
</div>
|
||||
31
app/templates/_theme_toggle.html
Normal file
31
app/templates/_theme_toggle.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{# Theme-Umschalter: ein Tippen schaltet System → Hell → Dunkel weiter.
|
||||
Serverseitig per POST (kein JS), Icon zeigt den aktuellen Modus.
|
||||
Shell-unabhängig – nutzt .cp-iconbtn aus dem jeweils geladenen Komponenten-CSS. #}
|
||||
{%- set theme_next = {'system': 'hell', 'hell': 'dunkel', 'dunkel': 'system'} -%}
|
||||
{%- set theme_label = {'system': 'System', 'hell': 'Hell', 'dunkel': 'Dunkel'} -%}
|
||||
<form class="cp-topbar__theme" method="post" action="{{ url_for('main.theme_setzen') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="modus" value="{{ theme_next[current_theme] }}">
|
||||
<input type="hidden" name="next" value="{{ request.path }}">
|
||||
<button type="submit" class="cp-iconbtn"
|
||||
aria-label="Darstellung: {{ theme_label[current_theme] }}. Tippen für {{ theme_label[theme_next[current_theme]] }}.">
|
||||
{% if current_theme == 'hell' %}
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"></path>
|
||||
</svg>
|
||||
{% elif current_theme == 'dunkel' %}
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"></path>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
6
app/templates/_zeit.html
Normal file
6
app/templates/_zeit.html
Normal file
|
|
@ -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 -%}
|
||||
144
app/templates/base_admin.html
Normal file
144
app/templates/base_admin.html
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de"{% if current_theme == 'hell' %} data-theme="light"{% elif current_theme == 'dunkel' %} data-theme="dark"{% endif %}>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}CheckPoint Ehrenamt{% endblock %}</title>
|
||||
|
||||
{# tokens.css zuerst (Variablen), dann das Admin-Komponenten-CSS. #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='tokens.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='components-admin.css') }}">
|
||||
|
||||
{# Admin-Brand: v1 nutzt die festen Checkpoint-Defaults aus tokens.css
|
||||
(das Team-Modell hat keine Admin-Brand-Felder). Ein zweites Team würde hier
|
||||
analog zur User-Shell --cp-admin-brand-* serverseitig überschreiben. #}
|
||||
{% block head_brand %}{% endblock %}
|
||||
</head>
|
||||
<body class="cp-shell-admin">
|
||||
|
||||
{% if current_user %}
|
||||
{# Mobile: schmale Topbar mit Hamburger; Sidebar wird zum Off-Canvas-Overlay. #}
|
||||
<header class="cp-admin-topbar">
|
||||
<button type="button" class="cp-hamburger" data-sidebar-open
|
||||
aria-label="Menü öffnen" aria-controls="cp-sidebar" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M3 6h18M3 12h18M3 18h18"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="cp-admin-topbar__title">{{ current_team.name if current_team else 'CheckPoint' }}</span>
|
||||
<a class="cp-iconbtn cp-admin-topbar__bell" href="{{ url_for('main.benachrichtigungen') }}"
|
||||
aria-label="Benachrichtigungen{% if benachrichtigungen_ungelesen %} ({{ benachrichtigungen_ungelesen }} ungelesen){% endif %}">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.7 21a2 2 0 0 1-3.4 0"></path>
|
||||
</svg>
|
||||
{% if benachrichtigungen_ungelesen %}<span class="cp-badge">{{ benachrichtigungen_ungelesen }}</span>{% endif %}
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<div class="cp-overlay" data-sidebar-close hidden></div>
|
||||
|
||||
<aside class="cp-sidebar" id="cp-sidebar">
|
||||
<div class="cp-sidebar__brand">
|
||||
<span>{{ current_team.name if current_team else 'CheckPoint' }}</span>
|
||||
<a class="cp-iconbtn cp-sidebar__bell" href="{{ url_for('main.benachrichtigungen') }}"
|
||||
aria-label="Benachrichtigungen{% if benachrichtigungen_ungelesen %} ({{ benachrichtigungen_ungelesen }} ungelesen){% endif %}">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.7 21a2 2 0 0 1-3.4 0"></path>
|
||||
</svg>
|
||||
{% if benachrichtigungen_ungelesen %}<span class="cp-badge">{{ benachrichtigungen_ungelesen }}</span>{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% set bp = request.blueprint %}
|
||||
{% set ep = request.endpoint %}
|
||||
<nav class="cp-sidebar__nav" aria-label="Verwaltung">
|
||||
<a class="cp-navlink" href="{{ url_for('main.index') }}"
|
||||
{% if ep == 'main.index' %}aria-current="page"{% endif %}>
|
||||
<svg class="cp-navlink__icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 11l9-8 9 8"></path><path d="M5 10v10h14V10"></path>
|
||||
</svg><span>Start</span>
|
||||
</a>
|
||||
<a class="cp-navlink" href="{{ url_for('planung.liste') }}"
|
||||
{% if bp == 'planung' %}aria-current="page"{% endif %}>
|
||||
<svg class="cp-navlink__icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2"></rect><path d="M16 2v4M8 2v4M3 10h18"></path>
|
||||
</svg><span>Planung</span>
|
||||
</a>
|
||||
<a class="cp-navlink" href="{{ url_for('dienste.liste') }}"
|
||||
{% if bp == 'dienste' and ep != 'dienste.meine_termine' %}aria-current="page"{% endif %}>
|
||||
<svg class="cp-navlink__icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9 11l3 3L22 4"></path><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||
</svg><span>Dienste</span>
|
||||
</a>
|
||||
<a class="cp-navlink" href="{{ url_for('dokumente.freigaben') }}"
|
||||
{% if ep in ['dokumente.freigaben', 'dokumente.liste'] %}aria-current="page"{% endif %}>
|
||||
<svg class="cp-navlink__icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><path d="M14 2v6h6M9 15l2 2 4-4"></path>
|
||||
</svg><span>Freigaben</span>
|
||||
</a>
|
||||
<a class="cp-navlink" href="{{ url_for('main.team') }}"
|
||||
{% if ep == 'main.team' %}aria-current="page"{% endif %}>
|
||||
<svg class="cp-navlink__icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13A4 4 0 0 1 16 11"></path>
|
||||
</svg><span>Team</span>
|
||||
</a>
|
||||
<a class="cp-navlink" href="{{ url_for('chat.kanal', art='ankuendigungen') }}"
|
||||
{% if bp == 'chat' %}aria-current="page"{% endif %}>
|
||||
<svg class="cp-navlink__icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
</svg><span>Chat-Verwaltung</span>
|
||||
</a>
|
||||
<a class="cp-navlink" href="{{ url_for('profil.index') }}"
|
||||
{% if ep == 'profil.index' %}aria-current="page"{% endif %}>
|
||||
<svg class="cp-navlink__icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle>
|
||||
</svg><span>Profil</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="cp-sidebar__footer">
|
||||
{% include "_theme_toggle.html" %}
|
||||
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="cp-navlink cp-navlink--logout">
|
||||
<svg class="cp-navlink__icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"></path>
|
||||
</svg><span>Abmelden</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
{% endif %}
|
||||
|
||||
<main class="cp-admin-main">
|
||||
<div class="cp-page">
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<ul class="cp-flash" role="status">
|
||||
{% for message in messages %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{% if current_user %}
|
||||
<script src="{{ url_for('static', filename='sidebar.js') }}" defer></script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
117
app/templates/base_user.html
Normal file
117
app/templates/base_user.html
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de"{% if current_theme == 'hell' %} data-theme="light"{% elif current_theme == 'dunkel' %} data-theme="dark"{% endif %}>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}CheckPoint Ehrenamt{% endblock %}</title>
|
||||
|
||||
{# tokens.css zuerst (Variablen), dann das User-Komponenten-CSS. #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='tokens.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='components.css') }}">
|
||||
|
||||
{# User-Brand-Tokens des aktiven Teams serverseitig überschreiben. |safe wegen
|
||||
der Anführungszeichen im Font – Quelle ist Admin/Seed (kein Nutzer-Input). #}
|
||||
{% block head_brand %}
|
||||
{% if current_team %}
|
||||
<style>
|
||||
:root {
|
||||
--cp-user-brand-primary: {{ current_team.brand_primary }};
|
||||
--cp-user-brand-primary-strong: {{ current_team.brand_primary_strong }};
|
||||
--cp-user-brand-accent: {{ current_team.brand_accent }};
|
||||
--cp-user-brand-accent-strong: {{ current_team.brand_accent_strong }};
|
||||
--cp-user-brand-font: {{ current_team.brand_font | safe }};
|
||||
}
|
||||
</style>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body class="cp-shell-user{% if current_user %} cp-has-nav{% endif %}">
|
||||
|
||||
{% if current_user %}
|
||||
<header class="cp-topbar">
|
||||
<a class="cp-topbar__logo" href="{{ url_for('main.index') }}">
|
||||
{{ current_team.name if current_team else 'CheckPoint' }}
|
||||
</a>
|
||||
<div class="cp-topbar__actions">
|
||||
{% include "_theme_toggle.html" %}
|
||||
<a class="cp-iconbtn" href="{{ url_for('main.benachrichtigungen') }}"
|
||||
aria-label="Benachrichtigungen{% if benachrichtigungen_ungelesen %} ({{ benachrichtigungen_ungelesen }} ungelesen){% endif %}">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"></path>
|
||||
<path d="M13.7 21a2 2 0 0 1-3.4 0"></path>
|
||||
</svg>
|
||||
{% if benachrichtigungen_ungelesen %}<span class="cp-badge">{{ benachrichtigungen_ungelesen }}</span>{% endif %}
|
||||
</a>
|
||||
<a class="cp-avatar" href="{{ url_for('profil.index') }}"
|
||||
aria-label="Profil von {{ current_user.anzeigename }}">
|
||||
{% if current_user.profilbild_pfad %}
|
||||
<img class="cp-avatar__img" src="{{ url_for('profil.bild', user_id=current_user.id) }}" alt="">
|
||||
{%- else -%}
|
||||
{%- set teile = current_user.anzeigename.split() -%}
|
||||
{{ (teile[0][:1] ~ (teile[1][:1] if teile|length > 1 else ''))|upper }}
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
{% endif %}
|
||||
|
||||
<main class="cp-page">
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<ul class="cp-flash" role="status">
|
||||
{% for message in messages %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
{% if current_user %}
|
||||
<nav class="cp-bottomnav" aria-label="Hauptnavigation">
|
||||
{% set ep = request.endpoint %}
|
||||
<a class="cp-navitem" href="{{ url_for('main.index') }}"
|
||||
{% if ep == 'main.index' %}aria-current="page"{% endif %}>
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 11l9-8 9 8"></path><path d="M5 10v10h14V10"></path>
|
||||
</svg>
|
||||
<span>Start</span>
|
||||
</a>
|
||||
<a class="cp-navitem" href="{{ url_for('dienste.meine_termine') }}"
|
||||
{% if ep in ['dienste.meine_termine', 'planung.verfuegbarkeit'] %}aria-current="page"{% endif %}>
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2"></rect>
|
||||
<path d="M16 2v4M8 2v4M3 10h18"></path>
|
||||
</svg>
|
||||
<span>Termine</span>
|
||||
</a>
|
||||
<a class="cp-navitem" href="{{ url_for('main.team') }}"
|
||||
{% if ep == 'main.team' %}aria-current="page"{% endif %}>
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13A4 4 0 0 1 16 11"></path>
|
||||
</svg>
|
||||
<span>Team</span>
|
||||
</a>
|
||||
<a class="cp-navitem" href="{{ url_for('profil.index') }}"
|
||||
{% if ep == 'profil.index' %}aria-current="page"{% endif %}>
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
<span>Profil</span>
|
||||
</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
31
app/templates/benachrichtigungen.html
Normal file
31
app/templates/benachrichtigungen.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{% extends shell_base %}
|
||||
|
||||
{% block title %}Benachrichtigungen · CheckPoint Ehrenamt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Benachrichtigungen</h1>
|
||||
|
||||
{% if eintraege %}
|
||||
<form method="post" action="{{ url_for('main.benachrichtigungen_gelesen') }}" class="cp-actions">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="cp-btn cp-btn--ghost">Alle als gelesen markieren</button>
|
||||
</form>
|
||||
|
||||
<ul class="cp-list">
|
||||
{% for b in eintraege %}
|
||||
<li class="cp-card{{ ' cp-card--ungelesen' if not b.gelesen else '' }}">
|
||||
{% if not b.gelesen %}<span class="cp-tag">Neu</span>{% endif %}
|
||||
{% if b.url %}
|
||||
<a href="{{ b.url }}"><strong>{{ b.text }}</strong></a>
|
||||
{% else %}
|
||||
<strong>{{ b.text }}</strong>
|
||||
{% endif %}
|
||||
<div class="cp-muted">{{ b.erstellt_am | lokalzeit }}</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="cp-muted">Keine Benachrichtigungen. Sobald ein Dienst, ein offener
|
||||
Platz oder eine Nachricht dazukommt, erscheint sie hier.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
82
app/templates/chat.html
Normal file
82
app/templates/chat.html
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
{% extends shell_base %}
|
||||
|
||||
{% block title %}{{ label }} · CheckPoint Ehrenamt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p><a class="cp-muted" href="{{ url_for('main.team') }}">← Team</a></p>
|
||||
<h1>{{ label }}</h1>
|
||||
{% if kanal.nur_admin_darf_posten %}
|
||||
<p class="cp-muted">Nur das Team-Management postet hier. Alle lesen mit.</p>
|
||||
{% endif %}
|
||||
|
||||
{# data-* steuern das Polling (siehe Skript unten). neu-base + id = JSON-Endpunkt. #}
|
||||
<ul class="cp-chat" id="cp-chat-liste"
|
||||
data-neu-base="{{ url_for('chat.kanal', art=kanal.art) }}/seit/"
|
||||
data-letzte="{{ letzte_id }}"
|
||||
data-meine="{{ current_user.id }}">
|
||||
{% for n in nachrichten %}
|
||||
<li class="cp-msg{{ ' cp-msg--eigen' if n.autor_id == current_user.id else '' }}">
|
||||
<div class="cp-msg__meta">{{ n.autor.anzeigename }} · {{ zeit(n.erstellt_am) }}</div>
|
||||
<div class="cp-msg__text">{{ n.text }}</div>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="cp-muted" id="cp-chat-leer">Noch keine Nachrichten.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% if darf_posten %}
|
||||
<form method="post" action="{{ url_for('chat.posten', art=kanal.art) }}" class="cp-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="cp-field">
|
||||
<label for="text">Nachricht</label>
|
||||
<textarea id="text" name="text" rows="3" maxlength="2000" required
|
||||
placeholder="Schreib etwas …"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="cp-btn cp-btn--primary">Senden</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{# Einzige v1-JS-Ausnahme: kleines fetch-Polling für neue Nachrichten.
|
||||
Einfügen ausschließlich per textContent → kein XSS über Nachrichtentext. #}
|
||||
<script>
|
||||
(function () {
|
||||
const liste = document.getElementById("cp-chat-liste");
|
||||
if (!liste) return;
|
||||
const basis = liste.dataset.neuBase;
|
||||
const meine = Number(liste.dataset.meine || 0);
|
||||
let letzte = Number(liste.dataset.letzte || 0);
|
||||
|
||||
function anhaengen(n) {
|
||||
const leer = document.getElementById("cp-chat-leer");
|
||||
if (leer) leer.remove();
|
||||
const li = document.createElement("li");
|
||||
li.className = "cp-msg" + (n.autor_id === meine || n.eigene ? " cp-msg--eigen" : "");
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "cp-msg__meta";
|
||||
meta.textContent = n.autor + " · " + n.zeit;
|
||||
const txt = document.createElement("div");
|
||||
txt.className = "cp-msg__text";
|
||||
txt.textContent = n.text;
|
||||
li.appendChild(meta);
|
||||
li.appendChild(txt);
|
||||
liste.appendChild(li);
|
||||
}
|
||||
|
||||
async function holen() {
|
||||
try {
|
||||
const r = await fetch(basis + letzte, { headers: { "X-Requested-With": "fetch" } });
|
||||
if (!r.ok) return;
|
||||
const daten = await r.json();
|
||||
if (!daten.nachrichten.length) return;
|
||||
for (const n of daten.nachrichten) {
|
||||
anhaengen(n);
|
||||
if (n.id > letzte) letzte = n.id;
|
||||
}
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
} catch (e) { /* stiller Wiederholversuch beim nächsten Intervall */ }
|
||||
}
|
||||
|
||||
setInterval(holen, 5000);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
95
app/templates/dienste_einsatz.html
Normal file
95
app/templates/dienste_einsatz.html
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
{% extends shell_base %}
|
||||
|
||||
{% block title %}Einsatz besetzen · CheckPoint Ehrenamt{% endblock %}
|
||||
{% from "_zeit.html" import zeitspanne %}
|
||||
|
||||
{% macro besetzte_plaetze(zuteilungen, frei, leer_text) %}
|
||||
{% for z in zuteilungen %}
|
||||
<li class="cp-card">
|
||||
<strong>{{ z.user.anzeigename }}</strong>
|
||||
<form method="post" action="{{ url_for('dienste.entfernen', einsatz_id=einsatz.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="zuteilung_id" value="{{ z.id }}">
|
||||
<button type="submit" class="cp-btn cp-btn--ghost">Entfernen</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% for _ in range(frei) %}
|
||||
<li class="cp-card cp-card--leer cp-muted">{{ leer_text }}</li>
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
{% if einsatz.planungszeitraum_id %}
|
||||
<p><a class="cp-muted" href="{{ url_for('dienste.zeitraum', zeitraum_id=einsatz.planungszeitraum_id) }}">← Zurück</a></p>
|
||||
{% else %}
|
||||
<p><a class="cp-muted" href="{{ url_for('dienste.liste') }}">← Dienstplan</a></p>
|
||||
{% endif %}
|
||||
|
||||
<h1>{{ einsatz.art }}{% if einsatz.ort %} · {{ einsatz.ort }}{% endif %}</h1>
|
||||
<div class="cp-muted">
|
||||
{{ einsatz.datum.strftime('%a, %d.%m.%Y') }} ·
|
||||
{{ zeitspanne(einsatz) }}
|
||||
</div>
|
||||
<div class="cp-muted">
|
||||
{{ haupt|length }}/{{ einsatz.HAUPT_PLAETZE }} Haupt ·
|
||||
{{ springer|length }}/{{ einsatz.SPRINGER_PLAETZE }} Springer
|
||||
</div>
|
||||
|
||||
{# Unterbesetzt-Markierung (manuell) #}
|
||||
<form method="post" action="{{ url_for('dienste.unterbesetzt', einsatz_id=einsatz.id) }}" class="cp-actions">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="cp-btn {{ 'cp-btn--urgent' if einsatz.unterbesetzt else 'cp-btn--ghost' }}">
|
||||
{{ "Unterbesetzt aufheben" if einsatz.unterbesetzt else "Als unterbesetzt markieren" }}
|
||||
</button>
|
||||
</form>
|
||||
{% if einsatz.unterbesetzt %}
|
||||
<p class="cp-tag cp-tag--urgent">Unterbesetzt</p>
|
||||
{% endif %}
|
||||
|
||||
<h2>Hauptplätze</h2>
|
||||
<ul class="cp-list">
|
||||
{{ besetzte_plaetze(haupt, haupt_frei, "Frei") }}
|
||||
</ul>
|
||||
|
||||
<h2>Springerplatz</h2>
|
||||
<ul class="cp-list">
|
||||
{{ besetzte_plaetze(springer, springer_frei, "Frei") }}
|
||||
</ul>
|
||||
|
||||
<h2>Verfügbare Personen</h2>
|
||||
<p class="cp-muted">Nur Personen, die „Kann" gemeldet haben. Zahlen helfen beim
|
||||
fairen Verteilen.</p>
|
||||
<ul class="cp-list">
|
||||
{% for p in kandidaten %}
|
||||
{% set f = fairness.get(p.id) %}
|
||||
<li class="cp-card">
|
||||
<strong>{{ p.anzeigename }}</strong>
|
||||
<div class="cp-muted">
|
||||
{{ (f[0] if f else 0) }} bisherige Einsätze ·
|
||||
zuletzt: {{ f[1].strftime('%d.%m.%Y') if f and f[1] else '—' }}
|
||||
</div>
|
||||
<div class="cp-actions">
|
||||
{% if haupt_frei > 0 %}
|
||||
<form method="post" action="{{ url_for('dienste.zuteilen', einsatz_id=einsatz.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="user_id" value="{{ p.id }}">
|
||||
<input type="hidden" name="platztyp" value="haupt">
|
||||
<button type="submit" class="cp-btn cp-btn--primary">Als Haupt</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if springer_frei > 0 %}
|
||||
<form method="post" action="{{ url_for('dienste.zuteilen', einsatz_id=einsatz.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="user_id" value="{{ p.id }}">
|
||||
<input type="hidden" name="platztyp" value="springer">
|
||||
<button type="submit" class="cp-btn cp-btn--ghost">Als Springer</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="cp-muted">Niemand hat „Kann" gemeldet (oder alle sind schon zugeteilt).</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
47
app/templates/dienste_liste.html
Normal file
47
app/templates/dienste_liste.html
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{% extends shell_base %}
|
||||
{% from "_zeit.html" import zeitspanne %}
|
||||
|
||||
{% block title %}Dienstplan · CheckPoint Ehrenamt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Dienstplan</h1>
|
||||
<p class="cp-muted">Wähle einen Zeitraum oder Einzeltermin, um Haupt- und
|
||||
Springerplätze zu besetzen.</p>
|
||||
|
||||
<h2>Zeiträume</h2>
|
||||
<ul class="cp-list">
|
||||
{% for zr in zeitraeume %}
|
||||
<li class="cp-card">
|
||||
<a href="{{ url_for('dienste.zeitraum', zeitraum_id=zr.id) }}">
|
||||
<strong>{{ zr.name }}</strong>
|
||||
</a>
|
||||
<div class="cp-muted">
|
||||
{{ zr.start_datum.strftime('%d.%m.%Y') }} – {{ zr.end_datum.strftime('%d.%m.%Y') }}
|
||||
</div>
|
||||
<span class="cp-tag">
|
||||
{{ "Veröffentlicht" if zr.ist_veroeffentlicht else "In Planung" }}
|
||||
</span>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="cp-muted">Noch keine Planungszeiträume.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h2>Einzeltermine</h2>
|
||||
<ul class="cp-list">
|
||||
{% for e in einzeltermine %}
|
||||
<li class="cp-card">
|
||||
<a href="{{ url_for('dienste.einsatz', einsatz_id=e.id) }}">
|
||||
<strong>{{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %}</strong>
|
||||
</a>
|
||||
<div class="cp-muted">
|
||||
{{ e.datum.strftime('%a, %d.%m.%Y') }} ·
|
||||
{{ zeitspanne(e) }}
|
||||
</div>
|
||||
{% if e.unterbesetzt %}<span class="cp-tag cp-tag--urgent">Unterbesetzt</span>{% endif %}
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="cp-muted">Keine Einzeltermine.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
47
app/templates/dienste_zeitraum.html
Normal file
47
app/templates/dienste_zeitraum.html
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{% extends shell_base %}
|
||||
{% from "_zeit.html" import zeitspanne %}
|
||||
|
||||
{% block title %}{{ zeitraum.name }} · Dienstplan{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p><a class="cp-muted" href="{{ url_for('dienste.liste') }}">← Dienstplan</a></p>
|
||||
|
||||
<h1>{{ zeitraum.name }}</h1>
|
||||
<div class="cp-muted">
|
||||
{{ zeitraum.start_datum.strftime('%d.%m.%Y') }} – {{ zeitraum.end_datum.strftime('%d.%m.%Y') }}
|
||||
</div>
|
||||
|
||||
{% if zeitraum.ist_veroeffentlicht %}
|
||||
<p class="cp-tag">Veröffentlicht · Verfügbarkeit gesperrt</p>
|
||||
{% else %}
|
||||
<form method="post"
|
||||
action="{{ url_for('dienste.veroeffentlichen', zeitraum_id=zeitraum.id) }}"
|
||||
class="cp-actions"
|
||||
onsubmit="return confirm('Zeitraum veröffentlichen? Danach ist die Verfügbarkeit gesperrt.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="cp-btn cp-btn--primary">Veröffentlichen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<h2>Einsätze</h2>
|
||||
<ul class="cp-list">
|
||||
{% for e in zeitraum.einsaetze %}
|
||||
{% set s = stand[e.id] %}
|
||||
<li class="cp-card">
|
||||
<a href="{{ url_for('dienste.einsatz', einsatz_id=e.id) }}">
|
||||
<strong>{{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %}</strong>
|
||||
</a>
|
||||
<div class="cp-muted">
|
||||
{{ e.datum.strftime('%a, %d.%m.%Y') }} ·
|
||||
{{ zeitspanne(e) }}
|
||||
</div>
|
||||
<div class="cp-muted">
|
||||
{{ s[0] }}/{{ e.HAUPT_PLAETZE }} Haupt · {{ s[1] }}/{{ e.SPRINGER_PLAETZE }} Springer
|
||||
</div>
|
||||
{% if e.unterbesetzt %}<span class="cp-tag cp-tag--urgent">Unterbesetzt</span>{% endif %}
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="cp-muted">Dieser Zeitraum hat noch keine Einsätze.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
99
app/templates/dokumente.html
Normal file
99
app/templates/dokumente.html
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
{% extends shell_base %}
|
||||
|
||||
{% block title %}Dokumente · CheckPoint Ehrenamt{% endblock %}
|
||||
|
||||
{% macro dokument_karte(d, mit_verschieben=False) %}
|
||||
<li class="cp-card">
|
||||
<a href="{{ url_for('dokumente.datei', dok_id=d.id) }}">
|
||||
<strong>{{ d.titel }}</strong>
|
||||
</a>
|
||||
<div class="cp-muted">
|
||||
{{ d.uploader.anzeigename }} · {{ d.erstellt_am.strftime('%d.%m.%Y') }}
|
||||
</div>
|
||||
{% if mit_verschieben and current_user.ist_admin %}
|
||||
<div class="cp-actions">
|
||||
<form method="post" action="{{ url_for('dokumente.verschieben', dok_id=d.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="cp-btn cp-btn--ghost">
|
||||
{{ "Ins Archiv" if d.ablage == "aktuell" else "Zurück zu Aktuell" }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<p><a class="cp-muted" href="{{ url_for('main.team') }}">← Team</a></p>
|
||||
<h1>Dokumente</h1>
|
||||
|
||||
{% if current_user.ist_admin and offene_freigaben %}
|
||||
<div class="cp-actions">
|
||||
<a class="cp-btn cp-btn--primary" href="{{ url_for('dokumente.freigaben') }}">
|
||||
Freigaben ({{ offene_freigaben }})
|
||||
</a>
|
||||
</div>
|
||||
{% elif current_user.ist_admin %}
|
||||
<div class="cp-actions">
|
||||
<a class="cp-btn cp-btn--ghost" href="{{ url_for('dokumente.freigaben') }}">Freigaben</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>Aktuell</h2>
|
||||
<ul class="cp-list">
|
||||
{% for d in aktuell %}
|
||||
{{ dokument_karte(d, mit_verschieben=True) }}
|
||||
{% else %}
|
||||
<li class="cp-muted">Noch keine freigegebenen Dokumente.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% if archiv %}
|
||||
<h2>Archiv</h2>
|
||||
<ul class="cp-list">
|
||||
{% for d in archiv %}
|
||||
{{ dokument_karte(d, mit_verschieben=True) }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if eigene_wartend %}
|
||||
<h2>Meine Uploads in Prüfung</h2>
|
||||
<p class="cp-muted">Nur du und der Admin sehen diese, bis sie freigegeben sind.</p>
|
||||
<ul class="cp-list">
|
||||
{% for d in eigene_wartend %}
|
||||
<li class="cp-card">
|
||||
<strong>{{ d.titel }}</strong>
|
||||
<div class="cp-muted">{{ d.erstellt_am.strftime('%d.%m.%Y') }}</div>
|
||||
{% if d.status == "abgelehnt" %}
|
||||
<span class="cp-tag cp-tag--urgent">Abgelehnt</span>
|
||||
{% if d.ablehnungsgrund %}
|
||||
<div class="cp-muted">Grund: {{ d.ablehnungsgrund }}</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="cp-tag">Wartet auf Freigabe</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<h2>Dokument hochladen</h2>
|
||||
<p class="cp-muted">PDF, Word, Excel oder Bild, max. 10 MB. Fotos nur ohne
|
||||
erkennbare Dritte bzw. mit deren Einverständnis.</p>
|
||||
<form method="post" action="{{ url_for('dokumente.upload') }}"
|
||||
enctype="multipart/form-data" class="cp-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="cp-field">
|
||||
<label for="titel">Titel</label>
|
||||
<input type="text" id="titel" name="titel" required
|
||||
placeholder="z. B. Hygienekonzept 2026">
|
||||
</div>
|
||||
<div class="cp-field">
|
||||
<label for="datei">Datei</label>
|
||||
<input type="file" id="datei" name="datei" required
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.gif,.webp">
|
||||
</div>
|
||||
<button type="submit" class="cp-btn cp-btn--primary">Hochladen</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
42
app/templates/dokumente_freigaben.html
Normal file
42
app/templates/dokumente_freigaben.html
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{% extends shell_base %}
|
||||
|
||||
{% block title %}Freigaben · CheckPoint Ehrenamt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p><a class="cp-muted" href="{{ url_for('dokumente.liste') }}">← Dokumente</a></p>
|
||||
<h1>Freigaben</h1>
|
||||
<p class="cp-muted">Hochgeladene Dokumente, die auf deine Entscheidung warten.</p>
|
||||
|
||||
<ul class="cp-list">
|
||||
{% for d in wartend %}
|
||||
<li class="cp-card">
|
||||
<a href="{{ url_for('dokumente.datei', dok_id=d.id) }}">
|
||||
<strong>{{ d.titel }}</strong>
|
||||
</a>
|
||||
<div class="cp-muted">
|
||||
{{ d.uploader.anzeigename }} · {{ d.erstellt_am.strftime('%d.%m.%Y') }}
|
||||
</div>
|
||||
|
||||
<div class="cp-actions">
|
||||
<form method="post" action="{{ url_for('dokumente.freigeben', dok_id=d.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="cp-btn cp-btn--primary">Freigeben</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('dokumente.ablehnen', dok_id=d.id) }}"
|
||||
class="cp-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="cp-field">
|
||||
<label for="grund-{{ d.id }}">Grund <span class="cp-muted">(optional)</span></label>
|
||||
<input type="text" id="grund-{{ d.id }}" name="grund"
|
||||
placeholder="z. B. unscharf, falsche Version">
|
||||
</div>
|
||||
<button type="submit" class="cp-btn cp-btn--urgent">Ablehnen</button>
|
||||
</form>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="cp-muted">Zurzeit nichts zu prüfen.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
23
app/templates/einsatz_bearbeiten.html
Normal file
23
app/templates/einsatz_bearbeiten.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{% extends shell_base %}
|
||||
|
||||
{% block title %}Einsatz bearbeiten · CheckPoint Ehrenamt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p>
|
||||
{% if zeitraum %}
|
||||
<a class="cp-muted" href="{{ url_for('planung.detail', zeitraum_id=zeitraum.id) }}">← {{ zeitraum.name }}</a>
|
||||
{% else %}
|
||||
<a class="cp-muted" href="{{ url_for('planung.einzeltermine') }}">← Einzeltermine</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<h1>Einsatz bearbeiten</h1>
|
||||
|
||||
<form method="post"
|
||||
action="{{ url_for('planung.einsatz_bearbeiten', einsatz_id=einsatz.id) }}"
|
||||
class="cp-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% include "_einsatz_felder.html" with context %}
|
||||
<button type="submit" class="cp-btn cp-btn--primary">Speichern</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
45
app/templates/einzeltermine.html
Normal file
45
app/templates/einzeltermine.html
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{% extends shell_base %}
|
||||
{% from "_zeit.html" import zeitspanne %}
|
||||
|
||||
{% block title %}Einzeltermine · CheckPoint Ehrenamt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p><a class="cp-muted" href="{{ url_for('planung.liste') }}">← Planung</a></p>
|
||||
|
||||
<h1>Einzeltermine</h1>
|
||||
<p class="cp-muted">Einzelne Termine ohne Planungszeitraum – direkt zum Besetzen.</p>
|
||||
|
||||
<h2>Termine</h2>
|
||||
<ul class="cp-list">
|
||||
{% for e in termine %}
|
||||
<li class="cp-card">
|
||||
<strong>{{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %}</strong>
|
||||
<div class="cp-muted">
|
||||
{{ e.datum.strftime('%a, %d.%m.%Y') }} ·
|
||||
{{ zeitspanne(e) }}
|
||||
</div>
|
||||
<div class="cp-muted">{{ e.HAUPT_PLAETZE }} Haupt + {{ e.SPRINGER_PLAETZE }} Springer</div>
|
||||
|
||||
<div class="cp-actions">
|
||||
<a class="cp-btn cp-btn--ghost"
|
||||
href="{{ url_for('planung.einsatz_bearbeiten', einsatz_id=e.id) }}">Bearbeiten</a>
|
||||
<form method="post"
|
||||
action="{{ url_for('planung.einsatz_loeschen', einsatz_id=e.id) }}"
|
||||
onsubmit="return confirm('Diesen Einzeltermin löschen?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="cp-btn cp-btn--ghost">Löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="cp-muted">Noch keine Einzeltermine.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h2>Einzeltermin hinzufügen</h2>
|
||||
<form method="post" action="{{ url_for('planung.einzeltermine') }}" class="cp-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% include "_einsatz_felder.html" with context %}
|
||||
<button type="submit" class="cp-btn cp-btn--primary">Einzeltermin hinzufügen</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
21
app/templates/index.html
Normal file
21
app/templates/index.html
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{% extends shell_base %}
|
||||
|
||||
{% block title %}Start · CheckPoint Ehrenamt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Hallo {{ user.anzeigename }}</h1>
|
||||
|
||||
{% if user.ist_admin %}
|
||||
{# Admin-Shell: die Sidebar trägt die Navigation, hier keine Kachel-Buttons. #}
|
||||
<p class="cp-muted">Willkommen im Verwaltungsbereich. Wähle links eine Aufgabe –
|
||||
Planung, Dienste, Freigaben, Team oder Chat-Verwaltung. Nutzer anlegen findest
|
||||
du unter „Team".</p>
|
||||
{% else %}
|
||||
<p class="cp-muted">Schön, dass du da bist. Deine Einsätze findest du unter
|
||||
„Termine", Team und Dokumente unter „Team".</p>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="cp-btn cp-btn--ghost">Abmelden</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
32
app/templates/login.html
Normal file
32
app/templates/login.html
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{% extends shell_base %}
|
||||
|
||||
{% block title %}Anmelden · CheckPoint Ehrenamt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Anmelden</h1>
|
||||
|
||||
<form method="post" action="{{ url_for('auth.login') }}" class="cp-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if mehrere_teams %}
|
||||
<div class="cp-field">
|
||||
<label for="team">Team-Kürzel</label>
|
||||
<input type="text" id="team" name="team"
|
||||
autocapitalize="none" autocomplete="off" required
|
||||
placeholder="z. B. checkpoint">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="cp-field">
|
||||
<label for="nutzername">Nutzername</label>
|
||||
<input type="text" id="nutzername" name="nutzername"
|
||||
autocomplete="username" autocapitalize="none" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="cp-field">
|
||||
<label for="passwort">Passwort</label>
|
||||
<input type="password" id="passwort" name="passwort"
|
||||
autocomplete="current-password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="cp-btn cp-btn--primary">Anmelden</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
71
app/templates/meine_termine.html
Normal file
71
app/templates/meine_termine.html
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
{% extends shell_base %}
|
||||
{% from "_zeit.html" import zeitspanne %}
|
||||
|
||||
{% block title %}Meine Termine · CheckPoint Ehrenamt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Meine Termine</h1>
|
||||
|
||||
{% if offene_planung %}
|
||||
<div class="cp-actions">
|
||||
<a class="cp-btn cp-btn--ghost" href="{{ url_for('planung.verfuegbarkeit') }}">Verfügbarkeit melden</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<ul class="cp-list">
|
||||
{% for z, e in termine %}
|
||||
<li class="cp-card">
|
||||
<strong>{{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %}</strong>
|
||||
<div class="cp-muted">
|
||||
{{ e.datum.strftime('%a, %d.%m.%Y') }} ·
|
||||
{{ zeitspanne(e) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="cp-tag">{{ "Haupt" if z.platztyp == "haupt" else "Springer" }}</span>
|
||||
<span class="cp-tag">{{ "Übernommen" if z.status == "uebernommen" else "Zugeteilt" }}</span>
|
||||
</div>
|
||||
<div class="cp-actions">
|
||||
<form method="post" action="{{ url_for('dienste.absagen', zuteilung_id=z.id) }}"
|
||||
onsubmit="return confirm('Diesen Dienst absagen?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="cp-btn cp-btn--urgent">Absagen</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="cp-muted">Du hast aktuell keine zugeteilten Termine. Sobald ein
|
||||
Dienstplan veröffentlicht ist, erscheinen deine Einsätze hier.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% if offene %}
|
||||
<h2>Offene Dienste</h2>
|
||||
<p class="cp-muted">Hier ist jemand abgesprungen. Wer zuerst übernimmt, bekommt
|
||||
den Platz.</p>
|
||||
<ul class="cp-list">
|
||||
{% for z, e in offene %}
|
||||
<li class="cp-card">
|
||||
<strong>{{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %}</strong>
|
||||
<div class="cp-muted">
|
||||
{{ e.datum.strftime('%a, %d.%m.%Y') }} ·
|
||||
{{ zeitspanne(e) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="cp-tag cp-tag--urgent">Offen</span>
|
||||
<span class="cp-tag">{{ "Haupt" if z.platztyp == "haupt" else "Springer" }}</span>
|
||||
</div>
|
||||
{% if e.id in eigene_einsatz_ids %}
|
||||
<div class="cp-muted">Du bist diesem Einsatz bereits zugeteilt.</div>
|
||||
{% else %}
|
||||
<div class="cp-actions">
|
||||
<form method="post" action="{{ url_for('dienste.uebernehmen', zuteilung_id=z.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="cp-btn cp-btn--primary">Übernehmen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
50
app/templates/nutzer_neu.html
Normal file
50
app/templates/nutzer_neu.html
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
{% extends shell_base %}
|
||||
|
||||
{% block title %}Nutzer · CheckPoint Ehrenamt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Nutzer anlegen</h1>
|
||||
|
||||
<form method="post" action="{{ url_for('auth.nutzer_anlegen') }}" class="cp-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="cp-field">
|
||||
<label for="anzeigename">Anzeigename</label>
|
||||
<input type="text" id="anzeigename" name="anzeigename" required>
|
||||
</div>
|
||||
|
||||
<div class="cp-field">
|
||||
<label for="nutzername">Nutzername</label>
|
||||
<input type="text" id="nutzername" name="nutzername"
|
||||
autocapitalize="none" required>
|
||||
</div>
|
||||
|
||||
<div class="cp-field">
|
||||
<label for="passwort">Passwort</label>
|
||||
<input type="password" id="passwort" name="passwort"
|
||||
autocomplete="new-password" required>
|
||||
</div>
|
||||
|
||||
<div class="cp-field">
|
||||
<label for="rolle">Rolle</label>
|
||||
<select id="rolle" name="rolle">
|
||||
<option value="ehrenamt">Ehrenamtliche:r</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="cp-btn cp-btn--primary">Anlegen</button>
|
||||
</form>
|
||||
|
||||
<h2>Team</h2>
|
||||
<ul class="cp-list">
|
||||
{% for u in nutzer %}
|
||||
<li>
|
||||
<strong>{{ u.anzeigename }}</strong>
|
||||
<span class="cp-muted">· {{ u.nutzername }}</span>
|
||||
<span class="cp-tag">{{ "Admin" if u.ist_admin else "Ehrenamt" }}</span>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="cp-muted">Noch keine Nutzer.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
56
app/templates/planung_detail.html
Normal file
56
app/templates/planung_detail.html
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
{% extends shell_base %}
|
||||
{% from "_zeit.html" import zeitspanne %}
|
||||
|
||||
{% block title %}{{ zeitraum.name }} · CheckPoint Ehrenamt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p><a class="cp-muted" href="{{ url_for('planung.liste') }}">← Planung</a></p>
|
||||
|
||||
<h1>{{ zeitraum.name }}</h1>
|
||||
<div class="cp-muted">
|
||||
{{ zeitraum.start_datum.strftime('%d.%m.%Y') }} – {{ zeitraum.end_datum.strftime('%d.%m.%Y') }}
|
||||
</div>
|
||||
<span class="cp-tag">
|
||||
{{ "Veröffentlicht" if zeitraum.ist_veroeffentlicht else "In Planung" }}
|
||||
</span>
|
||||
|
||||
<h2>Einsätze</h2>
|
||||
<ul class="cp-list">
|
||||
{% for e in einsaetze %}
|
||||
<li class="cp-card">
|
||||
<strong>{{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %}</strong>
|
||||
<div class="cp-muted">
|
||||
{{ e.datum.strftime('%a, %d.%m.%Y') }} ·
|
||||
{{ zeitspanne(e) }}
|
||||
</div>
|
||||
<div class="cp-muted">{{ e.HAUPT_PLAETZE }} Haupt + {{ e.SPRINGER_PLAETZE }} Springer</div>
|
||||
|
||||
{% if not zeitraum.ist_veroeffentlicht %}
|
||||
<div class="cp-actions">
|
||||
<a class="cp-btn cp-btn--ghost"
|
||||
href="{{ url_for('planung.einsatz_bearbeiten', einsatz_id=e.id) }}">
|
||||
Bearbeiten
|
||||
</a>
|
||||
<form method="post"
|
||||
action="{{ url_for('planung.einsatz_loeschen', einsatz_id=e.id) }}"
|
||||
onsubmit="return confirm('Diesen Einsatz löschen?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="cp-btn cp-btn--ghost">Löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="cp-muted">Noch keine Einsätze.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% if not zeitraum.ist_veroeffentlicht %}
|
||||
<h2>Einsatz hinzufügen</h2>
|
||||
<form method="post" action="{{ url_for('planung.detail', zeitraum_id=zeitraum.id) }}" class="cp-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% include "_einsatz_felder.html" with context %}
|
||||
<button type="submit" class="cp-btn cp-btn--primary">Einsatz hinzufügen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
52
app/templates/planung_liste.html
Normal file
52
app/templates/planung_liste.html
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{% extends shell_base %}
|
||||
|
||||
{% block title %}Planung · CheckPoint Ehrenamt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Planung</h1>
|
||||
|
||||
<div class="cp-actions">
|
||||
<a class="cp-btn cp-btn--ghost" href="{{ url_for('planung.einzeltermine') }}">Einzeltermine</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('planung.liste') }}" class="cp-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="cp-field">
|
||||
<label for="name">Name des Zeitraums</label>
|
||||
<input type="text" id="name" name="name" placeholder="z. B. Herbst 2026" required>
|
||||
</div>
|
||||
|
||||
<div class="cp-field">
|
||||
<label for="start_datum">Start</label>
|
||||
<input type="date" id="start_datum" name="start_datum" required>
|
||||
</div>
|
||||
|
||||
<div class="cp-field">
|
||||
<label for="end_datum">Ende</label>
|
||||
<input type="date" id="end_datum" name="end_datum" required>
|
||||
<small class="cp-muted">Mindestens 2 Monate.</small>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="cp-btn cp-btn--primary">Zeitraum anlegen</button>
|
||||
</form>
|
||||
|
||||
<h2>Zeiträume</h2>
|
||||
<ul class="cp-list">
|
||||
{% for zr in zeitraeume %}
|
||||
<li class="cp-card">
|
||||
<a href="{{ url_for('planung.detail', zeitraum_id=zr.id) }}">
|
||||
<strong>{{ zr.name }}</strong>
|
||||
</a>
|
||||
<div class="cp-muted">
|
||||
{{ zr.start_datum.strftime('%d.%m.%Y') }} – {{ zr.end_datum.strftime('%d.%m.%Y') }}
|
||||
</div>
|
||||
<span class="cp-tag">
|
||||
{{ "Veröffentlicht" if zr.ist_veroeffentlicht else "In Planung" }}
|
||||
</span>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="cp-muted">Noch keine Planungszeiträume.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
8
app/templates/platzhalter.html
Normal file
8
app/templates/platzhalter.html
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{% extends shell_base %}
|
||||
|
||||
{% block title %}{{ titel }} · CheckPoint Ehrenamt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ titel }}</h1>
|
||||
<p class="cp-muted">Diese Ansicht kommt in einer der nächsten Sessions.</p>
|
||||
{% endblock %}
|
||||
55
app/templates/profil.html
Normal file
55
app/templates/profil.html
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{% extends shell_base %}
|
||||
|
||||
{% block title %}Profil · CheckPoint Ehrenamt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Profil</h1>
|
||||
|
||||
<div class="cp-card">
|
||||
{% if current_user.profilbild_pfad %}
|
||||
<img class="cp-profilbild" src="{{ url_for('profil.bild', user_id=current_user.id) }}"
|
||||
alt="Profilbild von {{ current_user.anzeigename }}">
|
||||
{% else %}
|
||||
<div class="cp-profilbild cp-profilbild--leer" aria-hidden="true">
|
||||
{%- set teile = current_user.anzeigename.split() -%}
|
||||
{{ (teile[0][:1] ~ (teile[1][:1] if teile|length > 1 else ''))|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<strong>{{ current_user.anzeigename }}</strong>
|
||||
<div class="cp-muted">{{ "Admin" if current_user.ist_admin else "Ehrenamtlich" }}
|
||||
· {{ current_user.nutzername }}</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('profil.index') }}"
|
||||
enctype="multipart/form-data" class="cp-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="cp-field">
|
||||
<label for="anzeigename">Anzeigename</label>
|
||||
<input type="text" id="anzeigename" name="anzeigename" required
|
||||
value="{{ current_user.anzeigename }}">
|
||||
</div>
|
||||
|
||||
<div class="cp-field">
|
||||
<label for="profilbild">Profilbild <span class="cp-muted">(optional)</span></label>
|
||||
<input type="file" id="profilbild" name="profilbild"
|
||||
accept=".png,.jpg,.jpeg,.gif,.webp">
|
||||
<div class="cp-muted">PNG, JPG, GIF oder WebP. Ersetzt das bisherige Bild.</div>
|
||||
</div>
|
||||
|
||||
{% for feld in felder %}
|
||||
<div class="cp-field">
|
||||
<label for="{{ feld.key }}">{{ feld.label }} <span class="cp-muted">(optional)</span></label>
|
||||
{% if feld.mehrzeilig %}
|
||||
<textarea id="{{ feld.key }}" name="{{ feld.key }}" rows="3"
|
||||
placeholder="{{ feld.placeholder }}">{{ angaben.get(feld.key, '') }}</textarea>
|
||||
{% else %}
|
||||
<input type="text" id="{{ feld.key }}" name="{{ feld.key }}"
|
||||
placeholder="{{ feld.placeholder }}" value="{{ angaben.get(feld.key, '') }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<button type="submit" class="cp-btn cp-btn--primary">Speichern</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
29
app/templates/team.html
Normal file
29
app/templates/team.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{% extends shell_base %}
|
||||
|
||||
{% block title %}Team · CheckPoint Ehrenamt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Team</h1>
|
||||
|
||||
<ul class="cp-list">
|
||||
<li class="cp-card">
|
||||
<a href="{{ url_for('chat.kanal', art='ankuendigungen') }}"><strong>Ankündigungen</strong></a>
|
||||
<div class="cp-muted">Infos vom Team-Management. Alle lesen mit.</div>
|
||||
</li>
|
||||
<li class="cp-card">
|
||||
<a href="{{ url_for('chat.kanal', art='team') }}"><strong>Team-Chat</strong></a>
|
||||
<div class="cp-muted">Austausch der Gruppe – alle schreiben mit.</div>
|
||||
</li>
|
||||
<li class="cp-card">
|
||||
<a href="{{ url_for('dokumente.liste') }}"><strong>Dokumente</strong></a>
|
||||
<div class="cp-muted">Geteilte Dateien und Fotos der Gruppe.</div>
|
||||
</li>
|
||||
{% if current_user.ist_admin %}
|
||||
<li class="cp-card">
|
||||
<a href="{{ url_for('auth.nutzer_anlegen') }}"><strong>Nutzer anlegen</strong></a>
|
||||
<div class="cp-muted">Neuen Zugang für das Team einrichten (nur Admin).</div>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
|
||||
57
app/templates/verfuegbarkeit.html
Normal file
57
app/templates/verfuegbarkeit.html
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
{% extends shell_base %}
|
||||
{% from "_zeit.html" import zeitspanne %}
|
||||
|
||||
{% block title %}Termine · CheckPoint Ehrenamt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Verfügbarkeit</h1>
|
||||
|
||||
{% if not einsaetze %}
|
||||
<p class="cp-muted">Zurzeit gibt es nichts zu melden. Sobald die Planung
|
||||
startet oder ein Einzeltermin ansteht, kannst du hier pro Einsatz „Kann"
|
||||
oder „Kann nicht" melden.</p>
|
||||
{% else %}
|
||||
{% if zeitraum %}
|
||||
<p class="cp-muted">{{ zeitraum.name }} ·
|
||||
{{ zeitraum.start_datum.strftime('%d.%m.%Y') }}–{{ zeitraum.end_datum.strftime('%d.%m.%Y') }}</p>
|
||||
{% endif %}
|
||||
<p class="cp-muted">Melde pro Einsatz, ob du kannst. Änderbar, solange geplant wird.</p>
|
||||
|
||||
<ul class="cp-list">
|
||||
{% for e in einsaetze %}
|
||||
{% set wert = eigene.get(e.id) %}
|
||||
<li class="cp-card">
|
||||
<strong>{{ e.art }}{% if e.ort %} · {{ e.ort }}{% endif %}</strong>
|
||||
<div class="cp-muted">
|
||||
{{ e.datum.strftime('%a, %d.%m.%Y') }} ·
|
||||
{{ zeitspanne(e) }}
|
||||
</div>
|
||||
|
||||
<form method="post"
|
||||
action="{{ url_for('planung.verfuegbarkeit_setzen', einsatz_id=e.id) }}"
|
||||
class="cp-toggle" role="group" aria-label="Verfügbarkeit für {{ e.art }} am {{ e.datum.strftime('%d.%m.%Y') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" name="wert" value="kann"
|
||||
class="cp-toggle__btn {{ 'is-active' if wert == 'kann' else '' }}"
|
||||
aria-pressed="{{ 'true' if wert == 'kann' else 'false' }}">
|
||||
{{ '✓ ' if wert == 'kann' else '' }}Kann
|
||||
</button>
|
||||
<button type="submit" name="wert" value="kann_nicht"
|
||||
class="cp-toggle__btn {{ 'is-active' if wert == 'kann_nicht' else '' }}"
|
||||
aria-pressed="{{ 'true' if wert == 'kann_nicht' else 'false' }}">
|
||||
{{ '✓ ' if wert == 'kann_nicht' else '' }}Kann nicht
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="cp-muted" aria-live="polite">
|
||||
{% if wert == 'kann' %}Gemeldet: Kann
|
||||
{% elif wert == 'kann_nicht' %}Gemeldet: Kann nicht
|
||||
{% else %}Noch nicht gemeldet{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="cp-muted">In diesem Zeitraum gibt es noch keine Einsätze.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
18
deploy/backup-db.sh
Normal file
18
deploy/backup-db.sh
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
#!/usr/bin/env bash
|
||||
# Einfaches SQLite-Backup für CheckPoint Ehrenamt.
|
||||
# Nutzt das Online-Backup (.backup) – konsistent auch im laufenden Betrieb (WAL).
|
||||
# Per Cron einmal täglich, siehe DEPLOY.md.
|
||||
set -euo pipefail
|
||||
|
||||
DB="/opt/checkpoint/instance/checkpoint.sqlite"
|
||||
ZIEL="/opt/checkpoint/backups"
|
||||
AUFBEWAHRUNG_TAGE=14
|
||||
|
||||
mkdir -p "$ZIEL"
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
sqlite3 "$DB" ".backup '$ZIEL/checkpoint-$STAMP.sqlite'"
|
||||
|
||||
# Alte Backups aufräumen.
|
||||
find "$ZIEL" -name 'checkpoint-*.sqlite' -mtime "+$AUFBEWAHRUNG_TAGE" -delete
|
||||
|
||||
echo "Backup: $ZIEL/checkpoint-$STAMP.sqlite"
|
||||
23
deploy/checkpoint.service
Normal file
23
deploy/checkpoint.service
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[Unit]
|
||||
Description=CheckPoint Ehrenamt (gunicorn)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
# Eigener, nicht privilegierter Nutzer (siehe DEPLOY.md).
|
||||
User=checkpoint
|
||||
Group=checkpoint
|
||||
WorkingDirectory=/opt/checkpoint
|
||||
# Geheimnisse + CP_ENV kommen aus der Datei (nicht im Git): SECRET_KEY, CP_ENV.
|
||||
EnvironmentFile=/opt/checkpoint/.env
|
||||
ExecStart=/opt/checkpoint/.venv/bin/gunicorn -c gunicorn.conf.py wsgi:app
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
# Etwas Härtung des Dienstes.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ReadWritePaths=/opt/checkpoint/instance
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
31
deploy/nginx-checkpoint.conf
Normal file
31
deploy/nginx-checkpoint.conf
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# nginx-Serverblock für CheckPoint Ehrenamt (Reverse Proxy vor gunicorn).
|
||||
# Ablage: /etc/nginx/sites-available/checkpoint → nach sites-enabled verlinken.
|
||||
# certbot ergänzt den 443-Block + Zertifikate automatisch (siehe DEPLOY.md).
|
||||
# checkpoint.example.org überall durch die echte Domain ersetzen.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name checkpoint.example.org;
|
||||
|
||||
# Muss >= MAX_CONTENT_LENGTH der App (10 MB) sein, sonst lehnt nginx Uploads
|
||||
# vor der App mit 413 ab.
|
||||
client_max_body_size 12m;
|
||||
|
||||
# Statische Dateien (CSS, JS, Schriften) direkt von nginx ausliefern.
|
||||
# Uploads/Dokumente NICHT hier ausliefern – die laufen zugriffsgeschützt
|
||||
# über die App (instance/uploads ist bewusst nicht öffentlich).
|
||||
location /static/ {
|
||||
alias /opt/checkpoint/app/static/;
|
||||
expires 30d;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
19
gunicorn.conf.py
Normal file
19
gunicorn.conf.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""gunicorn-Konfiguration für CheckPoint Ehrenamt.
|
||||
|
||||
Start (über systemd, siehe deploy/checkpoint.service):
|
||||
gunicorn -c gunicorn.conf.py wsgi:app
|
||||
"""
|
||||
|
||||
# Nur lokal lauschen – nginx ist der öffentliche Eingang (Reverse Proxy).
|
||||
bind = "127.0.0.1:8000"
|
||||
|
||||
# Klein halten: 17 Nutzer, ein VPS. Mehrere Worker sind dank SQLite-WAL ok.
|
||||
workers = 3
|
||||
threads = 2
|
||||
worker_class = "gthread"
|
||||
timeout = 60
|
||||
|
||||
# Logs an stdout/stderr → systemd/journald fängt sie auf.
|
||||
accesslog = "-"
|
||||
errorlog = "-"
|
||||
loglevel = "info"
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Flask
|
||||
Flask-SQLAlchemy
|
||||
gunicorn
|
||||
11
run.py
Normal file
11
run.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""Entrypoint. Startet die App über die Factory.
|
||||
|
||||
Lokal: flask --app run run (oder) python run.py
|
||||
"""
|
||||
|
||||
from app import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True)
|
||||
57
seed.py
Normal file
57
seed.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""Seed-Skript.
|
||||
|
||||
Legt das erste Team (CheckPoint) mit seinen Brand-Tokens und den zwei festen
|
||||
Kanälen (ankuendigungen, team) an. Idempotent: mehrfaches Ausführen erzeugt
|
||||
keine Duplikate.
|
||||
|
||||
Aufruf (pyenv-Umgebung):
|
||||
python seed.py
|
||||
"""
|
||||
|
||||
from app import create_app, db
|
||||
from app.models import Kanal, Team
|
||||
|
||||
CHECKPOINT_NAME = "CheckPoint"
|
||||
CHECKPOINT_SLUG = "checkpoint"
|
||||
|
||||
|
||||
def seed():
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
team = Team.query.filter_by(name=CHECKPOINT_NAME).first()
|
||||
if team is None:
|
||||
team = Team(
|
||||
name=CHECKPOINT_NAME,
|
||||
slug=CHECKPOINT_SLUG,
|
||||
brand_primary="#21a8ad",
|
||||
brand_primary_strong="#147f84",
|
||||
brand_accent="#ef1823",
|
||||
brand_accent_strong="#bd0f18",
|
||||
brand_font='"DM Sans", Arial, Helvetica, sans-serif',
|
||||
)
|
||||
db.session.add(team)
|
||||
db.session.flush() # team.id verfügbar machen
|
||||
print(f"Team angelegt: {team.name} (id {team.id})")
|
||||
else:
|
||||
if not team.slug:
|
||||
team.slug = CHECKPOINT_SLUG
|
||||
print(f"Team-Kürzel nachgetragen: {team.slug}")
|
||||
print(f"Team existiert bereits: {team.name} (id {team.id})")
|
||||
|
||||
# Die zwei festen Kanäle sicherstellen.
|
||||
for art in (Kanal.ART_ANKUENDIGUNGEN, Kanal.ART_TEAM):
|
||||
vorhanden = Kanal.query.filter_by(team_id=team.id, art=art).first()
|
||||
if vorhanden is None:
|
||||
db.session.add(Kanal(team_id=team.id, art=art))
|
||||
print(f"Kanal angelegt: {art}")
|
||||
else:
|
||||
print(f"Kanal existiert bereits: {art}")
|
||||
|
||||
db.session.commit()
|
||||
print("Seed fertig.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed()
|
||||
334
tokens.css
Normal file
334
tokens.css
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
/* =============================================================================
|
||||
CheckPoint Ehrenamt · tokens.css
|
||||
Mandantenfähiges Token-System mit Hell- und Dunkelmodus.
|
||||
ZWEI ROLLEN-THEMES IN EINER DATEI: User (Mobile, Bottom-Nav) und
|
||||
Admin (Desktop-Sidebar, mobil per Off-Canvas). Jedes Theme ist für sich
|
||||
mandantenfähig — ein neues Team tauscht beide Brand-Blöcke.
|
||||
|
||||
AUFBAU (pro Theme dieselben drei Schichten):
|
||||
1) BRAND – pro Team austauschbar. Ein zweites Team ändert NUR diese Werte.
|
||||
2) BASE – theme-unabhängig: Abstände, Radien, Schrift, Transitions.
|
||||
3) SEMANTIK – hell + dunkel in einem, gelöst über die CSS-Funktion light-dark().
|
||||
|
||||
NAMENSRAUM:
|
||||
--cp-user-* → Token für die Ehrenamtlichen-Oberfläche (Bottom-Nav-Shell)
|
||||
--cp-admin-* → Token für die Admin-Oberfläche (Sidebar-Shell)
|
||||
Komponenten-CSS wählt je Shell den passenden Namensraum. Es gibt keine
|
||||
gemeinsamen Farb-Tokens mehr ohne Präfix — das verhindert versehentliches
|
||||
Mischen der beiden Looks.
|
||||
|
||||
THEME-STEUERUNG (kein JavaScript nötig):
|
||||
- Standard folgt der Systemeinstellung (prefers-color-scheme) automatisch.
|
||||
- Optionaler manueller Override später: <html data-theme="dark"> bzw. "light".
|
||||
|
||||
Hinweis: Diese Datei definiert nur Variablen. Komponenten-CSS muss diese
|
||||
Tokens verwenden (z. B. background: var(--cp-user-surface)), damit der
|
||||
Dunkelmodus wirklich greift – nirgends feste Weiß-/Schwarzwerte verdrahten.
|
||||
============================================================================= */
|
||||
|
||||
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;500;700&family=Roboto+Mono:wght@400;500&family=Alfa+Slab+One&display=swap");
|
||||
|
||||
/* #############################################################################
|
||||
TEIL A · USER-THEME (Ehrenamtliche, Mobile, Bottom-Nav)
|
||||
############################################################################# */
|
||||
/* =============================================================================
|
||||
A.1 · BRAND (pro Mandant überschreiben — User-Theme)
|
||||
----------------------------------------------------------------------------
|
||||
Für ein weiteres Team genügt es, diesen Block zu ersetzen – am besten
|
||||
serverseitig pro Team eingespielt, z. B.:
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--cp-user-brand-primary: #21a8ad;
|
||||
--cp-user-brand-primary-strong: #147f84;
|
||||
--cp-user-brand-accent: #ef1823;
|
||||
--cp-user-brand-accent-strong: #bd0f18;
|
||||
--cp-user-brand-font: "DM Sans", Arial, Helvetica, sans-serif;
|
||||
}
|
||||
</style>
|
||||
|
||||
Alle getönten Flächen, Fokus-Ringe und Status-Farben werden aus diesen
|
||||
wenigen Werten abgeleitet – das Team muss nichts weiter pflegen.
|
||||
============================================================================= */
|
||||
:root {
|
||||
--cp-user-brand-primary: #21a8ad; /* Türkis – Hauptfarbe */
|
||||
--cp-user-brand-primary-strong: #147f84; /* Türkis dunkel – Links, starke Akzente */
|
||||
--cp-user-brand-accent: #ef1823; /* Rot – dringende Aktionen, Warnungen */
|
||||
--cp-user-brand-accent-strong: #bd0f18; /* Rot dunkel – Hover, starke Akzente */
|
||||
--cp-user-brand-font: "DM Sans", Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
A.2 · BASE (theme-unabhängig — User-Theme)
|
||||
============================================================================= */
|
||||
:root {
|
||||
--cp-user-font: var(--cp-user-brand-font);
|
||||
|
||||
--cp-user-radius-sm: 6px;
|
||||
--cp-user-radius-md: 8px;
|
||||
--cp-user-radius-lg: 12px;
|
||||
--cp-user-radius-xl: 16px;
|
||||
--cp-user-radius-pill: 999px;
|
||||
|
||||
--cp-user-space-1: 0.25rem;
|
||||
--cp-user-space-2: 0.5rem;
|
||||
--cp-user-space-3: 0.75rem;
|
||||
--cp-user-space-4: 1rem;
|
||||
--cp-user-space-5: 1.25rem;
|
||||
--cp-user-space-6: 1.5rem;
|
||||
--cp-user-space-8: 2rem;
|
||||
|
||||
--cp-user-transition: 160ms ease;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
A.3 · SEMANTIK (hell + dunkel via light-dark — User-Theme)
|
||||
----------------------------------------------------------------------------
|
||||
light-dark(HELL, DUNKEL) liefert automatisch den passenden Wert, sobald
|
||||
color-scheme aktiv ist. Reihenfolge der Argumente: immer HELL zuerst.
|
||||
============================================================================= */
|
||||
:root {
|
||||
/* --- Flächen & Hintergründe ------------------------------------------- */
|
||||
--cp-user-bg: light-dark(#f5f7f8, #0f1518); /* Seitenhintergrund */
|
||||
--cp-user-surface: light-dark(#ffffff, #161f22); /* Karten, Header, Formulare */
|
||||
--cp-user-surface-alt: light-dark(#f0f5f6, #1d272b); /* neutrale Chips, abgesetzt */
|
||||
|
||||
/* --- Text -------------------------------------------------------------- */
|
||||
--cp-user-text: light-dark(#253033, #e8eef0); /* Haupttext */
|
||||
--cp-user-muted: light-dark(#66777c, #9fb1b5); /* Zusatz-/Meta-Text */
|
||||
--cp-user-faint: light-dark(#9aabad, #6b7c80); /* inaktiv, sehr leise */
|
||||
|
||||
/* --- Linien ------------------------------------------------------------ */
|
||||
--cp-user-line: light-dark(rgba(37, 48, 51, 0.13), rgba(232, 238, 240, 0.14));
|
||||
|
||||
/* --- Markenfarben (Buttons, aktive Zustände) -------------------------- */
|
||||
--cp-user-teal: var(--cp-user-brand-primary);
|
||||
--cp-user-red: var(--cp-user-brand-accent);
|
||||
|
||||
/* "dark"-Varianten = starke Akzent-/Textfarbe. Im Dunkelmodus heller,
|
||||
damit sie auf dunklem Grund lesbar bleibt. */
|
||||
--cp-user-teal-dark: light-dark(var(--cp-user-brand-primary-strong), #5fd0d4);
|
||||
--cp-user-red-dark: light-dark(var(--cp-user-brand-accent-strong), #ff6b73);
|
||||
|
||||
/* --- Getönte Flächen (aus der Markenfarbe abgeleitet) ----------------- */
|
||||
--cp-user-teal-soft: light-dark(
|
||||
color-mix(in srgb, var(--cp-user-brand-primary) 14%, white),
|
||||
color-mix(in srgb, var(--cp-user-brand-primary) 26%, transparent)
|
||||
);
|
||||
--cp-user-red-soft: light-dark(
|
||||
color-mix(in srgb, var(--cp-user-brand-accent) 12%, white),
|
||||
color-mix(in srgb, var(--cp-user-brand-accent) 26%, transparent)
|
||||
);
|
||||
|
||||
/* --- Info (sekundäre Infoflächen) -------------------------------------- */
|
||||
--cp-user-info: light-dark(#2b86c5, #6fb7e6);
|
||||
--cp-user-blue-soft: light-dark(#dff4fb, color-mix(in srgb, #4aa3dd 22%, transparent));
|
||||
|
||||
/* --- Fokus-Ring (aus der Markenfarbe abgeleitet) ---------------------- */
|
||||
--cp-user-focus: light-dark(
|
||||
color-mix(in srgb, var(--cp-user-brand-primary) 34%, transparent),
|
||||
color-mix(in srgb, var(--cp-user-brand-primary) 55%, transparent)
|
||||
);
|
||||
|
||||
/* --- Schatten --------------------------------------------------------- */
|
||||
--cp-user-shadow-color: light-dark(rgba(20, 52, 58, 0.12), rgba(0, 0, 0, 0.55));
|
||||
--cp-user-shadow-sm: 0 1px 3px var(--cp-user-shadow-color);
|
||||
--cp-user-shadow-md: 0 10px 28px var(--cp-user-shadow-color);
|
||||
|
||||
/* --- Status-Mapping (semantisch, für Badges/Hinweise) ----------------- */
|
||||
/* Türkis = offen/bestätigt/verfügbar · Rot = dringend/Vertretung ·
|
||||
Neutral = Info/intern/erledigt. */
|
||||
--cp-user-status-ok-bg: var(--cp-user-teal-soft);
|
||||
--cp-user-status-ok-text: var(--cp-user-teal-dark);
|
||||
--cp-user-status-urgent-bg: var(--cp-user-red-soft);
|
||||
--cp-user-status-urgent-text: var(--cp-user-red-dark);
|
||||
--cp-user-status-neutral-bg: var(--cp-user-surface-alt);
|
||||
--cp-user-status-neutral-text: var(--cp-user-muted);
|
||||
}
|
||||
|
||||
/* #############################################################################
|
||||
TEIL B · ADMIN-THEME (Sidebar-Verwaltungsoberfläche, Desktop + mobiles
|
||||
Off-Canvas-Menü). Eigener, vollständig getrennter Tokensatz — bewusst kein
|
||||
Vermischen mit den User-Tokens, damit ein Team beide Looks unabhängig
|
||||
pflegen oder austauschen kann.
|
||||
############################################################################# */
|
||||
|
||||
/* =============================================================================
|
||||
B.1 · BRAND (pro Mandant überschreiben — Admin-Theme)
|
||||
----------------------------------------------------------------------------
|
||||
Quelle: "Checkpoint Koeln UI Corporate Design". Ein zweites Team ersetzt
|
||||
nur diesen Block plus die Schrift-Imports oben in der Datei.
|
||||
============================================================================= */
|
||||
:root {
|
||||
--cp-admin-brand-navy: #1b1f30; /* Sidebar, Tabellenkopf */
|
||||
--cp-admin-brand-navy-soft: #252a3d; /* Sidebar-Trenner, dunkler Hover */
|
||||
--cp-admin-brand-teal: #01b2bb; /* Hauptmarkenfarbe, aktive Nav */
|
||||
--cp-admin-brand-teal-ui: #11b6c9; /* Primärbuttons, Floating Actions */
|
||||
--cp-admin-brand-teal-dark: #139ead; /* Hover, aktive Buttons */
|
||||
--cp-admin-brand-yellow: #fbef1f; /* Sidebar-Icons, Markenhighlight */
|
||||
--cp-admin-brand-pink: #e6037f; /* Logo-/Markenakzent */
|
||||
--cp-admin-brand-red: #e62143; /* Fehler, Abbruch, Warnung */
|
||||
--cp-admin-brand-red-dark: #c80f3d; /* Danger-Buttons, Hover */
|
||||
--cp-admin-brand-orange: #f59e18; /* sekundäre Hinweise */
|
||||
--cp-admin-brand-green: #168a2d; /* Erfolg, verfügbar, bestätigt */
|
||||
--cp-admin-brand-font-display: "Alfa Slab One", "Roboto Slab", Georgia, serif;
|
||||
--cp-admin-brand-font-ui: "Roboto Condensed", "Arial Narrow", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
B.2 · BASE (theme-unabhängig — Admin-Theme)
|
||||
============================================================================= */
|
||||
:root {
|
||||
--cp-admin-font-display: var(--cp-admin-brand-font-display);
|
||||
--cp-admin-font-ui: var(--cp-admin-brand-font-ui);
|
||||
--cp-admin-font-mono: "Roboto Mono", Consolas, monospace;
|
||||
|
||||
--cp-admin-radius-sm: 6px;
|
||||
--cp-admin-radius-md: 10px;
|
||||
--cp-admin-radius-lg: 14px;
|
||||
|
||||
--cp-admin-space-1: 4px;
|
||||
--cp-admin-space-2: 8px;
|
||||
--cp-admin-space-3: 12px;
|
||||
--cp-admin-space-4: 16px;
|
||||
--cp-admin-space-5: 24px;
|
||||
--cp-admin-space-6: 32px;
|
||||
|
||||
--cp-admin-sidebar-width: 260px;
|
||||
--cp-admin-transition: 160ms ease;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
B.3 · SEMANTIK (hell + dunkel via light-dark — Admin-Theme)
|
||||
----------------------------------------------------------------------------
|
||||
Das Quelldokument beschreibt nur einen hellen Look (helle Arbeitsfläche +
|
||||
dunkle Sidebar). Die Sidebar bleibt darum in BEIDEN Modi dunkel (Navy) —
|
||||
das ist so gewollt, sie ist ja schon das "dunkle Element" im Hellmodus.
|
||||
Im Dunkelmodus wird nur die Arbeitsfläche dunkler, die Sidebar bleibt das
|
||||
immer gleiche Marken-Navy. So muss am Logo/Sidebar-Look nichts wechseln.
|
||||
============================================================================= */
|
||||
:root {
|
||||
/* --- Sidebar (in beiden Themes gleich dunkel = Markenelement) ---------- */
|
||||
--cp-admin-sidebar-bg: var(--cp-admin-brand-navy);
|
||||
--cp-admin-sidebar-hover: var(--cp-admin-brand-navy-soft);
|
||||
--cp-admin-sidebar-text: light-dark(rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.92));
|
||||
--cp-admin-sidebar-text-muted: light-dark(rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.6));
|
||||
--cp-admin-sidebar-icon: var(--cp-admin-brand-yellow);
|
||||
--cp-admin-sidebar-active-bg: var(--cp-admin-brand-teal);
|
||||
--cp-admin-sidebar-active-text: #ffffff;
|
||||
|
||||
/* --- Arbeitsfläche & Flächen -------------------------------------------- */
|
||||
--cp-admin-bg: light-dark(#f5f7fa, #0e1320); /* Seitenhintergrund */
|
||||
--cp-admin-bg-cool: light-dark(#f5fafb, #0c1722); /* kühlerer Arbeitsbereich */
|
||||
--cp-admin-surface: light-dark(#ffffff, #161c2b); /* Karten, Panels, Formulare */
|
||||
--cp-admin-surface-soft: light-dark(#eef9fb, #16242c); /* Panel-Header, Akkordeons */
|
||||
|
||||
/* --- Text ---------------------------------------------------------------- */
|
||||
--cp-admin-text: light-dark(#1f2b37, #e6ecf1); /* Haupttext */
|
||||
--cp-admin-heading: light-dark(#090b10, #f5f8fa); /* Seitentitel, starke Headlines */
|
||||
--cp-admin-muted: light-dark(#6e8190, #93a3b0); /* Labels, Meta-Informationen */
|
||||
--cp-admin-muted-2: light-dark(#9ba7b1, #6b7a85); /* inaktive Icons, sehr leise */
|
||||
|
||||
/* --- Linien --------------------------------------------------------------- */
|
||||
--cp-admin-line: light-dark(#d6e6ec, rgba(214, 230, 236, 0.18)); /* Rahmen, Input-Borders */
|
||||
--cp-admin-line-soft: light-dark(#e8eef2, rgba(214, 230, 236, 0.1)); /* Tabellenlinien */
|
||||
|
||||
/* --- Markenfarben (Buttons, aktive Zustände, in beiden Themes gleich) --- */
|
||||
--cp-admin-teal: var(--cp-admin-brand-teal);
|
||||
--cp-admin-teal-ui: var(--cp-admin-brand-teal-ui);
|
||||
--cp-admin-teal-dark: light-dark(var(--cp-admin-brand-teal-dark), #5fd6e0);
|
||||
--cp-admin-teal-soft: light-dark(
|
||||
color-mix(in srgb, var(--cp-admin-brand-teal) 10%, white),
|
||||
color-mix(in srgb, var(--cp-admin-brand-teal) 24%, transparent)
|
||||
);
|
||||
|
||||
--cp-admin-pink: var(--cp-admin-brand-pink);
|
||||
|
||||
--cp-admin-red: var(--cp-admin-brand-red);
|
||||
--cp-admin-red-dark: light-dark(var(--cp-admin-brand-red-dark), #ff7a93);
|
||||
--cp-admin-red-soft: light-dark(
|
||||
color-mix(in srgb, var(--cp-admin-brand-red) 10%, white),
|
||||
color-mix(in srgb, var(--cp-admin-brand-red) 24%, transparent)
|
||||
);
|
||||
|
||||
--cp-admin-orange: var(--cp-admin-brand-orange);
|
||||
--cp-admin-orange-soft: light-dark(
|
||||
color-mix(in srgb, var(--cp-admin-brand-orange) 12%, white),
|
||||
color-mix(in srgb, var(--cp-admin-brand-orange) 24%, transparent)
|
||||
);
|
||||
|
||||
--cp-admin-green: var(--cp-admin-brand-green);
|
||||
--cp-admin-green-soft: light-dark(#e5f5e6, color-mix(in srgb, var(--cp-admin-brand-green) 24%, transparent));
|
||||
|
||||
/* --- Fokus-Ring ------------------------------------------------------------ */
|
||||
--cp-admin-focus: light-dark(
|
||||
color-mix(in srgb, var(--cp-admin-brand-teal) 34%, transparent),
|
||||
color-mix(in srgb, var(--cp-admin-brand-teal) 55%, transparent)
|
||||
);
|
||||
|
||||
/* --- Schatten ---------------------------------------------------------- */
|
||||
--cp-admin-shadow-color: light-dark(rgba(27, 31, 48, 0.06), rgba(0, 0, 0, 0.5));
|
||||
--cp-admin-shadow-soft: 0 2px 8px var(--cp-admin-shadow-color);
|
||||
--cp-admin-shadow-action: light-dark(0 4px 10px rgba(1, 178, 187, 0.25), 0 4px 10px rgba(1, 178, 187, 0.35));
|
||||
|
||||
/* --- Chip/Badge-Status (Text immer mitgeben, nie nur Farbe) ------------- */
|
||||
--cp-admin-status-neutral-bg: light-dark(#e9eef2, color-mix(in srgb, var(--cp-admin-muted-2) 22%, transparent));
|
||||
--cp-admin-status-neutral-text: var(--cp-admin-text);
|
||||
--cp-admin-status-success-bg: var(--cp-admin-green-soft);
|
||||
--cp-admin-status-success-text: var(--cp-admin-green);
|
||||
--cp-admin-status-info-bg: var(--cp-admin-teal-soft);
|
||||
--cp-admin-status-info-text: var(--cp-admin-teal-dark);
|
||||
--cp-admin-status-warning-bg: var(--cp-admin-orange-soft);
|
||||
--cp-admin-status-warning-text: light-dark(#8a5a0a, #ffc169);
|
||||
--cp-admin-status-error-bg: var(--cp-admin-red-soft);
|
||||
--cp-admin-status-error-text: var(--cp-admin-red-dark);
|
||||
}
|
||||
|
||||
/* #############################################################################
|
||||
TEIL C · GLOBALE STEUERUNG (gilt für beide Themes)
|
||||
############################################################################# */
|
||||
|
||||
/* =============================================================================
|
||||
C.1 · Hell/Dunkel aktivieren + manueller Override
|
||||
----------------------------------------------------------------------------
|
||||
Setzt das Attribut data-theme die color-scheme fest, "gewinnt" diese gegen
|
||||
die Systemeinstellung – ganz ohne doppelte Token-Definitionen.
|
||||
Ein späterer Umschalter müsste nur document.documentElement.dataset.theme
|
||||
setzen (winziges Vanilla-JS, Future Log).
|
||||
============================================================================= */
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
:root[data-theme="light"] { color-scheme: light; }
|
||||
:root[data-theme="dark"] { color-scheme: dark; }
|
||||
|
||||
/* =============================================================================
|
||||
C.2 · Shell-Auswahl
|
||||
----------------------------------------------------------------------------
|
||||
Jede Shell-Vorlage setzt auf ihrem Wurzelelement eine der beiden Klassen.
|
||||
So lesen Komponenten in jeder Shell automatisch die richtigen Tokens, ohne
|
||||
dass irgendwo "user" oder "admin" hartkodiert werden muss:
|
||||
|
||||
<body class="cp-shell-user"> → in base_user.html
|
||||
<body class="cp-shell-admin"> → in base_admin.html
|
||||
|
||||
Innerhalb von components.css kann dadurch z. B. .cp-shell-user .cp-card
|
||||
bzw. .cp-shell-admin .cp-card jeweils ihre eigenen Tokens nutzen, falls
|
||||
geteilte Klassennamen je Shell unterschiedlich aussehen sollen.
|
||||
============================================================================= */
|
||||
.cp-shell-user {
|
||||
background: var(--cp-user-bg);
|
||||
color: var(--cp-user-text);
|
||||
font-family: var(--cp-user-font);
|
||||
}
|
||||
|
||||
.cp-shell-admin {
|
||||
background: var(--cp-admin-bg);
|
||||
color: var(--cp-admin-text);
|
||||
font-family: var(--cp-admin-font-ui);
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: inherit;
|
||||
}
|
||||
9
wsgi.py
Normal file
9
wsgi.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"""WSGI-Entrypoint für die Produktion (gunicorn wsgi:app).
|
||||
|
||||
Kein debug, keine Reloader-Logik – das macht der Prozess-Manager (systemd).
|
||||
Geheimnisse kommen aus der Umgebung (CP_ENV=production, SECRET_KEY), siehe DEPLOY.md.
|
||||
"""
|
||||
|
||||
from app import create_app
|
||||
|
||||
app = create_app()
|
||||
Loading…
Add table
Reference in a new issue