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>
This commit is contained in:
Michael Stangl
2026-07-11 12:49:42 +02:00
parent 00dffc59eb
commit 9de80d2f2a
41 changed files with 5054 additions and 4033 deletions
+142 -97
View File
@@ -11,11 +11,12 @@ __license__ = "Python"
from itertools import chain
from random import *
import ast
import configparser, os
import copy
import math
from collections import defaultdict
from functools import total_ordering
from xml.dom import minidom
# Strukturklassen: Person, Gruppe, Tisch
@@ -256,12 +257,15 @@ class Tische:
config.read( os.path.expanduser(FileName) )
for section in config.sections():
Tid = int(section)
Nummer = eval(config.get( section, 'Nummer' ))
nPl = eval(config.get( section, 'Plaetze' ))
Koords = eval(config.get( section, 'Koordinaten' ))
Nachbarn = eval(config.get( section, 'Nachbarliste' ))
Hofid = eval(config.get( section, 'Hof' ))
T = Tisch( Tid, Plaetze=nPl, Koordinaten=Koords, Nachbarliste=Nachbarn, Hof=Hofid, Nummer=Nummer )
# ast.literal_eval statt eval: tische.ini kann eine hochgeladene
# Fremddatei sein (Web-M1), da darf kein beliebiger Code laufen.
# Erlaubt weiterhin Zahlen, Tupel und Listen wie zuvor.
Nummer = ast.literal_eval(config.get( section, 'Nummer' ))
nPl = ast.literal_eval(config.get( section, 'Plaetze' ))
Koords = ast.literal_eval(config.get( section, 'Koordinaten' ))
Nachbarn = ast.literal_eval(config.get( section, 'Nachbarliste' ))
Hofid = ast.literal_eval(config.get( section, 'Hof' ))
T = Tisch( Tid, Plaetze=nPl, Koordinaten=Koords, Nachbarliste=Nachbarn, Hof=Hofid, Nummer=Nummer )
self.Tisch_dazu( T )
def vonId(self, SearchedId):
for T in chain(self.TischListe):
@@ -281,19 +285,21 @@ class Tische:
class Plaetze:
"""Informationen ueber freie oder benachbarte Plaetze"""
def __init__(self, Tische):
# mache Listen mit den Tischen mit n Freien Plaetzen
self.__TIDsNFreie = []
# je Anzahl freier Plaetze ein Set von Tisch-Ids. defaultdict(set)
# erspart die manuelle Vorbelegung und liefert fuer noch nie
# gesehene Fuellstaende automatisch ein leeres Set.
self.__TIDsNFreie = defaultdict(set)
self.__Groesster = Tische.Groesster
self.__Tische = Tische
for i in range( self.__Groesster + 1 ):
self.__TIDsNFreie.append( set() )
for T in Tische.TischListe:
n_freie = T.freiePlaetze()
self.__TIDsNFreie[n_freie].add(T.Id)
def __repr__(self):
result = "\n-- Tische mit n Freien Plaetzen:\n"
for i in range(self.__Groesster+1):
result = result + " " +repr(i) + " :" + repr(self.__TIDsNFreie[i]) + "\n"
# .get statt [i], damit das Anzeigen keine leeren Eintraege
# ins defaultdict schreibt
result = result + " " +repr(i) + " :" + repr(self.__TIDsNFreie.get(i, set())) + "\n"
return result
def IdsnFreie(self, n_freie):
"""gibt ein Set mit allen Tischids zurueck, wo noch X freie Plaetze sind"""
@@ -406,11 +412,13 @@ class Strafliste:
print("-- Strafliste laden")
config = configparser.ConfigParser()
config.read( os.path.expanduser(FileName) )
self.NichtNachbar = eval(config.get( 'Globals', 'NichtNachbar' ))
self.Allein = eval(config.get( 'Globals', 'Allein' ))
self.TischWert = eval( config.get( 'Globals', 'TischWertigkeiten' ) )
# ast.literal_eval statt eval: die Werte sind reine Literale
# (Zahlen und Dicts wie {2:-10}), also kein Code noetig.
self.NichtNachbar = ast.literal_eval(config.get( 'Globals', 'NichtNachbar' ))
self.Allein = ast.literal_eval(config.get( 'Globals', 'Allein' ))
self.TischWert = ast.literal_eval( config.get( 'Globals', 'TischWertigkeiten' ) )
for (k,v) in config.items( 'Trennung' ):
self.GruppenTrennen[int(k)] = eval(v)
self.GruppenTrennen[int(k)] = ast.literal_eval(v)
# Sitzplatzverteilung, bestehend aus den Einzelelementen
@@ -618,10 +626,6 @@ class Sitzplatzverteilung:
Teile.append(
'<circle cx="%g" cy="%g" r="%g" fill="white" '
'stroke="black" stroke-width="1.5"/>\n' % (Mx, My, TR) )
Teile.append(
'<text x="%g" y="%g" text-anchor="middle" '
'font-size="10" fill="black">Tisch %s</text>\n'
% (Mx, My - TR - 2, T.Id) )
Plaetze = list(T)
n = len(Plaetze)
@@ -636,6 +640,19 @@ class Sitzplatzverteilung:
'stroke="black" stroke-width="1"/>\n'
% (Px, Py, PersonenRadius, Farbe) )
# Tischnummer zentriert in die Tischmitte (nach den Personen-
# kreisen gezeichnet, damit sie oben liegt; ein halbtransparenter
# weisser Kreis darunter haelt sie auch ueber Personenkreisen
# lesbar, falls die Tischmitte belegt sein sollte)
Teile.append(
'<circle cx="%g" cy="%g" r="%g" fill="white" '
'fill-opacity="0.75"/>\n' % (Mx, My, PersonenRadius) )
Teile.append(
'<text x="%g" y="%g" text-anchor="middle" '
'dominant-baseline="central" font-size="%g" '
'font-weight="bold" fill="black">%s</text>\n'
% (Mx, My, PersonenRadius * 1.1, T.Nummer) )
Teile.append( '</svg>\n' )
fout = open( OutputSVG, 'w' )
@@ -897,11 +914,19 @@ class Sitzplatzverteilung:
Tisch = Tische.vonId(TischId)
self.PersonSetzen( Person, Tisch )
def TopX(self, Liste, Anzahl=-1 ):
"""gibt eine Liste mit X Ids zurueck, die die schlechtesten Wertungen haben"""
"""gibt eine Liste mit X Ids zurueck, die die schlechtesten Wertungen haben
Achtung: Dies ist bewusst *nicht* heapq.nsmallest. Der laufende
Schwellwert Min sinkt monoton mit, so dass nur Ids aufgenommen
werden, die waehrend der Iteration einen neuen Tiefpunkt erreichen
oder gleichziehen. Das Ergebnis haengt an der Iterationsreihenfolge
und praegt das Mutationsverhalten des GA (mutieren() ->
SchlechteGruppenTopX). Eine Umstellung auf heapq wuerde die
Loesungsfindung veraendern und ist daher bewusst unterlassen.
"""
Min = 0
RListe=[]
Wertigkeit=[]
# TODO filter verwenden
for Id, Wert in Liste.items():
if Wert <= Min:
RListe.append(Id)
@@ -911,92 +936,112 @@ class Sitzplatzverteilung:
# Sitzplatzverteilung, bestehend aus den Einzelelementen
class XMLConfig:
"""aus Xml file alles einlesen"""
def getText(self, nodelist):
rc = ""
for node in nodelist:
if node.nodeType == node.TEXT_NODE:
rc = rc + node.data
return rc
def handlePersonNode( self, person ):
"""Personendaten erfassen"""
info = {}
try:
Titel = person.getElementsByTagName("Titel")[0]
info["Titel"] = self.getText(Titel.childNodes)
except IndexError:
info["Titel"] = ""
Vorname = person.getElementsByTagName("Vorname")[0]
Nachname = person.getElementsByTagName("Nachname")[0]
info["Vorname"] = self.getText(Vorname.childNodes)
info["Nachname"] = self.getText(Nachname.childNodes)
return info
def HandleBestellung(self, Bestellung):
"""alle Bloecke in der XML Bestellung parsen"""
PidListe = []
VipHash = {}
class JSONConfig:
"""liest die Gaesteliste aus einer bestellung.json ein
(Nachfolger des frueheren XMLConfig; JSON hat die alte XML-Form
abgeloest, siehe doc/dateiformate.md).
Erwartete Struktur:
{
"gruppen": [
{
"name": "Familie Huber", # optional, nur Anzeige
"tisch": 1, # optional -> VIP-Fixplatz
"anzahl": 1, # optional -> Sammelbuchung
"personen": [
{"vorname": "Anna", "nachname": "Huber", "titel": ""}
]
}
]
}
"tisch" macht die ganze Gruppe zu VIPs an diesem Tisch (laeuft
nicht durch die GA-Platzierung). "anzahl" > 1 vervielfacht jede
Person der Gruppe in so viele identische Buchungen (anonyme
Sammelbuchung). "titel" ist optional.
"""
def HandleBestellung(self, Daten):
"""die geladene JSON-Struktur in Personen/Gruppen/VIPs zerlegen
Rueckgabe: [ PN, GN, VIPGruppe, VipHash, GruppenNamen ]
(wie CSVConfig; das fruehere XMLConfig gab die ersten vier
Elemente zurueck - Aufrufer, die GruppenNamen nicht brauchen,
koennen es ignorieren.)
"""
if not isinstance( Daten, dict ) or 'gruppen' not in Daten:
raise ValueError("bestellung.json braucht ein Objekt mit dem "
"Schluessel 'gruppen'")
PN = Personen()
GN = Gruppen()
VIPGruppe = Gruppe( 0 )
VipHash = {}
GruppenNamen = {}
print('-HandleBestellung')
AlleGruppenXML = Bestellung.getElementsByTagName("Gruppe")
PID = 0
GID = 0
for GruppeXML in AlleGruppenXML:
GID = GID + 1
print('-- GID ' + repr(GID))
G = Gruppe( GID )
for Index, GruppeJSON in enumerate( Daten['gruppen'] ):
if 'personen' not in GruppeJSON:
raise ValueError("Gruppe %d: 'personen' fehlt" % (Index + 1))
IsVip = False
AnzahlReservierungen = 1
for Node in GruppeXML.childNodes:
if Node.nodeName == 'Tisch':
TischNr = self.getText(Node.childNodes)
print('TischNr:', TischNr)
IsVip = True
if Node.nodeName == 'Anzahl':
AnzahlReservierungen = int(self.getText(Node.childNodes))
print('Anzahl der Reservierungen:', AnzahlReservierungen)
IsContingent = True
IsVip = 'tisch' in GruppeJSON and GruppeJSON['tisch'] is not None
if IsVip:
try:
TischNr = int( GruppeJSON['tisch'] )
except (TypeError, ValueError):
raise ValueError("Gruppe %d: 'tisch' ist keine "
"Tischnummer" % (Index + 1))
for Node in GruppeXML.childNodes:
if Node.nodeName == 'Person':
NRes = 0
for i in range(int(AnzahlReservierungen)):
NRes = NRes + 1
PID = PID + 1
print("PID =" + repr(PID))
info = self.handlePersonNode( Node )
try:
AnzahlReservierungen = int( GruppeJSON.get( 'anzahl', 1 ) )
except (TypeError, ValueError):
raise ValueError("Gruppe %d: 'anzahl' ist keine Zahl"
% (Index + 1))
if AnzahlReservierungen < 1:
raise ValueError("Gruppe %d: 'anzahl' muss >= 1 sein"
% (Index + 1))
# regulaere (nicht-VIP) Gruppen bekommen eine GID
if not IsVip:
GID = GID + 1
G = Gruppe( GID )
GruppenNamen[ GID ] = GruppeJSON.get( 'name', 'Gruppe %d' % GID )
for PersonJSON in GruppeJSON['personen']:
Vorname = str( PersonJSON.get( 'vorname', '' ) ).strip()
Nachname = str( PersonJSON.get( 'nachname', '' ) ).strip()
Titel = str( PersonJSON.get( 'titel', '' ) ).strip()
if Vorname == '' or Nachname == '':
raise ValueError("Gruppe %d: jede Person braucht Vorname "
"und Nachname" % (Index + 1))
for i in range( AnzahlReservierungen ):
PID = PID + 1
P = Person( PID, Vorname, Nachname, Titel )
if IsVip:
VipHash[ PID ] = TischNr
VIPGruppe.Person_dazu( P )
else:
PN.PersonAdd( P )
G.Person_dazu( P )
if not IsVip:
if G.Anzahl() > 0:
GN.Gruppe_dazu( G )
else:
# leere Gruppe wieder verwerfen
del GruppenNamen[ GID ]
GID = GID - 1
return [ PN, GN, VIPGruppe, VipHash, GruppenNamen ]
Vorname = info["Vorname"]
Nachname = info["Nachname"]
Titel = info["Titel"]
P = Person( PID, Vorname, Nachname, Titel)
if IsVip == True:
PidListe.append( int(PID) )
VipHash[ int(PID) ] = int(TischNr)
VIPGruppe.Person_dazu( P )
else:
PN.PersonAdd( P )
G.Person_dazu( P )
if (G.Anzahl() > 0) & (IsVip != True):
GN.Gruppe_dazu( G )
if IsVip == True:
GID = GID - 1
print(PN)
print(GN)
print(VIPGruppe)
print(VipHash)
print('==========')
return [ PN, GN, VIPGruppe, VipHash ]
def laden(self, Bestellung):
"""lese alle Eingangsdaten aus einem XML File ein"""
fsock = open( Bestellung, 'r')
xmlini = minidom.parse(fsock)
return self.HandleBestellung(xmlini)
"""lese alle Eingangsdaten aus einer JSON-Datei ein"""
import json
# utf-8-sig schluckt ein evtl. vorhandenes BOM
fsock = open( Bestellung, 'r', encoding='utf-8-sig' )
Daten = json.load( fsock )
fsock.close()
return self.HandleBestellung( Daten )