Web-MVP (M1): FastAPI-API, Testoberflaeche, tische.ini-Upload, zwei neue Beispiele
- web/main.py: Session-basierte REST-API um den GA-Kern (POST /sitzung, .../tische, .../tische-datei, .../personen, .../berechnen, GET .../ergebnis[.svg]); Sessions in-memory mit 24h-TTL - web/static/index.html: einfache Browser-Testoberflaeche (Tisch-Tabelle mit Presets, tische.ini-Upload, CSV-Gaesteliste, Kennzahlen, SVG) - bin/run_web.bat/.sh + run_web_helper.ps1: start/stop/status inkl. Aufraeumen verwaister uvicorn-Reload-Kindprozesse (Zombie-Sockets) - work/hochzeit: 120 Hochzeitsgaeste, Ehrentafel, Kindertisch/Teenager- Tisch fest gesetzt, Kinderalter normalverteilt, ~85% Auslastung - work/innenhoefe: 300 Gaeste in zwei trapezfoermigen Innenhoefen mit 4er-Tischen, 12 VIPs vor der Buehne, Umfeld.svg mit Tanzflaeche/Band - Roadmap: Konzeptentscheidungen 1-11 (Zielgruppe, Einmalzahlung, MoR, Hetzner, Zwei-Marken-Strategie Sitz&Platz/SeatWonder), Namensrecherche im Anhang, M1-Fortschritt und GA-Lasttest-Datenpunkt dokumentiert Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+470
@@ -0,0 +1,470 @@
|
||||
"""
|
||||
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.xml)
|
||||
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.xml);
|
||||
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' )
|
||||
@@ -0,0 +1,329 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sitz & Platz — Testoberfläche (M1)</title>
|
||||
<style>
|
||||
:root {
|
||||
--akzent: #2f6f4f;
|
||||
--rand: #d9d4c8;
|
||||
--hintergrund: #faf8f3;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
margin: 0; background: var(--hintergrund); color: #2a2a24;
|
||||
line-height: 1.5;
|
||||
}
|
||||
header {
|
||||
background: var(--akzent); color: white;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
header h1 { margin: 0; font-size: 1.3rem; }
|
||||
header p { margin: .2rem 0 0; opacity: .85; font-size: .85rem; }
|
||||
main { max-width: 60rem; margin: 0 auto; padding: 1rem 1.5rem 4rem; }
|
||||
section {
|
||||
background: white; border: 1px solid var(--rand);
|
||||
border-radius: 8px; padding: 1rem 1.25rem; margin-top: 1.25rem;
|
||||
}
|
||||
h2 { font-size: 1.05rem; margin: 0 0 .75rem; color: var(--akzent); }
|
||||
table { border-collapse: collapse; width: 100%; font-size: .9rem; }
|
||||
th, td { border: 1px solid var(--rand); padding: .3rem .5rem; text-align: left; }
|
||||
th { background: #f1eee6; }
|
||||
input[type=number] { width: 4.5rem; }
|
||||
textarea {
|
||||
width: 100%; min-height: 11rem; font-family: ui-monospace, monospace;
|
||||
font-size: .85rem; border: 1px solid var(--rand); border-radius: 4px;
|
||||
padding: .5rem;
|
||||
}
|
||||
button {
|
||||
background: var(--akzent); color: white; border: 0;
|
||||
border-radius: 4px; padding: .45rem .9rem; font-size: .9rem;
|
||||
cursor: pointer; margin: .25rem .35rem .25rem 0;
|
||||
}
|
||||
button.sekundaer { background: #8b8577; }
|
||||
button:disabled { background: #b9b4a6; cursor: not-allowed; }
|
||||
.status { font-size: .85rem; margin-top: .5rem; min-height: 1.2rem; }
|
||||
.status.ok { color: var(--akzent); }
|
||||
.status.fehler { color: #a03030; white-space: pre-wrap; }
|
||||
#svgBild { max-width: 100%; border: 1px solid var(--rand); border-radius: 4px;
|
||||
margin-top: .75rem; background: white; }
|
||||
.kennzahl { display: inline-block; background: #f1eee6; border-radius: 4px;
|
||||
padding: .3rem .7rem; margin: .15rem .3rem .15rem 0; font-size: .9rem; }
|
||||
.kennzahl b { color: var(--akzent); }
|
||||
.hinweis { font-size: .8rem; color: #6b6557; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Sitz & Platz <small style="font-weight:normal">— Testoberfläche (Meilenstein 1)</small></h1>
|
||||
<p>Session-basiert, nichts wird dauerhaft gespeichert (TTL 24 h). Sitzung: <code id="sitzungsId">wird angelegt…</code></p>
|
||||
</header>
|
||||
<main>
|
||||
|
||||
<section>
|
||||
<h2>1. Tische definieren</h2>
|
||||
<p class="hinweis">Koordinaten sind Rasterpositionen (wie in tische.ini). Tische, die
|
||||
näher als der Nachbar-Radius (1,5 Rastereinheiten) beieinander liegen, gelten
|
||||
automatisch als Nachbarn — Gruppen, die geteilt werden müssen, kommen bevorzugt
|
||||
an Nachbartische.</p>
|
||||
<table id="tischTabelle">
|
||||
<thead>
|
||||
<tr><th>Tisch-Nr.</th><th>Plätze</th><th>X</th><th>Y</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<button type="button" onclick="tischZeile()">+ Tisch hinzufügen</button>
|
||||
<button type="button" class="sekundaer" onclick="presetReihe()">Preset: 4er-Reihe</button>
|
||||
<button type="button" class="sekundaer" onclick="presetRaster()">Preset: 3×3-Raster</button>
|
||||
<br>
|
||||
<label class="hinweis">Oder eine bestehende <code>tische.ini</code> laden
|
||||
(z. B. aus <code>work/hochzeit</code>):</label>
|
||||
<input type="file" id="tischDatei" accept=".ini,text/plain">
|
||||
<button type="button" class="sekundaer" onclick="tischDateiLaden()">tische.ini laden</button>
|
||||
<br>
|
||||
<button type="button" onclick="tischeSpeichern()">Tische speichern</button>
|
||||
<div class="status" id="tischStatus"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Gästeliste (CSV)</h2>
|
||||
<p class="hinweis">Spalten: <code>Vorname;Nachname;Titel;Gruppe;Tisch;Anzahl</code> —
|
||||
<code>Gruppe</code> hält Personen zusammen (leer = Einzelgast),
|
||||
<code>Tisch</code> setzt VIPs fest (z. B. Brautpaar an Tisch 1),
|
||||
<code>Anzahl</code> expandiert eine Zeile in mehrere Buchungen
|
||||
(anonyme Sammelbuchung). Datei hochladen <em>oder</em> direkt hier bearbeiten:</p>
|
||||
<textarea id="csvText"></textarea>
|
||||
<br>
|
||||
<input type="file" id="csvDatei" accept=".csv,text/csv">
|
||||
<br>
|
||||
<button type="button" onclick="gaesteHochladen()">Gästeliste übernehmen</button>
|
||||
<div class="status" id="gaesteStatus"></div>
|
||||
<div id="gruppenUebersicht"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. Sitzordnung berechnen</h2>
|
||||
<p class="hinweis">Jeder Klick startet den genetischen Algorithmus neu und liefert
|
||||
eine neue Variante — die Wertigkeit (0 = perfekt, negativer = schlechter)
|
||||
macht Varianten vergleichbar.</p>
|
||||
<button type="button" id="berechnenKnopf" onclick="berechnen()" disabled>Sitzordnung berechnen</button>
|
||||
<div class="status" id="rechnenStatus"></div>
|
||||
<div id="kennzahlen"></div>
|
||||
<div id="ergebnisTabellen"></div>
|
||||
<img id="svgBild" alt="" style="display:none">
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<script>
|
||||
'use strict';
|
||||
let sitzung = null;
|
||||
|
||||
async function api(pfad, optionen) {
|
||||
const antwort = await fetch(pfad, optionen);
|
||||
const daten = await antwort.json().catch(() => null);
|
||||
if (!antwort.ok) {
|
||||
const detail = daten && daten.detail
|
||||
? (typeof daten.detail === 'string' ? daten.detail : JSON.stringify(daten.detail, null, 2))
|
||||
: antwort.statusText;
|
||||
throw new Error(detail);
|
||||
}
|
||||
return daten;
|
||||
}
|
||||
|
||||
function status(id, text, klasse) {
|
||||
const el = document.getElementById(id);
|
||||
el.textContent = text;
|
||||
el.className = 'status ' + (klasse || '');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ 1. Tische */
|
||||
function tischZeile(plaetze, x, y, tid) {
|
||||
const tbody = document.querySelector('#tischTabelle tbody');
|
||||
const nr = tid ?? tbody.children.length + 1;
|
||||
const tr = document.createElement('tr');
|
||||
if (tid !== undefined) tr.dataset.tid = tid; // echte Id aus tische.ini behalten
|
||||
tr.innerHTML =
|
||||
'<td class="tischNr">' + nr + '</td>' +
|
||||
'<td><input type="number" min="1" max="100" value="' + (plaetze ?? 8) + '" class="plaetze"></td>' +
|
||||
'<td><input type="number" step="1" value="' + (x ?? nr) + '" class="x"></td>' +
|
||||
'<td><input type="number" step="1" value="' + (y ?? 1) + '" class="y"></td>' +
|
||||
'<td><button type="button" class="sekundaer" onclick="this.closest(\'tr\').remove(); nummerieren()">✕</button></td>';
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
function nummerieren() {
|
||||
document.querySelectorAll('#tischTabelle tbody tr').forEach((tr, i) => {
|
||||
if (tr.dataset.tid === undefined)
|
||||
tr.querySelector('.tischNr').textContent = i + 1;
|
||||
});
|
||||
}
|
||||
function tischeLeeren() {
|
||||
document.querySelector('#tischTabelle tbody').innerHTML = '';
|
||||
}
|
||||
function presetReihe() {
|
||||
tischeLeeren();
|
||||
for (let i = 1; i <= 4; i++) tischZeile(5, i, 1);
|
||||
}
|
||||
function presetRaster() {
|
||||
tischeLeeren();
|
||||
for (let y = 1; y <= 3; y++)
|
||||
for (let x = 1; x <= 3; x++)
|
||||
tischZeile(8, x, y);
|
||||
}
|
||||
async function tischeSpeichern() {
|
||||
const zeilen = [...document.querySelectorAll('#tischTabelle tbody tr')];
|
||||
if (zeilen.length === 0) { status('tischStatus', 'Mindestens ein Tisch nötig.', 'fehler'); return; }
|
||||
// Zeilen aus einer geladenen tische.ini behalten ihre echte Id
|
||||
// (wichtig fuer VIP-Bindungen wie "Tisch 16"); neue Zeilen bekommen
|
||||
// die naechste freie Id
|
||||
const vergeben = new Set(zeilen.map(tr => parseInt(tr.dataset.tid, 10)).filter(n => !isNaN(n)));
|
||||
let naechste = 1;
|
||||
const freieId = () => { while (vergeben.has(naechste)) naechste++; vergeben.add(naechste); return naechste; };
|
||||
const tische = zeilen.map(tr => ({
|
||||
id: tr.dataset.tid !== undefined ? parseInt(tr.dataset.tid, 10) : freieId(),
|
||||
plaetze: parseInt(tr.querySelector('.plaetze').value, 10),
|
||||
x: parseFloat(tr.querySelector('.x').value),
|
||||
y: parseFloat(tr.querySelector('.y').value),
|
||||
}));
|
||||
try {
|
||||
const r = await api('/sitzung/' + sitzung + '/tische', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tische }),
|
||||
});
|
||||
const nachbarn = r.tische.map(t => 'Tisch ' + t.id + ' ↔ [' + t.nachbarn.join(', ') + ']').join(' ');
|
||||
status('tischStatus', '✓ ' + r.tische.length + ' Tische, ' + r.plaetzeGesamt +
|
||||
' Plätze gesamt. Abgeleitete Nachbarn: ' + nachbarn, 'ok');
|
||||
berechnenFreigeben();
|
||||
} catch (e) { status('tischStatus', 'Fehler: ' + e.message, 'fehler'); }
|
||||
}
|
||||
|
||||
async function tischDateiLaden() {
|
||||
const eingabe = document.getElementById('tischDatei');
|
||||
if (eingabe.files.length === 0) {
|
||||
status('tischStatus', 'Bitte zuerst eine tische.ini auswählen.', 'fehler');
|
||||
return;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('datei', eingabe.files[0]);
|
||||
try {
|
||||
const r = await api('/sitzung/' + sitzung + '/tische-datei', { method: 'POST', body: form });
|
||||
tischeLeeren();
|
||||
for (const t of r.tische) tischZeile(t.plaetze, t.x, t.y, t.id);
|
||||
status('tischStatus', '✓ ' + r.tische.length + ' Tische aus Datei übernommen (' +
|
||||
r.plaetzeGesamt + ' Plätze) — Layout ist bereits gespeichert, ' +
|
||||
'„Tische speichern“ ist nur nach Änderungen nötig.', 'ok');
|
||||
berechnenFreigeben();
|
||||
} catch (e) { status('tischStatus', 'Fehler: ' + e.message, 'fehler'); }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ 2. Gäste */
|
||||
const beispielCSV = `Vorname;Nachname;Titel;Gruppe;Tisch;Anzahl
|
||||
Britta;Beispiel;;Brautpaar;1;
|
||||
Bernd;Beispiel;Dr.;Brautpaar;1;
|
||||
Anna;Huber;;Familie Huber;;
|
||||
Berta;Huber;;Familie Huber;;
|
||||
Carl;Huber;;Familie Huber;;
|
||||
Doris;Huber;;Familie Huber;;
|
||||
Emil;Meier;;Familie Meier;;
|
||||
Frieda;Meier;;Familie Meier;;
|
||||
Gustav;Meier;;Familie Meier;;
|
||||
Hanna;Schmidt;;Studienfreunde;;
|
||||
Igor;Petrov;;Studienfreunde;;
|
||||
Julia;Weber;;Studienfreunde;;
|
||||
Gast;Kollegium;;Kollegen;;4
|
||||
Karl;Einzelgast;;;;
|
||||
`;
|
||||
|
||||
async function gaesteHochladen() {
|
||||
const dateiInput = document.getElementById('csvDatei');
|
||||
let blob;
|
||||
if (dateiInput.files.length > 0) {
|
||||
blob = dateiInput.files[0];
|
||||
} else {
|
||||
blob = new Blob([document.getElementById('csvText').value], { type: 'text/csv' });
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('datei', blob, 'gaeste.csv');
|
||||
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');
|
||||
const html = '<table><thead><tr><th>Gruppe</th><th>Personen</th></tr></thead><tbody>' +
|
||||
r.gruppen.map(g => '<tr><td>' + g.name + '</td><td>' + g.anzahl + '</td></tr>').join('') +
|
||||
'</tbody></table>';
|
||||
document.getElementById('gruppenUebersicht').innerHTML = html;
|
||||
berechnenFreigeben();
|
||||
} catch (e) { status('gaesteStatus', 'Fehler: ' + e.message, 'fehler'); }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ 3. Berechnen */
|
||||
async function berechnenFreigeben() {
|
||||
try {
|
||||
const s = await api('/sitzung/' + sitzung);
|
||||
document.getElementById('berechnenKnopf').disabled = !(s.tische > 0 && s.personen > 0);
|
||||
} catch (e) { /* Session ggf. abgelaufen — beim Berechnen gemeldet */ }
|
||||
}
|
||||
|
||||
async function berechnen() {
|
||||
const knopf = document.getElementById('berechnenKnopf');
|
||||
knopf.disabled = true;
|
||||
status('rechnenStatus', 'Der genetische Algorithmus läuft…');
|
||||
try {
|
||||
const r = await api('/sitzung/' + sitzung + '/berechnen', { method: 'POST' });
|
||||
status('rechnenStatus', '✓ Variante ' + r.variante + ' berechnet.', 'ok');
|
||||
knopf.textContent = 'Neue Variante berechnen';
|
||||
zeigeErgebnis(r);
|
||||
const svg = await fetch('/sitzung/' + sitzung + '/ergebnis.svg');
|
||||
const svgText = await svg.text();
|
||||
const bild = document.getElementById('svgBild');
|
||||
bild.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgText);
|
||||
bild.style.display = 'block';
|
||||
} catch (e) {
|
||||
status('rechnenStatus', 'Fehler: ' + e.message, 'fehler');
|
||||
} finally {
|
||||
knopf.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function zeigeErgebnis(r) {
|
||||
const getrennte = r.gruppen.filter(g => g.teilungen > 0).length;
|
||||
document.getElementById('kennzahlen').innerHTML =
|
||||
'<span class="kennzahl">Wertigkeit: <b>' + r.wertigkeit + '</b></span>' +
|
||||
'<span class="kennzahl">Variante: <b>' + r.variante + '</b></span>' +
|
||||
'<span class="kennzahl">getrennte Gruppen: <b>' + getrennte + '</b></span>';
|
||||
|
||||
let html = '<table><thead><tr><th>Tisch</th><th>Belegung</th><th>Gäste (Gruppe)</th></tr></thead><tbody>';
|
||||
for (const t of r.tische) {
|
||||
const gaeste = t.personen.map(p =>
|
||||
((p.titel ? p.titel + ' ' : '') + p.vorname + ' ' + p.nachname +
|
||||
' <span class="hinweis">(' + p.gruppe + ')</span>')).join(', ');
|
||||
html += '<tr><td>Tisch ' + t.nummer + '</td><td>' + t.besetzt + '/' + t.plaetze +
|
||||
'</td><td>' + gaeste + '</td></tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
|
||||
html += '<h2 style="margin-top:1rem">Gruppen</h2>' +
|
||||
'<table><thead><tr><th>Gruppe</th><th>Personen</th><th>Teilungen</th><th>Strafpunkte</th></tr></thead><tbody>' +
|
||||
r.gruppen.map(g => '<tr><td>' + g.name + '</td><td>' + g.anzahl + '</td><td>' +
|
||||
g.teilungen + '</td><td>' + g.wert + '</td></tr>').join('') +
|
||||
'</tbody></table>';
|
||||
document.getElementById('ergebnisTabellen').innerHTML = html;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ Start */
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
document.getElementById('csvText').value = beispielCSV;
|
||||
presetReihe();
|
||||
try {
|
||||
const r = await api('/sitzung', { method: 'POST' });
|
||||
sitzung = r.id;
|
||||
document.getElementById('sitzungsId').textContent = sitzung;
|
||||
} catch (e) {
|
||||
document.getElementById('sitzungsId').textContent = 'Fehler: ' + e.message;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user