Files
platz/web/main.py
T
Michael Stangl 9de80d2f2a Web-M1-Ausbau: JSON-Gaesteliste, GA-Verlauf, Python-Serversteuerung, tische.ini-Upload
- bestellung.xml -> bestellung.json: neuer JSONConfig (loest XMLConfig ab),
  alle work-Beispiele konvertiert, Doku (CLAUDE/README/dateiformate) umgestellt
- ga.py: Beobachtungsobjekt Entwicklung zeichnet den Loesungsverlauf auf,
  neuer Schalter --show-development erzeugt Entwicklung.svg (beste/mittlere/
  schlechteste Wertigkeit ueber die Zyklus-Aktionen)
- web/main.py: neuer Endpunkt /tische-datei (tische.ini-Upload, ast.literal_eval),
  Frontend kann jetzt eine tische.ini laden
- libs/webserver.py: plattformunabhaengige start/stop/status-Steuerung der
  Web-Testinstanz (ersetzt run_web_helper.ps1), raeumt auch verwaiste
  --reload-Prozessbaeume zuverlaessig auf
- Sicherheit/Modernisierung: eval() -> ast.literal_eval in Tische/Strafliste,
  sicherer Mini-Auswerter fuer die String-Ausdruecke in zyklus.cfg,
  defaultdict(set) in Plaetze
- SVG: Tischnummer fett zentriert im Tischkreis statt "Tisch x" darueber
- Tests: JSONConfig, Entwicklung und der Zyklus-Auswerter abgedeckt (9 Tests)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:49:42 +02:00

471 lines
15 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
GET /sitzung/{id}/ergebnis — Ergebnis als JSON (Tische -> Personen)
GET /sitzung/{id}/ergebnis.svg — Ergebnis als SVG (alsSVG aus dem Kern)
Start (lokale Testinstanz, siehe bin/run_web.bat / bin/run_web.sh):
python -m uvicorn main:app --reload --app-dir web
"""
import ast
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, 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)
import configparser
_PlatzCfg = configparser.ConfigParser()
_PlatzCfg.read( os.path.join( CFGDIR, 'platz.cfg' ) )
ZYKLUSDATEI = os.path.expandvars( os.path.normpath(
_PlatzCfg.get( 'Config', 'Zyklusdatei',
fallback=os.path.join( CFGDIR, 'zyklus.cfg' ) ) ) )
ZYKLUSART = _PlatzCfg.get( 'Config', 'Zyklusart', fallback='Easy' )
STRAFENDATEI = os.path.expandvars( os.path.normpath(
_PlatzCfg.get( 'Config', 'Strafendatei',
fallback=os.path.join( CFGDIR, 'strafen.cfg' ) ) ) )
if not os.path.isfile( ZYKLUSDATEI ):
ZYKLUSDATEI = os.path.join( CFGDIR, 'zyklus.cfg' )
if not os.path.isfile( STRAFENDATEI ):
STRAFENDATEI = os.path.join( CFGDIR, 'strafen.cfg' )
GAZyklus = Zyklus()
GAZyklus.laden( ZYKLUSDATEI, ZYKLUSART )
Strafen = Strafliste()
Strafen.laden( STRAFENDATEI )
# ---------------------------------------------------------------- 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 _ErgebnisJSON(S):
"""baut die JSON-Darstellung des besten Ergebnisses einer Session"""
SV = S['ergebnis']
GruppenNamen = S.get( 'gruppenNamen', {} )
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'] )
return {
'wertigkeit': SV.value,
'variante': S['varianten'],
'tische': TischListe,
'gruppen': GruppenListe,
}
# --------------------------------------------------------------- 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,
'ergebnis': None,
'varianten': 0,
}
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': S['ergebnis'] is not None,
'varianten': S['varianten'],
}
@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 ]
S['ergebnis'] = None
S['varianten'] = 0
return {
'tische': S['tische'],
'plaetzeGesamt': sum( T['plaetze'] for T in S['tische'] ),
}
@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['ergebnis'] = None
S['varianten'] = 0
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['ergebnis'] = None
S['varianten'] = 0
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):
"""startet die GA-Optimierung; jeder Aufruf erzeugt eine neue Variante"""
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) )
S['ergebnis'] = GAFarm.Bester()
S['varianten'] = S['varianten'] + 1
return _ErgebnisJSON( S )
@app.get( '/sitzung/{SitzungsId}/ergebnis' )
def Ergebnis(SitzungsId: str):
"""das beste bisher berechnete Ergebnis als JSON"""
S = _GibSession( SitzungsId )
if S['ergebnis'] is None:
raise HTTPException( status_code=404,
detail='Noch nichts berechnet (POST .../berechnen)' )
return _ErgebnisJSON( S )
@app.get( '/sitzung/{SitzungsId}/ergebnis.svg' )
def ErgebnisSVG(SitzungsId: str):
"""das beste bisher berechnete Ergebnis als SVG (alsSVG aus dem Kern)"""
S = _GibSession( SitzungsId )
if S['ergebnis'] is None:
raise HTTPException( status_code=404,
detail='Noch nichts berechnet (POST .../berechnen)' )
# alsSVG schreibt in eine Datei -> Tempdatei schreiben, lesen, loeschen
Handle, TempPfad = tempfile.mkstemp( suffix='.svg' )
os.close( Handle )
try:
S['ergebnis'].alsSVG( TempPfad )
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' )