CP_EHRENAMT/app/models.py
2026-06-25 18:58:44 +02:00

375 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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}>"