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

429 lines
15 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.

"""Dienstplan bauen (Admin).
Manuelle Zuteilung: pro Einsatz 2 Haupt + 1 Springer. Keine automatische
Verteilung die App zeigt nur die verfügbaren Personen samt Fairness-Hilfe
(Anzahl bisheriger Einsätze, letzter Einsatz) an; auswählen tut der Admin.
Alles admin-geschützt und team-gescoped.
"""
from flask import (
Blueprint,
abort,
flash,
redirect,
render_template,
request,
url_for,
)
from sqlalchemy import func, or_
from .. import db
from ..auth import admin_required, current_user, login_required
from ..models import Benachrichtigung, Einsatz, Planungszeitraum, User, Verfuegbarkeit, Zuteilung
from ..notifications import benachrichtigen, benachrichtigen_team
from ..team_scope import get_active_team_id, team_query
bp = Blueprint("dienste", __name__, url_prefix="/dienste")
# Status, die einen Platz tatsächlich belegen.
AKTIVE_STATUS = (Zuteilung.STATUS_ZUGETEILT, Zuteilung.STATUS_UEBERNOMMEN)
# -----------------------------------------------------------------------------
# Hilfen
# -----------------------------------------------------------------------------
def _einsatz_holen(einsatz_id):
einsatz = team_query(Einsatz).filter_by(id=einsatz_id).first()
if einsatz is None:
abort(404)
return einsatz
def _belegung(einsatz):
"""Aktive Zuteilungen des Einsatzes, getrennt nach Platztyp."""
haupt, springer = [], []
for z in einsatz.zuteilungen:
if z.status not in AKTIVE_STATUS:
continue
if z.platztyp == Zuteilung.PLATZ_HAUPT:
haupt.append(z)
elif z.platztyp == Zuteilung.PLATZ_SPRINGER:
springer.append(z)
return haupt, springer
def _fairness_map():
"""{user_id: (anzahl_einsaetze, letztes_datum)} über das ganze Team.
Grundlage: aktive Zuteilungen. Hilft dem Admin, fair zu verteilen.
"""
rows = (
db.session.query(
Zuteilung.user_id,
func.count(Zuteilung.id),
func.max(Einsatz.datum),
)
.join(Einsatz, Zuteilung.einsatz_id == Einsatz.id)
.filter(Zuteilung.team_id == get_active_team_id())
.filter(Zuteilung.user_id.isnot(None))
.filter(Zuteilung.status.in_(AKTIVE_STATUS))
.group_by(Zuteilung.user_id)
.all()
)
return {uid: (anzahl, letztes) for uid, anzahl, letztes in rows}
def _kandidaten(einsatz, belegt_ids):
"""Personen mit „Kann" für diesen Einsatz, die noch nicht zugeteilt sind."""
q = (
team_query(User)
.join(Verfuegbarkeit, Verfuegbarkeit.user_id == User.id)
.filter(Verfuegbarkeit.einsatz_id == einsatz.id)
.filter(Verfuegbarkeit.wert == Verfuegbarkeit.WERT_KANN)
)
if belegt_ids:
q = q.filter(User.id.notin_(belegt_ids))
return q.order_by(User.anzeigename).all()
# -----------------------------------------------------------------------------
# Übersichten
# -----------------------------------------------------------------------------
@bp.route("/")
@admin_required
def liste():
zeitraeume = team_query(Planungszeitraum).order_by(
Planungszeitraum.start_datum.desc()
).all()
einzeltermine = (
team_query(Einsatz)
.filter_by(planungszeitraum_id=None)
.order_by(Einsatz.datum, Einsatz.start_zeit)
.all()
)
return render_template(
"dienste_liste.html", zeitraeume=zeitraeume, einzeltermine=einzeltermine
)
@bp.route("/zeitraum/<int:zeitraum_id>/veroeffentlichen", methods=("POST",))
@admin_required
def veroeffentlichen(zeitraum_id):
zr = team_query(Planungszeitraum).filter_by(id=zeitraum_id).first()
if zr is None:
abort(404)
if zr.ist_veroeffentlicht:
flash("Der Zeitraum ist bereits veröffentlicht.")
else:
zr.status = Planungszeitraum.STATUS_VEROEFFENTLICHT
# Jede zugeteilte Person über ihre Dienste informieren.
zuteilungen = (
team_query(Zuteilung)
.join(Einsatz, Zuteilung.einsatz_id == Einsatz.id)
.filter(Einsatz.planungszeitraum_id == zr.id)
.filter(Zuteilung.user_id.isnot(None))
.filter(Zuteilung.status.in_(AKTIVE_STATUS))
.all()
)
for z in zuteilungen:
rolle = "Haupt" if z.platztyp == Zuteilung.PLATZ_HAUPT else "Springer"
benachrichtigen(
z.user_id,
f"Neuer Dienst: {z.einsatz.art} am {z.einsatz.datum.strftime('%d.%m.%Y')} ({rolle})",
url=url_for("dienste.meine_termine"),
typ=Benachrichtigung.TYP_DIENST,
)
db.session.commit()
flash(f"{zr.name}“ veröffentlicht. Alle sehen jetzt ihren Dienstplan.")
return redirect(url_for("dienste.zeitraum", zeitraum_id=zr.id))
@bp.route("/zeitraum/<int:zeitraum_id>")
@admin_required
def zeitraum(zeitraum_id):
zr = team_query(Planungszeitraum).filter_by(id=zeitraum_id).first()
if zr is None:
abort(404)
# Besetzungsstand je Einsatz für die Übersicht.
stand = {}
for e in zr.einsaetze:
haupt, springer = _belegung(e)
stand[e.id] = (len(haupt), len(springer))
return render_template("dienste_zeitraum.html", zeitraum=zr, stand=stand)
# -----------------------------------------------------------------------------
# Bau-Ansicht pro Einsatz
# -----------------------------------------------------------------------------
@bp.route("/einsatz/<int:einsatz_id>")
@admin_required
def einsatz(einsatz_id):
e = _einsatz_holen(einsatz_id)
haupt, springer = _belegung(e)
belegt_ids = [z.user_id for z in haupt + springer]
kandidaten = _kandidaten(e, belegt_ids)
fairness = _fairness_map()
return render_template(
"dienste_einsatz.html",
einsatz=e,
haupt=haupt,
springer=springer,
haupt_frei=Einsatz.HAUPT_PLAETZE - len(haupt),
springer_frei=Einsatz.SPRINGER_PLAETZE - len(springer),
kandidaten=kandidaten,
fairness=fairness,
)
@bp.route("/einsatz/<int:einsatz_id>/zuteilen", methods=("POST",))
@admin_required
def zuteilen(einsatz_id):
e = _einsatz_holen(einsatz_id)
platztyp = request.form.get("platztyp")
user_id = request.form.get("user_id", type=int)
if platztyp not in (Zuteilung.PLATZ_HAUPT, Zuteilung.PLATZ_SPRINGER):
flash("Ungültiger Platztyp.")
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
person = team_query(User).filter_by(id=user_id).first()
if person is None:
abort(404)
# Person muss „Kann" gemeldet haben.
kann = team_query(Verfuegbarkeit).filter_by(
user_id=person.id, einsatz_id=e.id, wert=Verfuegbarkeit.WERT_KANN
).first()
if kann is None:
flash(f"{person.anzeigename} hat für diesen Einsatz kein „Kann“ gemeldet.")
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
haupt, springer = _belegung(e)
belegt_ids = [z.user_id for z in haupt + springer]
if person.id in belegt_ids:
flash(f"{person.anzeigename} ist diesem Einsatz schon zugeteilt.")
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
if platztyp == Zuteilung.PLATZ_HAUPT and len(haupt) >= Einsatz.HAUPT_PLAETZE:
flash("Die Hauptplätze sind bereits voll.")
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
if platztyp == Zuteilung.PLATZ_SPRINGER and len(springer) >= Einsatz.SPRINGER_PLAETZE:
flash("Der Springerplatz ist bereits besetzt.")
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
db.session.add(
Zuteilung(
team_id=get_active_team_id(),
einsatz_id=e.id,
user_id=person.id,
platztyp=platztyp,
status=Zuteilung.STATUS_ZUGETEILT,
)
)
db.session.commit()
flash(f"{person.anzeigename} zugeteilt.")
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
@bp.route("/einsatz/<int:einsatz_id>/entfernen", methods=("POST",))
@admin_required
def entfernen(einsatz_id):
e = _einsatz_holen(einsatz_id)
zuteilung_id = request.form.get("zuteilung_id", type=int)
z = team_query(Zuteilung).filter_by(id=zuteilung_id, einsatz_id=e.id).first()
if z is None:
abort(404)
db.session.delete(z)
db.session.commit()
flash("Zuteilung entfernt.")
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
@bp.route("/einsatz/<int:einsatz_id>/unterbesetzt", methods=("POST",))
@admin_required
def unterbesetzt(einsatz_id):
e = _einsatz_holen(einsatz_id)
e.unterbesetzt = not e.unterbesetzt
db.session.commit()
flash("Als unterbesetzt markiert." if e.unterbesetzt else "Markierung entfernt.")
return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
# -----------------------------------------------------------------------------
# Sichtbarkeitsfilter: nur veröffentlichte Zeiträume oder Einzeltermine
# -----------------------------------------------------------------------------
def _sichtbar_filter(query):
return query.filter(
or_(
Einsatz.planungszeitraum_id.is_(None),
Planungszeitraum.status == Planungszeitraum.STATUS_VEROEFFENTLICHT,
)
)
# -----------------------------------------------------------------------------
# Meine Termine (alle eingeloggten Nutzer) eigene Zuteilungen + offene Dienste
# -----------------------------------------------------------------------------
@bp.route("/meine-termine")
@login_required
def meine_termine():
rows = _sichtbar_filter(
db.session.query(Zuteilung, Einsatz)
.join(Einsatz, Zuteilung.einsatz_id == Einsatz.id)
.outerjoin(Planungszeitraum, Einsatz.planungszeitraum_id == Planungszeitraum.id)
.filter(Zuteilung.team_id == get_active_team_id())
.filter(Zuteilung.user_id == current_user().id)
.filter(Zuteilung.status.in_(AKTIVE_STATUS))
).order_by(Einsatz.datum, Einsatz.start_zeit).all()
# Offene Dienste (nach einer Absage) für alle zum Übernehmen.
offene = _sichtbar_filter(
db.session.query(Zuteilung, Einsatz)
.join(Einsatz, Zuteilung.einsatz_id == Einsatz.id)
.outerjoin(Planungszeitraum, Einsatz.planungszeitraum_id == Planungszeitraum.id)
.filter(Zuteilung.team_id == get_active_team_id())
.filter(Zuteilung.status == Zuteilung.STATUS_OFFEN)
).order_by(Einsatz.datum, Einsatz.start_zeit).all()
# Einsätze, auf denen man selbst schon aktiv ist dort kein zweiter Platz.
eigene_einsatz_ids = {e.id for _, e in rows}
# Gibt es gerade etwas zu melden? (für den Link zur Verfügbarkeit)
offene_planung = (
team_query(Planungszeitraum)
.filter_by(status=Planungszeitraum.STATUS_IN_PLANUNG)
.first()
is not None
or team_query(Einsatz).filter_by(planungszeitraum_id=None).first() is not None
)
return render_template(
"meine_termine.html",
termine=rows,
offene=offene,
eigene_einsatz_ids=eigene_einsatz_ids,
offene_planung=offene_planung,
)
# -----------------------------------------------------------------------------
# Absage & Übernahme
# -----------------------------------------------------------------------------
def _offen_anlegen(einsatz, platztyp):
db.session.add(
Zuteilung(
team_id=get_active_team_id(),
einsatz_id=einsatz.id,
user_id=None,
platztyp=platztyp,
status=Zuteilung.STATUS_OFFEN,
)
)
@bp.route("/zuteilung/<int:zuteilung_id>/absagen", methods=("POST",))
@login_required
def absagen(zuteilung_id):
z = team_query(Zuteilung).filter_by(id=zuteilung_id).first()
if z is None:
abort(404)
# Nur die eigene Zuteilung absagen.
if z.user_id != current_user().id:
abort(403)
if z.status not in AKTIVE_STATUS:
flash("Dieser Dienst ist nicht (mehr) aktiv.")
return redirect(url_for("dienste.meine_termine"))
einsatz = z.einsatz
war_haupt = z.platztyp == Zuteilung.PLATZ_HAUPT
z.status = Zuteilung.STATUS_ABGESAGT
if war_haupt:
# Springer rückt nach, falls vorhanden.
springer = next(
(
s
for s in einsatz.zuteilungen
if s.platztyp == Zuteilung.PLATZ_SPRINGER
and s.status in AKTIVE_STATUS
and s.user_id is not None
),
None,
)
if springer is not None:
springer.platztyp = Zuteilung.PLATZ_HAUPT
springer.status = Zuteilung.STATUS_UEBERNOMMEN
_offen_anlegen(einsatz, Zuteilung.PLATZ_SPRINGER)
benachrichtigen(
springer.user_id,
f"Du bist nachgerückt: {einsatz.art} am {einsatz.datum.strftime('%d.%m.%Y')} (jetzt Haupt)",
url=url_for("dienste.meine_termine"),
typ=Benachrichtigung.TYP_DIENST,
)
else:
_offen_anlegen(einsatz, Zuteilung.PLATZ_HAUPT)
else:
# Springer sagt ab -> Springerplatz wird offen.
_offen_anlegen(einsatz, Zuteilung.PLATZ_SPRINGER)
# Offener Platz: alle zum Übernehmen einladen außer wer schon auf diesem
# Einsatz steht (z. B. der gerade nachgerückte Springer) und der absagenden Person.
schon_dabei = {
s.user_id
for s in einsatz.zuteilungen
if s.user_id is not None and s.status in AKTIVE_STATUS
}
schon_dabei.add(current_user().id)
benachrichtigen_team(
f"Offener Dienst: {einsatz.art} am {einsatz.datum.strftime('%d.%m.%Y')} jetzt übernehmen",
url=url_for("dienste.meine_termine"),
typ=Benachrichtigung.TYP_OFFEN,
ausser_user_ids=schon_dabei,
)
db.session.commit()
flash("Abgesagt. Der frei gewordene Platz ist jetzt offen.")
return redirect(url_for("dienste.meine_termine"))
@bp.route("/zuteilung/<int:zuteilung_id>/uebernehmen", methods=("POST",))
@login_required
def uebernehmen(zuteilung_id):
user = current_user()
# Vorprüfung: nicht zweimal auf denselben Einsatz.
z = team_query(Zuteilung).filter_by(id=zuteilung_id).first()
if z is None:
abort(404)
schon_dabei = team_query(Zuteilung).filter(
Zuteilung.einsatz_id == z.einsatz_id,
Zuteilung.user_id == user.id,
Zuteilung.status.in_(AKTIVE_STATUS),
).first()
if schon_dabei is not None:
flash("Du bist diesem Einsatz schon zugeteilt.")
return redirect(url_for("dienste.meine_termine"))
# Atomar gegen gleichzeitiges Übernehmen: nur, solange wirklich noch offen.
getroffen = (
Zuteilung.query.filter_by(
id=zuteilung_id,
team_id=get_active_team_id(),
status=Zuteilung.STATUS_OFFEN,
user_id=None,
).update(
{"user_id": user.id, "status": Zuteilung.STATUS_UEBERNOMMEN},
synchronize_session=False,
)
)
db.session.commit()
if getroffen == 1:
flash("Übernommen.")
else:
flash("Dieser Dienst wurde gerade schon übernommen.")
return redirect(url_for("dienste.meine_termine"))