Files
platz/web/main.py
T
Michael Stangl b29b3b22d4 SVG-Raumplan: Platzzahl je Tisch, Hover-Infos und Bild vor der Liste
- Platzzahl unter der Tischnummer im SVG
- Personenkreise mit <title> (Name - Gruppe) und data-Attributen
  (gruppe/name/gid); im Web den Raumplan inline statt als <img>
  einbetten, dazu ein Kasten mit der Gruppen-Namensliste und
  Hervorhebung der Gruppe beim Hover
- Ausgabe: Raumplan vor der Tabellenliste (schneller Varianten-Vergleich)
- SVG als UTF-8 schreiben (Umlaute/Gedankenstrich sonst ungueltig)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 17:30:33 +02:00

536 lines
18 KiB
Python

"""
REST-API um den bestehenden Sitzplatz-Kern (Meilenstein 1 der Roadmap).
Session-basiert, nichts wird dauerhaft gespeichert: jede Sitzung lebt
im Speicher des Servers und verfaellt nach 24 Stunden (TTL) — bewusst
ohne Accounts/DB, siehe Roadmap M1 ("schont Datenschutz-Aufwand").
Endpunkte (siehe auch /docs — interaktive Swagger-UI von FastAPI):
POST /sitzung — legt eine Arbeits-Session an
GET /sitzung/{id} — Status der Session
POST /sitzung/{id}/tische — Tisch-Layout als JSON (Ersatz tische.ini)
POST /sitzung/{id}/personen — Gaesteliste als CSV-Upload (Ersatz bestellung.json)
POST /sitzung/{id}/berechnen — startet die GA-Optimierung; liefert bis zu
?varianten=N unterschiedliche Loesungen (M2)
GET /sitzung/{id}/ergebnis — alle Varianten des letzten Laufs als JSON
GET /sitzung/{id}/ergebnis.svg — eine Variante als SVG (?variante=k, 1 = beste)
Start (lokale Testinstanz, siehe bin/run_web.bat / bin/run_web.sh):
python -m uvicorn main:app --reload --app-dir web
"""
import ast
import configparser
import math
import os
import sys
import tempfile
import threading
import time
import uuid
# Projektwurzel und Pfade robust aus der eigenen Lage ableiten, damit der
# Server auch ohne vorheriges setenv laeuft (PLATZ_* hat Vorrang, falls gesetzt)
BASISDIR = os.path.dirname( os.path.dirname( os.path.abspath( __file__ ) ) )
LIBSDIR = os.environ.get( 'PLATZ_LIBS', os.path.join( BASISDIR, 'libs' ) )
CFGDIR = os.environ.get( 'PLATZ_CFG', os.path.join( BASISDIR, 'cfg' ) )
if LIBSDIR not in sys.path:
sys.path.insert( 0, LIBSDIR )
from fastapi import FastAPI, File, HTTPException, Query, UploadFile
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from ga import Farm, Zyklus
from CSVConfig import CSVConfig
from Strukturdaten import Sitzplatzverteilung, Strafliste, Tisch, Tische
app = FastAPI(
title='Sitz & Platz — API',
description='Sitzplatz-Optimierung per genetischem Algorithmus '
'(Meilenstein 1: Session-basiert, ohne Accounts)',
version='0.1.0' )
# ---------------------------------------------------------------- GA-Konfig
# Zyklus und Strafliste einmal beim Start laden (wie in libs/platz.py):
# zyklus.cfg und strafen.cfg direkt aus dem cfg-Verzeichnis (PLATZ_CFG bzw.
# Projekt-cfg). GA-Profil per Umgebungsvariable PLATZ_ZYKLUSART (Vorgabe:
# Adaptiv), Strafpunkte-Profil per PLATZ_STRAFENART (Vorgabe: default) —
# analog zum Schalter --strafen in libs/platz.py.
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' )
GAZyklus = Zyklus()
GAZyklus.laden( ZYKLUSDATEI, ZYKLUSART )
Strafen = Strafliste()
Strafen.laden( STRAFENDATEI, STRAFENART )
# ---------------------------------------------------------------- Sessions
SESSION_TTL_SEKUNDEN = 24 * 3600
Sessions = {}
SessionsLock = threading.Lock()
def _SessionsAufraeumen():
"""Sessions loeschen, deren TTL abgelaufen ist (Datenschutz per Design)"""
Jetzt = time.time()
with SessionsLock:
Abgelaufen = [ Sid for Sid, S in Sessions.items()
if Jetzt - S['erzeugt'] > SESSION_TTL_SEKUNDEN ]
for Sid in Abgelaufen:
del Sessions[Sid]
def _GibSession(SitzungsId):
_SessionsAufraeumen()
with SessionsLock:
S = Sessions.get( SitzungsId )
if S is None:
raise HTTPException( status_code=404,
detail='Sitzung nicht gefunden oder abgelaufen (TTL 24h)' )
return S
# ------------------------------------------------------------ API-Modelle
class TischEingabe(BaseModel):
"""ein Tisch aus dem Frontend (Ersatz fuer eine tische.ini-Section)"""
id: int | None = Field( default=None, description='Tisch-Id; leer = fortlaufend' )
nummer: int | None = Field( default=None, description='Anzeigenummer; leer = wie Id' )
hof: int = Field( default=1, description='Hof/Raum-Nummer' )
plaetze: int = Field( gt=0, le=100, description='Anzahl Sitzplaetze' )
x: float = Field( description='X-Koordinate im Raster' )
y: float = Field( description='Y-Koordinate im Raster' )
nachbarn: list[int] | None = Field( default=None,
description='explizite Nachbar-Tisch-Ids; leer = aus Naehe ableiten' )
class TischLayout(BaseModel):
tische: list[TischEingabe] = Field( min_length=1 )
nachbarRadius: float = Field( default=1.5, gt=0,
description='max. Rasterabstand, bis zu dem zwei Tische als '
'Nachbarn gelten (nur fuer Tische ohne explizite Liste)' )
# --------------------------------------------------------------- Hilfen
def _NachbarnAbleiten(TischListe, Radius):
"""Nachbarliste aus raeumlicher Naehe der Koordinaten ableiten
(Roadmap M1: Ersatz fuer manuelle Nachbarliste-Pflege).
Explizit angegebene Nachbarn bleiben erhalten und werden
beidseitig ergaenzt."""
Nachbarn = { T.id: set( T.nachbarn or [] ) for T in TischListe }
for T1 in TischListe:
for T2 in TischListe:
if T1.id == T2.id:
continue
if T1.nachbarn is None or T2.nachbarn is None:
Abstand = math.dist( (T1.x, T1.y), (T2.x, T2.y) )
if Abstand <= Radius:
Nachbarn[T1.id].add( T2.id )
Nachbarn[T2.id].add( T1.id )
# expliziten Angaben beidseitig machen
for Tid, Menge in Nachbarn.items():
for NachbarId in Menge:
if NachbarId in Nachbarn:
Nachbarn[NachbarId].add( Tid )
return { Tid: sorted(Menge) for Tid, Menge in Nachbarn.items() }
def _TischeBauen(S):
"""baut aus dem gespeicherten Layout frische Tisch-Objekte
(pro Berechnung neu, damit keine Reste vorheriger Laeufe kleben)"""
TE = Tische( [] )
for T in S['tische']:
TE.Tisch_dazu( Tisch(
T['id'],
Plaetze=T['plaetze'],
Koordinaten=( T['x'], T['y'] ),
Nachbarliste=T['nachbarn'],
Hof=T['hof'],
Nummer=T['nummer'] ) )
return TE
def _Signatur(SV):
"""Sitzordnungs-Signatur einer Loesung: welche Personen an welchem Tisch.
Zwei Loesungen mit gleicher Signatur sind fuer den Nutzer dieselbe
Sitzordnung (der GA-Pool enthaelt viele Mutations-Verwandte) — die
Varianten-Auswahl dedupliziert damit (Roadmap M2: 'unterschiedliche
gute Loesungen')."""
return frozenset(
( T.Id, frozenset( P.Id for P in T ) ) for T in SV.Tische )
def _VarianteJSON(SV, GruppenNamen, Nr):
"""baut die JSON-Darstellung EINER Loesung (eine Variante)"""
def GruppeVonPerson(P):
try:
G = SV.Gruppen.GruppeVonPid( P.Id )
return GruppenNamen.get( G.Id, 'Gruppe %s' % G.Id )
except KeyError:
return 'VIP'
TischListe = []
for T in SV.Tische:
Personen = [ {
'titel': P._Titel,
'vorname': P._Vorname,
'nachname': P._Name,
'gruppe': GruppeVonPerson( P ),
} for P in T ]
Personen.sort( key=lambda p: (p['gruppe'], p['nachname'], p['vorname']) )
TischListe.append( {
'id': T.Id,
'nummer': T.Nummer,
'hof': T.Hof,
'plaetze': T.Plaetze,
'besetzt': T.besetztePlaetze(),
'frei': T.freiePlaetze(),
'wert': SV.TischWertGet( T ),
'personen': Personen,
} )
TischListe.sort( key=lambda t: t['id'] )
GruppenListe = []
for G in SV.Gruppen:
GruppenListe.append( {
'id': G.Id,
'name': GruppenNamen.get( G.Id, 'Gruppe %s' % G.Id ),
'anzahl': G.Anzahl(),
'teilungen': SV.GruppenTeilungGet( G ),
'wert': SV.GruppenWertGet( G ),
} )
GruppenListe.sort( key=lambda g: g['id'] )
# Vergleichs-Kennzahlen (Roadmap M2: Wertigkeit, Gruppentrennungen,
# allein sitzende Personen — LeuteAllein zaehlt nur Personen aus
# mehrkoepfigen Gruppen ohne Gruppenmitglied am Tisch)
Kennzahlen = {
'wertigkeit': SV.value,
'getrennteGruppen': sum( 1 for G in GruppenListe if G['teilungen'] > 0 ),
'teilungenGesamt': sum( G['teilungen'] for G in GruppenListe ),
'alleinePersonen': sum( len( SV.LeuteAllein(T) ) for T in SV.Tische ),
}
return {
'nr': Nr,
'wertigkeit': SV.value,
'kennzahlen': Kennzahlen,
'tische': TischListe,
'gruppen': GruppenListe,
}
def _ErgebnisJSON(S):
"""baut die JSON-Darstellung aller Varianten des letzten Laufs"""
GruppenNamen = S.get( 'gruppenNamen', {} )
return {
'lauf': S['laeufe'],
'varianten': [ _VarianteJSON( SV, GruppenNamen, Nr + 1 )
for Nr, SV in enumerate( S['ergebnisse'] ) ],
}
# --------------------------------------------------------------- Endpunkte
@app.post( '/sitzung', status_code=201 )
def SitzungAnlegen():
"""legt eine neue Arbeits-Session an (TTL 24h, nur im Speicher)"""
_SessionsAufraeumen()
SitzungsId = uuid.uuid4().hex
with SessionsLock:
Sessions[SitzungsId] = {
'erzeugt': time.time(),
'tische': None,
'personen': None,
'ergebnisse': [], # Varianten des letzten Laufs (beste zuerst)
'laeufe': 0, # wie oft .../berechnen aufgerufen wurde
}
return { 'id': SitzungsId, 'ttlStunden': SESSION_TTL_SEKUNDEN // 3600 }
@app.get( '/sitzung/{SitzungsId}' )
def SitzungStatus(SitzungsId: str):
"""Status: was ist schon da, was fehlt noch"""
S = _GibSession( SitzungsId )
return {
'id': SitzungsId,
'tische': len( S['tische'] ) if S['tische'] else 0,
'personen': S['personen']['nLeute'] if S['personen'] else 0,
'vips': S['personen']['nVips'] if S['personen'] else 0,
'berechnet': len( S['ergebnisse'] ) > 0,
'laeufe': S['laeufe'],
'varianten': len( S['ergebnisse'] ),
}
@app.post( '/sitzung/{SitzungsId}/tische' )
def TischeSetzen(SitzungsId: str, Layout: TischLayout):
"""Tisch-Layout uebernehmen (JSON-Ersatz fuer tische.ini);
Nachbarschaft wird aus raeumlicher Naehe abgeleitet"""
S = _GibSession( SitzungsId )
# Ids vergeben/pruefen
NaechsteId = 1
Vergeben = set()
for T in Layout.tische:
if T.id is None:
while NaechsteId in Vergeben:
NaechsteId = NaechsteId + 1
T.id = NaechsteId
if T.id in Vergeben:
raise HTTPException( status_code=400,
detail='Tisch-Id %d ist doppelt' % T.id )
Vergeben.add( T.id )
Nachbarn = _NachbarnAbleiten( Layout.tische, Layout.nachbarRadius )
S['tische'] = [ {
'id': T.id,
'nummer': T.nummer if T.nummer is not None else T.id,
'hof': T.hof,
'plaetze': T.plaetze,
'x': T.x,
'y': T.y,
'nachbarn': Nachbarn[T.id],
} for T in Layout.tische ]
# Nachbar-Radius fuer den Rueckweg merken (GET .../tische), damit der
# grafische Editor dasselbe Layout inkl. Radius wieder laden kann.
S['nachbarRadius'] = Layout.nachbarRadius
S['ergebnisse'] = []
return {
'tische': S['tische'],
'plaetzeGesamt': sum( T['plaetze'] for T in S['tische'] ),
}
@app.get( '/sitzung/{SitzungsId}/tische' )
def TischeHolen(SitzungsId: str):
"""das gespeicherte Tisch-Layout zurueckgeben (fuer den grafischen
Editor: Layout aus einer geteilten Session wieder aufnehmen).
Die abgeleitete Nachbarliste ist pro Tisch enthalten; der zuletzt
verwendete nachbarRadius kommt separat mit (Vorgabe 1.5)."""
S = _GibSession( SitzungsId )
return {
'tische': S['tische'] or [],
'nachbarRadius': S.get( 'nachbarRadius', 1.5 ),
}
@app.post( '/sitzung/{SitzungsId}/tische-datei' )
def TischDateiHochladen(SitzungsId: str, datei: UploadFile = File(...)):
"""bestehende tische.ini hochladen (gleiches Format wie fuer die CLI,
z.B. work/hochzeit/tische.ini); die Nachbarliste aus der Datei
wird unveraendert uebernommen, nichts wird neu abgeleitet"""
S = _GibSession( SitzungsId )
Rohdaten = datei.file.read()
try:
Text = Rohdaten.decode( 'utf-8-sig' )
except UnicodeDecodeError:
Text = Rohdaten.decode( 'cp1252' )
config = configparser.ConfigParser()
try:
config.read_string( Text )
except configparser.Error as Fehler:
raise HTTPException( status_code=400,
detail='tische.ini nicht lesbar: %s' % Fehler )
if not config.sections():
raise HTTPException( status_code=400,
detail='tische.ini enthaelt keine Tisch-Sektionen ([1], [2], ...)' )
# gleiche Feldsemantik wie Tische.laden() in Strukturdaten.py,
# aber mit ast.literal_eval statt eval (Upload = fremde Eingabe)
TischListe = []
for Sektion in config.sections():
try:
Tid = int( Sektion )
Plaetze = int( ast.literal_eval( config.get( Sektion, 'Plaetze' ) ) )
Koords = ast.literal_eval( config.get( Sektion, 'Koordinaten' ) )
Nachbarn = list( ast.literal_eval(
config.get( Sektion, 'Nachbarliste' ) ) )
Nummer = int( ast.literal_eval(
config.get( Sektion, 'Nummer', fallback=Sektion ) ) )
Hof = int( ast.literal_eval(
config.get( Sektion, 'Hof', fallback='1' ) ) )
except (ValueError, SyntaxError, configparser.Error) as Fehler:
raise HTTPException( status_code=400,
detail='tische.ini, Sektion [%s]: %s' % (Sektion, Fehler) )
if Plaetze < 1:
raise HTTPException( status_code=400,
detail='tische.ini, Sektion [%s]: Plaetze muss >= 1 sein'
% Sektion )
TischListe.append( {
'id': Tid,
'nummer': Nummer,
'hof': Hof,
'plaetze': Plaetze,
'x': float( Koords[0] ),
'y': float( Koords[1] ),
'nachbarn': sorted( int(N) for N in Nachbarn ),
} )
Ids = [ T['id'] for T in TischListe ]
if len( Ids ) != len( set( Ids ) ):
raise HTTPException( status_code=400,
detail='tische.ini enthaelt doppelte Tisch-Ids' )
S['tische'] = TischListe
S['ergebnisse'] = []
return {
'tische': S['tische'],
'plaetzeGesamt': sum( T['plaetze'] for T in S['tische'] ),
}
@app.post( '/sitzung/{SitzungsId}/personen' )
def PersonenHochladen(SitzungsId: str, datei: UploadFile = File(...)):
"""Gaesteliste als CSV hochladen (Ersatz fuer bestellung.json);
Format siehe libs/CSVConfig.py"""
S = _GibSession( SitzungsId )
Rohdaten = datei.file.read()
try:
Text = Rohdaten.decode( 'utf-8-sig' )
except UnicodeDecodeError:
Text = Rohdaten.decode( 'cp1252' ) # Excel/Windows-Fallback
try:
[ PN, GN, VIPGruppe, VipHash, GruppenNamen ] = \
CSVConfig().HandleGaesteliste( Text )
except ValueError as Fehler:
raise HTTPException( status_code=400, detail=str(Fehler) )
S['personen'] = {
'PN': PN,
'GN': GN,
'VIPGruppe': VIPGruppe,
'VipHash': VipHash,
'nLeute': GN.NLeute,
'nVips': VIPGruppe.Anzahl(),
}
S['gruppenNamen'] = GruppenNamen
S['ergebnisse'] = []
return {
'personen': GN.NLeute,
'vips': VIPGruppe.Anzahl(),
'gruppen': [ {
'id': G.Id,
'name': GruppenNamen.get( G.Id, 'Gruppe %s' % G.Id ),
'anzahl': G.Anzahl(),
} for G in GN ],
}
@app.post( '/sitzung/{SitzungsId}/berechnen' )
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)' )):
"""startet die GA-Optimierung; jeder Aufruf ist ein neuer Lauf und
liefert bis zu 'varianten' unterschiedliche gute Sitzordnungen"""
S = _GibSession( SitzungsId )
if S['tische'] is None:
raise HTTPException( status_code=400,
detail='Erst das Tisch-Layout setzen (POST .../tische)' )
if S['personen'] is None:
raise HTTPException( status_code=400,
detail='Erst die Gaesteliste hochladen (POST .../personen)' )
TE = _TischeBauen( S )
P = S['personen']
# Vorab-Pruefungen mit klaren Fehlermeldungen (der Kern wirft sonst
# nur IndexError/AttributeError tief aus der Platzierung heraus)
GesamtLeute = P['nLeute'] + P['nVips']
if GesamtLeute > TE.NStuehle:
raise HTTPException( status_code=400,
detail='Zu wenig Stuehle: %d Personen, aber nur %d Plaetze'
% (GesamtLeute, TE.NStuehle) )
VIPsProTisch = {}
for Pid, Tid in P['VipHash'].items():
VIPsProTisch[Tid] = VIPsProTisch.get( Tid, 0 ) + 1
for Tid, AnzahlVIPs in VIPsProTisch.items():
T = TE.vonId( Tid )
if T is None:
raise HTTPException( status_code=400,
detail='VIP-Tisch %d existiert nicht im Layout' % Tid )
if AnzahlVIPs > T.Plaetze:
raise HTTPException( status_code=400,
detail='Tisch %d hat %d Plaetze, aber %d VIPs sind '
'dort fest gesetzt' % (Tid, T.Plaetze, AnzahlVIPs) )
try:
GAFarm = Farm( GAZyklus, Sitzplatzverteilung,
Tische=TE, Gruppen=P['GN'], Strafen=Strafen,
VIPListe=P['VipHash'], VIPs=P['VIPGruppe'] )
except IndexError as Fehler:
raise HTTPException( status_code=400, detail=str(Fehler) )
# Roadmap M2: nicht nur den Besten, sondern bis zu 'varianten'
# UNTERSCHIEDLICHE gute Loesungen aus dem Endpool — Farm.TopN liefert
# absteigend sortiert, die Signatur-Deduplizierung wirft Loesungen mit
# identischer Sitzordnung raus (Mutations-Verwandte). Liefert der Pool
# weniger unterschiedliche Sitzordnungen, kommen eben weniger zurueck
# (ein weiterer Klick auf Berechnen erzeugt einen frischen Lauf).
Ergebnisse, Gesehen = [], set()
for SV in GAFarm.TopN( len( GAFarm.Pool ) ):
Sig = _Signatur( SV )
if Sig in Gesehen:
continue
Gesehen.add( Sig )
Ergebnisse.append( SV )
if len( Ergebnisse ) == varianten:
break
S['ergebnisse'] = Ergebnisse
S['laeufe'] = S['laeufe'] + 1
return _ErgebnisJSON( S )
@app.get( '/sitzung/{SitzungsId}/ergebnis' )
def Ergebnis(SitzungsId: str):
"""alle Varianten des letzten Laufs als JSON (beste zuerst)"""
S = _GibSession( SitzungsId )
if not S['ergebnisse']:
raise HTTPException( status_code=404,
detail='Noch nichts berechnet (POST .../berechnen)' )
return _ErgebnisJSON( S )
@app.get( '/sitzung/{SitzungsId}/ergebnis.svg' )
def ErgebnisSVG(SitzungsId: str,
variante: int = Query( default=1, ge=1,
description='Nummer der Variante (1 = beste)' )):
"""eine berechnete Variante als SVG (alsSVG aus dem Kern)"""
S = _GibSession( SitzungsId )
if not S['ergebnisse']:
raise HTTPException( status_code=404,
detail='Noch nichts berechnet (POST .../berechnen)' )
if variante > len( S['ergebnisse'] ):
raise HTTPException( status_code=404,
detail='Variante %d gibt es nicht (letzter Lauf hat %d)'
% (variante, len( S['ergebnisse'] )) )
# alsSVG schreibt in eine Datei -> Tempdatei schreiben, lesen, loeschen
Handle, TempPfad = tempfile.mkstemp( suffix='.svg' )
os.close( Handle )
try:
S['ergebnisse'][variante - 1].alsSVG( TempPfad,
GruppenNamen=S.get( 'gruppenNamen', {} ) )
fsock = open( TempPfad, 'r' )
SVG = fsock.read()
fsock.close()
finally:
os.remove( TempPfad )
return Response( content=SVG, media_type='image/svg+xml' )
# statisches Test-Frontend unter / (muss nach den API-Routen gemountet sein)
app.mount( '/', StaticFiles(
directory=os.path.join( os.path.dirname( os.path.abspath( __file__ ) ),
'static' ),
html=True ), name='static' )