429 lines
15 KiB
Python
429 lines
15 KiB
Python
"""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"))
|