From c6e0b95d816daa41b3d4cc3780686dfe20dd6c94 Mon Sep 17 00:00:00 2001 From: Michael Stangl Date: Mon, 27 Jul 2026 10:10:41 +0200 Subject: [PATCH] Web-GUI: fixierte Gruppen anzeigen, Strafprofil waehlen, Farben nutzen CSVConfig.HandleGaesteliste liefert zusaetzlich FixierteGruppen (Name -> Tisch/Anzahl), damit fest platzierte Gaeste in der Gaesteliste sichtbar (grau hinterlegt) erscheinen. Durchgaengig "VIP" -> "fixiert" umbenannt. Backend: Strafprofile werden gecacht geladen, neuer Endpoint /strafprofile und Query-Parameter strafen an /berechnen, damit sich Profile (default vs. locker) in der GUI vergleichen lassen. SVG-Ausgabe nutzt das Farbschema aus farben.cfg (Rueckfall auf Palette). Frontend: tische.ini und CSV werden bei Auswahl sofort geladen, Auswahl der fixierten Gruppen und des Optimierungsprofils in der Oberflaeche. Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/CSVConfig.py | 17 ++++++++-- web/main.py | 73 +++++++++++++++++++++++++++++++++++++------ web/static/index.html | 63 +++++++++++++++++++++++++++---------- 3 files changed, 124 insertions(+), 29 deletions(-) diff --git a/libs/CSVConfig.py b/libs/CSVConfig.py index a9e3978..2fc7791 100644 --- a/libs/CSVConfig.py +++ b/libs/CSVConfig.py @@ -84,6 +84,11 @@ class CSVConfig: VIPGruppe = Gruppe( 0 ) VipHash = {} GruppenNamen = {} + # fest platzierte ("fixierte") Gruppen fuer die Anzeige: Name -> + # {'tisch': Tischnummer, 'anzahl': Personenzahl}. VIPs laufen nicht + # durch die GA-Platzierung, sollen in der Gaesteliste aber sichtbar + # (grau hinterlegt) erscheinen. + FixierteGruppen = {} # Gruppen entstehen beim ersten Auftreten ihres Namens; # Einzelpersonen (leerer Gruppenname) bekommen eine eigene Gruppe @@ -126,9 +131,13 @@ class CSVConfig: PID = PID + 1 P = Person( PID, Vorname, Nachname, Titel ) if TischNr is not None: - # VIP: fester Tisch, keine GA-Platzierung + # fixiert: fester Tisch, keine GA-Platzierung VipHash[ PID ] = TischNr VIPGruppe.Person_dazu( P ) + Name = GruppenName if GruppenName else 'Fixiert Tisch %d' % TischNr + Eintrag = FixierteGruppen.setdefault( + Name, { 'tisch': TischNr, 'anzahl': 0 } ) + Eintrag['anzahl'] = Eintrag['anzahl'] + 1 else: PN.PersonAdd( P ) if GruppenName == '': @@ -155,7 +164,7 @@ class CSVConfig: if G.Anzahl() > 0: GN.Gruppe_dazu( G ) - return [ PN, GN, VIPGruppe, VipHash, GruppenNamen ] + return [ PN, GN, VIPGruppe, VipHash, GruppenNamen, FixierteGruppen ] def laden(self, FileName): """lese alle Eingangsdaten aus einer CSV-Datei ein""" @@ -179,12 +188,14 @@ Einzel;Gast;;;; """ print("\n-- Parse Beispiel-CSV:") C = CSVConfig() - [ PN, GN, VIPGruppe, VipHash, GruppenNamen ] = C.HandleGaesteliste( Beispiel ) + [ PN, GN, VIPGruppe, VipHash, GruppenNamen, FixierteGruppen ] = \ + C.HandleGaesteliste( Beispiel ) print(PN) print(GN) print(VIPGruppe) print(VipHash) print(GruppenNamen) + print(FixierteGruppen) else: pass diff --git a/web/main.py b/web/main.py index d01516d..a99f0f2 100644 --- a/web/main.py +++ b/web/main.py @@ -44,6 +44,7 @@ from pydantic import BaseModel, Field from ga import Farm, Zyklus from CSVConfig import CSVConfig +from Farben import Farbschema from Strukturdaten import Sitzplatzverteilung, Strafliste, Tisch, Tische app = FastAPI( @@ -62,11 +63,43 @@ ZYKLUSDATEI = os.path.join( CFGDIR, 'zyklus.cfg' ) STRAFENDATEI = os.path.join( CFGDIR, 'strafen.cfg' ) ZYKLUSART = os.environ.get( 'PLATZ_ZYKLUSART', 'Adaptiv' ) STRAFENART = os.environ.get( 'PLATZ_STRAFENART', 'default' ) +FARBENDATEI = os.path.join( CFGDIR, 'farben.cfg' ) +FARBENART = os.environ.get( 'PLATZ_FARBENART', 'grosse_gruppen' ) GAZyklus = Zyklus() GAZyklus.laden( ZYKLUSDATEI, ZYKLUSART ) -Strafen = Strafliste() -Strafen.laden( STRAFENDATEI, STRAFENART ) + +# Strafprofile werden bei Bedarf geladen und gecacht, damit sich in der GUI +# verschiedene Profile (z.B. 'default' vs 'locker') auswaehlen und vergleichen +# lassen. Das Default-Profil wird schon beim Start geladen (faellt frueh auf, +# falls die Datei/Sektion kaputt ist). +StrafProfile = {} + + +def strafprofil(Name): + """liefert (gecacht) die Strafliste einer Sektion aus strafen.cfg; + wirft ValueError bei unbekannter Sektion""" + if Name not in StrafProfile: + SL = Strafliste() + SL.laden( STRAFENDATEI, Name ) + StrafProfile[Name] = SL + return StrafProfile[Name] + + +def strafprofil_namen(): + """alle in strafen.cfg vorhandenen Profilnamen (fuer die GUI-Auswahl)""" + cfg = configparser.ConfigParser() + cfg.read( STRAFENDATEI ) + return cfg.sections() + + +strafprofil( STRAFENART ) # Default vorwaermen / frueh scheitern +# Farbprofil fuer die SVG-Ausgabe; faellt bei fehlender Datei/Sektion auf die +# eingebaute Palette zurueck (Farbschema=None), damit der Server trotzdem laeuft. +try: + Farben = Farbschema().laden( FARBENDATEI, FARBENART ) +except (ValueError, OSError): + Farben = None # ---------------------------------------------------------------- Sessions SESSION_TTL_SEKUNDEN = 24 * 3600 @@ -160,7 +193,7 @@ def _VarianteJSON(SV, GruppenNamen, Nr): G = SV.Gruppen.GruppeVonPid( P.Id ) return GruppenNamen.get( G.Id, 'Gruppe %s' % G.Id ) except KeyError: - return 'VIP' + return 'fixiert' TischListe = [] for T in SV.Tische: @@ -220,6 +253,7 @@ def _ErgebnisJSON(S): GruppenNamen = S.get( 'gruppenNamen', {} ) return { 'lauf': S['laeufe'], + 'strafen': S.get( 'strafen', STRAFENART ), # verwendetes Strafprofil 'varianten': [ _VarianteJSON( SV, GruppenNamen, Nr + 1 ) for Nr, SV in enumerate( S['ergebnisse'] ) ], } @@ -389,7 +423,7 @@ def PersonenHochladen(SitzungsId: str, datei: UploadFile = File(...)): Text = Rohdaten.decode( 'cp1252' ) # Excel/Windows-Fallback try: - [ PN, GN, VIPGruppe, VipHash, GruppenNamen ] = \ + [ PN, GN, VIPGruppe, VipHash, GruppenNamen, FixierteGruppen ] = \ CSVConfig().HandleGaesteliste( Text ) except ValueError as Fehler: raise HTTPException( status_code=400, detail=str(Fehler) ) @@ -407,12 +441,15 @@ def PersonenHochladen(SitzungsId: str, datei: UploadFile = File(...)): return { 'personen': GN.NLeute, - 'vips': VIPGruppe.Anzahl(), + 'fixiert': VIPGruppe.Anzahl(), 'gruppen': [ { 'id': G.Id, 'name': GruppenNamen.get( G.Id, 'Gruppe %s' % G.Id ), 'anzahl': G.Anzahl(), } for G in GN ], + # fest platzierte Gruppen (fuer die grau hinterlegte Anzeige) + 'fixierte': [ { 'name': Name, 'tisch': Info['tisch'], 'anzahl': Info['anzahl'] } + for Name, Info in FixierteGruppen.items() ], } @@ -420,9 +457,14 @@ def PersonenHochladen(SitzungsId: str, datei: UploadFile = File(...)): def Berechnen(SitzungsId: str, varianten: int = Query( default=3, ge=1, le=5, description='wieviele unterschiedliche Varianten der Lauf ' - 'zurueckgeben soll (aus Farm.TopN, dedupliziert)' )): + 'zurueckgeben soll (aus Farm.TopN, dedupliziert)' ), + strafen: str = Query( default=STRAFENART, + description='Name des Strafpunkte-Profils (Sektion in strafen.cfg), ' + 'z.B. default oder locker - zum Vergleich waehlbar' )): """startet die GA-Optimierung; jeder Aufruf ist ein neuer Lauf und - liefert bis zu 'varianten' unterschiedliche gute Sitzordnungen""" + liefert bis zu 'varianten' unterschiedliche gute Sitzordnungen. Das + Strafpunkte-Profil ('strafen') steuert die Bewertung waehrend der + Optimierung - so lassen sich z.B. default und locker vergleichen.""" S = _GibSession( SitzungsId ) if S['tische'] is None: raise HTTPException( status_code=400, @@ -454,9 +496,14 @@ def Berechnen(SitzungsId: str, detail='Tisch %d hat %d Plaetze, aber %d VIPs sind ' 'dort fest gesetzt' % (Tid, T.Plaetze, AnzahlVIPs) ) + try: + SL = strafprofil( strafen ) + except ValueError as Fehler: + raise HTTPException( status_code=400, detail=str(Fehler) ) + try: GAFarm = Farm( GAZyklus, Sitzplatzverteilung, - Tische=TE, Gruppen=P['GN'], Strafen=Strafen, + Tische=TE, Gruppen=P['GN'], Strafen=SL, VIPListe=P['VipHash'], VIPs=P['VIPGruppe'] ) except IndexError as Fehler: raise HTTPException( status_code=400, detail=str(Fehler) ) @@ -481,9 +528,17 @@ def Berechnen(SitzungsId: str, S['ergebnisse'] = Ergebnisse S['laeufe'] = S['laeufe'] + 1 + S['strafen'] = strafen # fuer die Anzeige, welches Profil den Lauf ergab return _ErgebnisJSON( S ) +@app.get( '/strafprofile' ) +def Strafprofile(): + """verfuegbare Strafpunkte-Profile (Sektionen in strafen.cfg) fuer die + Auswahl in der GUI; 'vorgabe' ist das voreingestellte Profil.""" + return { 'profile': strafprofil_namen(), 'vorgabe': STRAFENART } + + @app.get( '/sitzung/{SitzungsId}/ergebnis' ) def Ergebnis(SitzungsId: str): """alle Varianten des letzten Laufs als JSON (beste zuerst)""" @@ -513,7 +568,7 @@ def ErgebnisSVG(SitzungsId: str, os.close( Handle ) try: S['ergebnisse'][variante - 1].alsSVG( TempPfad, - GruppenNamen=S.get( 'gruppenNamen', {} ) ) + GruppenNamen=S.get( 'gruppenNamen', {} ), Farbschema=Farben ) fsock = open( TempPfad, 'r' ) SVG = fsock.read() fsock.close() diff --git a/web/static/index.html b/web/static/index.html index b806e45..3519b8e 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -61,6 +61,7 @@ padding: .3rem .7rem; margin: .15rem .3rem .15rem 0; font-size: .9rem; } .kennzahl b { color: var(--akzent); } .hinweis { font-size: .8rem; color: #6b6557; } + #gruppenUebersicht tr.fixiert td { background: #e6e3dc; color: #555; } .varianten { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .6rem; } .variante { border: 1px solid var(--rand); border-radius: 6px; background: white; @@ -97,10 +98,9 @@
- - - + +
@@ -110,9 +110,10 @@

