"""SQLAlchemy-Modelle der CheckPoint-Ehrenamt-App.
Regeln (siehe CLAUDE.md):
- Mandantenfähig ab Tag 1: JEDES Modell trägt eine team_id. Jede Abfrage filtert
später nach dem Team des eingeloggten Nutzers.
- SQLite für v1, aber portabel halten (kein SQLite-spezifischer Typ), damit ein
späterer Umstieg auf PostgreSQL möglich bleibt.
- Status-/Typ-Felder sind Strings mit Konstanten (unten je Modell), keine DB-Enums.
"""
from datetime import date, datetime, timedelta, timezone
from . import db
def _now():
"""Zeitzonenbewusster UTC-Zeitstempel (portabel, ohne SQLite-Defaults)."""
return datetime.now(timezone.utc)
# =============================================================================
# team — der Mandant. Das CheckPoint-Team ist das erste von perspektivisch mehreren.
# =============================================================================
class Team(db.Model):
__tablename__ = "team"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
# Kurzes, eindeutiges Kürzel für den teambezogenen Login (z. B. "checkpoint").
slug = db.Column(db.String(40), nullable=False, unique=True, index=True)
# Brand-Tokens (werden pro Team in den
injiziert, Session 4).
brand_primary = db.Column(db.String(32), nullable=False, default="#21a8ad")
brand_primary_strong = db.Column(db.String(32), nullable=False, default="#147f84")
brand_accent = db.Column(db.String(32), nullable=False, default="#ef1823")
brand_accent_strong = db.Column(db.String(32), nullable=False, default="#bd0f18")
brand_font = db.Column(
db.String(200),
nullable=False,
default='"DM Sans", Arial, Helvetica, sans-serif',
)
logo_pfad = db.Column(db.String(255), nullable=True)
# Beziehungen (alle Kinder gehören zu genau diesem Team).
users = db.relationship("User", back_populates="team", cascade="all, delete-orphan")
planungszeitraeume = db.relationship(
"Planungszeitraum", back_populates="team", cascade="all, delete-orphan"
)
kanaele = db.relationship(
"Kanal", back_populates="team", cascade="all, delete-orphan"
)
def __repr__(self):
return f""
# =============================================================================
# user — Admin oder Ehrenamtliche:r. Keine Selbstregistrierung (Admin legt an).
# =============================================================================
class User(db.Model):
__tablename__ = "user"
# Nutzername eindeutig pro Team, nicht global (Mandantenfähigkeit).
__table_args__ = (
db.UniqueConstraint("team_id", "nutzername", name="uq_user_team_nutzername"),
)
ROLLE_ADMIN = "admin"
ROLLE_EHRENAMT = "ehrenamt"
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
)
rolle = db.Column(db.String(16), nullable=False, default=ROLLE_EHRENAMT)
nutzername = db.Column(db.String(80), nullable=False)
passwort_hash = db.Column(db.String(255), nullable=False)
anzeigename = db.Column(db.String(120), nullable=False)
profilbild_pfad = db.Column(db.String(255), nullable=True)
# Optionale freiwillige Angaben (Platzhalter, Session 12 erweitert die Struktur).
freiwillige_angaben = db.Column(db.Text, nullable=True)
team = db.relationship("Team", back_populates="users")
verfuegbarkeiten = db.relationship(
"Verfuegbarkeit", back_populates="user", cascade="all, delete-orphan"
)
zuteilungen = db.relationship(
"Zuteilung", back_populates="user", cascade="all, delete-orphan"
)
@property
def ist_admin(self):
return self.rolle == self.ROLLE_ADMIN
def __repr__(self):
return f""
# =============================================================================
# planungszeitraum — Block von mind. 2 Monaten. Enthält die kuratierten Einsätze.
# =============================================================================
class Planungszeitraum(db.Model):
__tablename__ = "planungszeitraum"
STATUS_IN_PLANUNG = "in_planung"
STATUS_VEROEFFENTLICHT = "veroeffentlicht"
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
)
name = db.Column(db.String(120), nullable=False)
start_datum = db.Column(db.Date, nullable=False)
end_datum = db.Column(db.Date, nullable=False)
status = db.Column(db.String(20), nullable=False, default=STATUS_IN_PLANUNG)
team = db.relationship("Team", back_populates="planungszeitraeume")
einsaetze = db.relationship(
"Einsatz",
back_populates="planungszeitraum",
cascade="all, delete-orphan",
order_by="Einsatz.datum, Einsatz.start_zeit",
)
@property
def ist_veroeffentlicht(self):
return self.status == self.STATUS_VEROEFFENTLICHT
def __repr__(self):
return f""
# =============================================================================
# einsatz — gehört zu einem Planungszeitraum. Braucht 2 Haupt- + 1 Springerplatz.
# =============================================================================
class Einsatz(db.Model):
__tablename__ = "einsatz"
# Platzstruktur ist fix (CLAUDE.md): 2 Haupt + 1 Springer. Belegung über Zuteilungen.
HAUPT_PLAETZE = 2
SPRINGER_PLAETZE = 1
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
)
# Optional: ein Einsatz kann zu einem Planungszeitraum gehören ODER ein
# eigenständiger Einzeltermin sein (planungszeitraum_id = NULL).
planungszeitraum_id = db.Column(
db.Integer, db.ForeignKey("planungszeitraum.id"), nullable=True, index=True
)
datum = db.Column(db.Date, nullable=False)
start_zeit = db.Column(db.Time, nullable=False)
# Erwartete Dauer in Minuten (30er-Schritte). Optional: leer = offenes Ende.
dauer_minuten = db.Column(db.Integer, nullable=True)
art = db.Column(db.String(80), nullable=False) # z. B. Tour, Party, Sonderveranstaltung
ort = db.Column(db.String(120), nullable=True) # z. B. Altstadt
unterbesetzt = db.Column(db.Boolean, nullable=False, default=False)
planungszeitraum = db.relationship("Planungszeitraum", back_populates="einsaetze")
verfuegbarkeiten = db.relationship(
"Verfuegbarkeit", back_populates="einsatz", cascade="all, delete-orphan"
)
zuteilungen = db.relationship(
"Zuteilung", back_populates="einsatz", cascade="all, delete-orphan"
)
@property
def end_zeit(self):
"""Aus Start + erwarteter Dauer abgeleitet. None, wenn keine Dauer gesetzt."""
if self.start_zeit is None or not self.dauer_minuten:
return None
basis = datetime.combine(date.min, self.start_zeit)
return (basis + timedelta(minutes=self.dauer_minuten)).time()
@property
def dauer_text(self):
"""Menschliche Dauer, z. B. 'ca. 1,5 Std' oder 'ca. 30 Min'. None ohne Dauer."""
if not self.dauer_minuten:
return None
stunden, minuten = divmod(self.dauer_minuten, 60)
if stunden and minuten == 30:
return f"ca. {stunden},5 Std"
if stunden:
return f"ca. {stunden} Std"
return f"ca. {minuten} Min"
def __repr__(self):
return f""
# =============================================================================
# verfuegbarkeit — (user × einsatz) → kann | kann_nicht.
# =============================================================================
class Verfuegbarkeit(db.Model):
__tablename__ = "verfuegbarkeit"
__table_args__ = (
db.UniqueConstraint("user_id", "einsatz_id", name="uq_verfuegbarkeit_user_einsatz"),
)
WERT_KANN = "kann"
WERT_KANN_NICHT = "kann_nicht"
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
einsatz_id = db.Column(
db.Integer, db.ForeignKey("einsatz.id"), nullable=False, index=True
)
wert = db.Column(db.String(16), nullable=False)
user = db.relationship("User", back_populates="verfuegbarkeiten")
einsatz = db.relationship("Einsatz", back_populates="verfuegbarkeiten")
def __repr__(self):
return f""
# =============================================================================
# zuteilung — (user × einsatz) mit platztyp (haupt|springer) und status.
# =============================================================================
class Zuteilung(db.Model):
__tablename__ = "zuteilung"
PLATZ_HAUPT = "haupt"
PLATZ_SPRINGER = "springer"
STATUS_ZUGETEILT = "zugeteilt"
STATUS_ABGESAGT = "abgesagt"
STATUS_OFFEN = "offen"
STATUS_UEBERNOMMEN = "uebernommen"
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
)
einsatz_id = db.Column(
db.Integer, db.ForeignKey("einsatz.id"), nullable=False, index=True
)
# Bei offenem Platz kann (noch) keine Person zugeordnet sein.
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True, index=True)
platztyp = db.Column(db.String(16), nullable=False)
status = db.Column(db.String(16), nullable=False, default=STATUS_ZUGETEILT)
einsatz = db.relationship("Einsatz", back_populates="zuteilungen")
user = db.relationship("User", back_populates="zuteilungen")
def __repr__(self):
return f""
# =============================================================================
# dokument — Datei (PDF/Word/Bild) mit Freigabe-Workflow.
# =============================================================================
class Dokument(db.Model):
__tablename__ = "dokument"
STATUS_WARTET = "wartet_auf_freigabe"
STATUS_FREIGEGEBEN = "freigegeben"
STATUS_ABGELEHNT = "abgelehnt"
ABLAGE_AKTUELL = "aktuell"
ABLAGE_ARCHIV = "archiv"
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
)
uploader_id = db.Column(
db.Integer, db.ForeignKey("user.id"), nullable=False, index=True
)
titel = db.Column(db.String(200), nullable=False) # Pflicht
datei_pfad = db.Column(db.String(255), nullable=False)
status = db.Column(db.String(24), nullable=False, default=STATUS_WARTET)
ablage = db.Column(db.String(16), nullable=False, default=ABLAGE_AKTUELL)
ablehnungsgrund = db.Column(db.Text, nullable=True)
erstellt_am = db.Column(db.DateTime(timezone=True), nullable=False, default=_now)
uploader = db.relationship("User")
def __repr__(self):
return f""
# =============================================================================
# kanal — pro Team zwei feste: ankuendigungen (nur Admin postet), team (alle).
# =============================================================================
class Kanal(db.Model):
__tablename__ = "kanal"
__table_args__ = (
db.UniqueConstraint("team_id", "art", name="uq_kanal_team_art"),
)
ART_ANKUENDIGUNGEN = "ankuendigungen"
ART_TEAM = "team"
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
)
art = db.Column(db.String(20), nullable=False)
team = db.relationship("Team", back_populates="kanaele")
nachrichten = db.relationship(
"Nachricht",
back_populates="kanal",
cascade="all, delete-orphan",
order_by="Nachricht.erstellt_am",
)
@property
def nur_admin_darf_posten(self):
return self.art == self.ART_ANKUENDIGUNGEN
def __repr__(self):
return f""
# =============================================================================
# nachricht — Kanal, Autor, Text, Zeitstempel. Nur Text.
# =============================================================================
class Nachricht(db.Model):
__tablename__ = "nachricht"
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
)
kanal_id = db.Column(db.Integer, db.ForeignKey("kanal.id"), nullable=False, index=True)
autor_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
text = db.Column(db.Text, nullable=False)
erstellt_am = db.Column(db.DateTime(timezone=True), nullable=False, default=_now)
kanal = db.relationship("Kanal", back_populates="nachrichten")
autor = db.relationship("User")
def __repr__(self):
return f""
# =============================================================================
# benachrichtigung — In-App-Hinweis pro Empfänger (v1: kein Push/E-Mail).
# Ausgelöst von: neuer Dienst, offener Dienst, neue Nachricht.
# =============================================================================
class Benachrichtigung(db.Model):
__tablename__ = "benachrichtigung"
TYP_DIENST = "dienst" # neue/eigene Zuteilung
TYP_OFFEN = "offener_dienst" # ein Platz ist frei geworden
TYP_NACHRICHT = "nachricht" # neue Chat-Nachricht
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(
db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
)
# Empfänger der Benachrichtigung.
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
typ = db.Column(db.String(20), nullable=False)
text = db.Column(db.String(255), nullable=False)
url = db.Column(db.String(255), nullable=True) # Ziel beim Antippen (optional)
gelesen = db.Column(db.Boolean, nullable=False, default=False, index=True)
erstellt_am = db.Column(db.DateTime(timezone=True), nullable=False, default=_now)
def __repr__(self):
return f""