375 lines
14 KiB
Python
375 lines
14 KiB
Python
"""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}>"
|