2. Gästeliste (CSV)

Spalten: Vorname;Nachname;Titel;Gruppe;Tisch;AnzahlGruppe hält Personen zusammen (leer = Einzelgast), - Tisch setzt VIPs fest (z. B. Brautpaar an Tisch 1), + Tisch fixiert Gäste an einem Tisch (z. B. Brautpaar an Tisch 1), Anzahl expandiert eine Zeile in mehrere Buchungen - (anonyme Sammelbuchung). Datei hochladen oder direkt hier bearbeiten:

+ (anonyme Sammelbuchung). Eine Datei auswählen (wird sofort übernommen) + oder hier direkt bearbeiten und übernehmen:


@@ -128,7 +129,11 @@ Sitzordnungen zum Vergleich — die Wertigkeit (0 = perfekt, negativer = schlechter), die Zahl getrennter Gruppen und allein sitzende Personen machen sie vergleichbar. Variante anklicken, um Tische und Raumplan umzuschalten; jeder weitere Klick auf - „Berechnen“ startet einen frischen Lauf.

+ „Berechnen“ startet einen frischen Lauf. Über das Optimierungsprofil lassen sich + Bewertungs-Vorgehen vergleichen (z. B. default vs. + locker): Profil wählen und neu berechnen.

+
@@ -259,6 +264,7 @@ async function tischDateiLaden() { '„Tische speichern“ ist nur nach Änderungen nötig.', 'ok'); berechnenFreigeben(); } catch (e) { status('tischStatus', 'Fehler: ' + e.message, 'fehler'); } + finally { eingabe.value = ''; } // damit dieselbe Datei erneut auswählbar ist } /* ------------------------------------------------ 2. Gäste */ @@ -293,11 +299,10 @@ async function csvDateiGewaehlt(ev) { } text = text.replace(/^/, ''); document.getElementById('csvText').value = text; - // stale Ausgabe eines frueheren Uploads entfernen - document.getElementById('gruppenUebersicht').innerHTML = ''; - status('gaesteStatus', 'Datei „' + datei.name + '“ geladen — zum Übernehmen ' + - '„Gästeliste übernehmen“ klicken.', 'ok'); - ev.target.value = ''; // Textarea ist ab jetzt die alleinige Quelle + ev.target.value = ''; // damit dieselbe Datei erneut auswählbar ist + // direkt uebernehmen: die ausgewaehlte Datei wird sofort geladen (die + // Textarea ist bereits befuellt und dient gaesteHochladen als Quelle) + await gaesteHochladen(); } async function gaesteHochladen() { @@ -308,16 +313,35 @@ async function gaesteHochladen() { try { const r = await api('/sitzung/' + sitzung + '/personen', { method: 'POST', body: form }); status('gaesteStatus', '✓ ' + r.personen + ' Personen in ' + r.gruppen.length + - ' Gruppen, dazu ' + r.vips + ' VIPs mit festem Tisch.', 'ok'); + ' Gruppen' + (r.fixiert ? ', dazu ' + r.fixiert + + ' fixierte Personen an festen Tischen' : '') + '.', 'ok'); + // regulaere Gruppen, danach die fest platzierten ("fixierten") Gruppen + // grau hinterlegt + const regulaer = r.gruppen.map(g => + '' + g.name + '' + g.anzahl + '').join(''); + const fixiert = (r.fixierte || []).map(f => + '' + f.name + + ' (fixiert an Tisch ' + f.tisch + ')' + + f.anzahl + '').join(''); const html = '' + - r.gruppen.map(g => '').join('') + - '
GruppePersonen
' + g.name + '' + g.anzahl + '
'; + regulaer + fixiert + ''; document.getElementById('gruppenUebersicht').innerHTML = html; berechnenFreigeben(); } catch (e) { status('gaesteStatus', 'Fehler: ' + e.message, 'fehler'); } } /* ------------------------------------------------ 3. Berechnen */ +async function strafprofileLaden() { + // Auswahl der Strafprofile aus strafen.cfg fuellen (z.B. default, locker) + try { + const r = await api('/strafprofile'); + const sel = document.getElementById('strafenWahl'); + sel.innerHTML = r.profile.map(p => + '').join(''); + } catch (e) { /* Auswahl bleibt leer -> Backend nutzt sein Default */ } +} + async function berechnenFreigeben() { try { const s = await api('/sitzung/' + sitzung); @@ -332,9 +356,12 @@ async function berechnen() { knopf.disabled = true; status('rechnenStatus', 'Der genetische Algorithmus läuft…'); try { - const r = await api('/sitzung/' + sitzung + '/berechnen?varianten=3', { method: 'POST' }); + const profil = document.getElementById('strafenWahl').value || 'default'; + const r = await api('/sitzung/' + sitzung + '/berechnen?varianten=3&strafen=' + + encodeURIComponent(profil), { method: 'POST' }); letzterLauf = r; - status('rechnenStatus', '✓ Lauf ' + r.lauf + ': ' + r.varianten.length + + status('rechnenStatus', '✓ Lauf ' + r.lauf + ' (Optimierungsprofil „' + r.strafen + '“): ' + + r.varianten.length + (r.varianten.length === 1 ? ' Variante gefunden (der Lauf konvergierte auf eine Sitzordnung).' : ' unterschiedliche Varianten zum Vergleich.'), 'ok'); @@ -367,6 +394,7 @@ async function zeigeVariante(i) { document.getElementById('kennzahlen').innerHTML = 'Variante: ' + v.nr + ' (Lauf ' + letzterLauf.lauf + ')' + + 'Optimierungsprofil: ' + letzterLauf.strafen + '' + 'Wertigkeit: ' + v.kennzahlen.wertigkeit + '' + 'getrennte Gruppen: ' + v.kennzahlen.getrennteGruppen + '' + 'allein sitzend: ' + v.kennzahlen.alleinePersonen + ''; @@ -439,6 +467,7 @@ function gruppeInteraktion(svg) { window.addEventListener('DOMContentLoaded', async () => { document.getElementById('csvText').value = beispielCSV; document.getElementById('csvDatei').addEventListener('change', csvDateiGewaehlt); + strafprofileLaden(); try { sitzung = await sitzungHolen(); document.getElementById('sitzungsId').textContent = sitzung;