Compare commits
No commits in common. "3ac56b2c5b7528aab80fac3c90165c38d16dd948" and "0c83ce4d6e367f1f963af28545f73fb54dd26a9d" have entirely different histories.
3ac56b2c5b
...
0c83ce4d6e
73 changed files with 0 additions and 7186 deletions
10
.env.example
10
.env.example
|
|
@ -1,10 +0,0 @@
|
||||||
# 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
213
CLAUDE-backup.md
|
|
@ -1,213 +0,0 @@
|
||||||
# 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
272
CLAUDE.md
|
|
@ -1,272 +0,0 @@
|
||||||
# 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.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 145 KiB |
154
DEPLOY.md
154
DEPLOY.md
|
|
@ -1,154 +0,0 @@
|
||||||
# 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.
|
|
||||||
```
|
|
||||||
22
Dockerfile
22
Dockerfile
|
|
@ -1,22 +0,0 @@
|
||||||
FROM python:3.13-slim
|
|
||||||
|
|
||||||
# Non-root user for security
|
|
||||||
RUN useradd --system --create-home --home-dir /app checkpoint
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Dependencies first (layer caching — reinstall only when requirements change)
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# App code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# instance/ is a Docker volume — just ensure the directory exists with right owner
|
|
||||||
RUN mkdir -p instance/uploads && chown -R checkpoint:checkpoint /app
|
|
||||||
|
|
||||||
USER checkpoint
|
|
||||||
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
CMD ["gunicorn", "-c", "gunicorn.conf.py", "wsgi:app"]
|
|
||||||
|
|
@ -1,270 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
# 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
385
TASKS-backup.md
|
|
@ -1,385 +0,0 @@
|
||||||
# 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
431
TASKS.md
|
|
@ -1,431 +0,0 @@
|
||||||
# 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
124
app/__init__.py
|
|
@ -1,124 +0,0 @@
|
||||||
"""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
215
app/auth.py
|
|
@ -1,215 +0,0 @@
|
||||||
"""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
50
app/csrf.py
|
|
@ -1,50 +0,0 @@
|
||||||
"""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
375
app/models.py
|
|
@ -1,375 +0,0 @@
|
||||||
"""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}>"
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
"""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,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
"""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"))
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
"""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])
|
|
||||||
|
|
@ -1,429 +0,0 @@
|
||||||
"""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"))
|
|
||||||
|
|
@ -1,236 +0,0 @@
|
||||||
"""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"))
|
|
||||||
|
|
@ -1,339 +0,0 @@
|
||||||
"""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,
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
"""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)
|
|
||||||
|
|
@ -1,397 +0,0 @@
|
||||||
/* =============================================================================
|
|
||||||
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; }
|
|
||||||
|
|
@ -1,417 +0,0 @@
|
||||||
/* =============================================================================
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
/* CheckPoint Ehrenamt · fonts.css
|
|
||||||
Selbst gehostete Schriften (SIL Open Font License) – kein externer
|
|
||||||
Request an Google (DSGVO). Subset: latin (deckt deutsche Umlaute/ß ab).
|
|
||||||
Erzeugt aus den Google-Fonts-CSS2-Antworten, src auf lokale Dateien umgebogen. */
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'DM Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url(fonts/dm-sans-400.woff2) format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'DM Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url(fonts/dm-sans-500.woff2) format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'DM Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
font-display: swap;
|
|
||||||
src: url(fonts/dm-sans-600.woff2) format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'DM Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url(fonts/dm-sans-700.woff2) format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'DM Sans';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 800;
|
|
||||||
font-display: swap;
|
|
||||||
src: url(fonts/dm-sans-800.woff2) format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto Condensed';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url(fonts/roboto-condensed-400.woff2) format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto Condensed';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url(fonts/roboto-condensed-500.woff2) format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto Condensed';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url(fonts/roboto-condensed-700.woff2) format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto Mono';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url(fonts/roboto-mono-400.woff2) format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto Mono';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url(fonts/roboto-mono-500.woff2) format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Alfa Slab One';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url(fonts/alfa-slab-one-400.woff2) format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,24 +0,0 @@
|
||||||
/* 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);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
@ -1,340 +0,0 @@
|
||||||
/* =============================================================================
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
"""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()
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
{# 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>
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
{# 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>
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{# 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 -%}
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
#!/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"
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
[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
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
# 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
"""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 = "0.0.0.0: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"
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
Flask
|
|
||||||
Flask-SQLAlchemy
|
|
||||||
gunicorn
|
|
||||||
11
run.py
11
run.py
|
|
@ -1,11 +0,0 @@
|
||||||
"""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
57
seed.py
|
|
@ -1,57 +0,0 @@
|
||||||
"""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
334
tokens.css
|
|
@ -1,334 +0,0 @@
|
||||||
/* =============================================================================
|
|
||||||
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
9
wsgi.py
|
|
@ -1,9 +0,0 @@
|
||||||
"""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