"""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("/", 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//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//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/", 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, }