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

339 lines
11 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.

"""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("/<int:zeitraum_id>", 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/<int:einsatz_id>/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/<int:einsatz_id>/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/<int:einsatz_id>", 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,
}