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