339 lines
11 KiB
Python
339 lines
11 KiB
Python
"""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,
|
||
}
|