"""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//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/") @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/") @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//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//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//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//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//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"))