DOCS/outputs/mnaha/seed/step3_map_parcours.py
Thomas Fransolet d14f151517 Livrables de presentation, seed de demo MNAHA et wireframes
- outputs/platform, outputs/prospect : scripts de construction des decks
  commerciaux + captures sources et .pptx generes.
- outputs/mnaha : seed complet de l'instance de demo luxembourgeoise
  (scripts par etape, contenus, images generees), le deck en version
  marque blanche et en version brandee, le PDF de presentation.
- outputs/Wireframes : maquettes app mobile, app web et manager.
- interview clients : notes de l'entretien Fourneau St Michel.

state.json et les __pycache__ sont ignores : le premier porte les cles
d'API de l'instance seedee, les scripts le regenerent.
2026-09-11 15:47:57 +02:00

443 lines
21 KiB
Python

# -*- coding: utf-8 -*-
"""Étape 3 — carte extérieure, parcours guidé, escape game et agenda."""
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from api import Api, LANGS, tr
from content import AGENDA_EVENTS, MAP_CATEGORIES, MAP_POINTS
STATE = Path(__file__).resolve().parent / "state.json"
MAP, AGENDA, PARCOURS = 0, 9, 12
def create_section(api, state, config_key, section_type, label):
config_id = state["configs"][config_key]
existing = api.get(f"/api/Section/configuration/{config_id}") or []
found = next((s for s in existing if s["label"] == label), None)
if found:
return found, False
created = api.post("/api/Section", {
"configurationId": config_id,
"instanceId": state["instanceId"],
"label": label,
"type": section_type,
"isSubSection": False,
})
return created, True
def utc(dt):
"""Suffixe Z obligatoire : un décalage « +00:00 » est désérialisé en Kind=Local
et Npgsql refuse alors d'écrire dans une colonne timestamptz."""
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
def point(lon, lat):
return {"type": "Point", "coordinates": [lon, lat]}
def seed_map(api, state):
section, created = create_section(api, state, "echternach", MAP,
"Parcours extérieur")
# PUT /api/SectionMap met à jour un point, pas la section : la section
# se met à jour comme les autres, par PUT /api/Section typé.
api.put("/api/Section", {
**section,
"type": MAP,
"order": 0,
"title": tr(FR="Le site, point par point", DE="Die Anlage, Punkt für Punkt",
EN="The site, point by point", NL="De site, punt voor punt"),
"description": tr(
FR="Cinq arrêts sur l'emprise de la villa. Fonctionne sans réseau une fois "
"le site téléchargé.",
DE="Fünf Stationen auf dem Gelände der Villa. Funktioniert ohne Netz, "
"sobald der Standort heruntergeladen ist.",
EN="Five stops across the villa grounds. Works offline once the site is "
"downloaded.",
NL="Vijf haltes op het terrein van de villa. Werkt zonder netwerk zodra de "
"site is gedownload."),
"zoom": 18,
"mapProvider": 0,
"isListViewEnabled": True,
"centerLatitude": "49.80640",
"centerLongitude": "6.40205",
"categories": [
{"id": c["id"], "order": c["id"], "icon": None,
"label": tr(**c["label"])}
for c in MAP_CATEGORIES
],
"points": [],
})
existing = api.get(f"/api/SectionMap/{section['id']}/points") or []
if existing:
print(f" = carte Echternach ({len(existing)} points déjà là)")
else:
for pt in MAP_POINTS:
api.post(f"/api/SectionMap/{section['id']}/points", {
"sectionMapId": section["id"],
"title": tr(**pt["title"]),
"description": tr(**pt["description"]),
"categorieId": pt["cat"],
"geometry": point(pt["lon"], pt["lat"]),
"contents": [],
"schedules": [], "prices": [], "phone": [], "email": [], "site": [],
})
print(f" + carte Echternach ({len(MAP_POINTS)} points)")
state["mapEchternach"] = section["id"]
return section
GUIDED_STEPS_ECHTERNACH = [
{
"title": {"FR": "Franchir le seuil", "DE": "Über die Schwelle",
"EN": "Crossing the threshold", "NL": "Over de drempel"},
"description": {
"FR": "Vous entrez par où entraient les visiteurs du maître de maison. "
"Devant vous, plus de cent mètres de façade.",
"DE": "Sie betreten das Gelände dort, wo die Gäste des Hausherrn eintraten. "
"Vor Ihnen: über hundert Meter Fassade.",
"EN": "You enter where the master's guests once did. Ahead of you, more "
"than a hundred metres of frontage.",
"NL": "U komt binnen waar de gasten van de heer des huizes binnenkwamen. "
"Voor u: meer dan honderd meter gevel."},
"lat": 49.80680, "lon": 6.40255,
},
{
"title": {"FR": "La cour et ses portiques", "DE": "Der Hof und seine Portiken",
"EN": "The court and its porticoes", "NL": "De hof en zijn zuilengangen"},
"description": {
"FR": "La colonnade abritait la circulation entre les ailes. Le sol que "
"vous foulez suit encore son tracé.",
"DE": "Die Kolonnade schützte den Weg zwischen den Flügeln. Der Boden unter "
"Ihnen folgt noch ihrem Verlauf.",
"EN": "The colonnade sheltered movement between the wings. The ground you "
"walk on still follows its line.",
"NL": "De zuilengang beschutte de doorgang tussen de vleugels. De grond "
"onder u volgt nog haar lijn."},
"lat": 49.80655, "lon": 6.40210,
},
{
"title": {"FR": "Sous le plancher chauffé", "DE": "Unter dem beheizten Boden",
"EN": "Beneath the heated floor", "NL": "Onder de verwarmde vloer"},
"description": {
"FR": "Les pilettes que vous voyez portaient un sol sous lequel circulait "
"l'air chaud. Un esclave alimentait le foyer, jour et nuit.",
"DE": "Die sichtbaren Pfeiler trugen einen Boden, unter dem warme Luft "
"zirkulierte. Ein Sklave hielt das Feuer Tag und Nacht in Gang.",
"EN": "The pillars you see carried a floor beneath which hot air "
"circulated. A slave fed the furnace, day and night.",
"NL": "De pijlers die u ziet droegen een vloer waaronder warme lucht "
"circuleerde. Een slaaf voedde het vuur, dag en nacht."},
"lat": 49.80630, "lon": 6.40165,
},
{
"title": {"FR": "Les jardins en terrasse", "DE": "Die Terrassengärten",
"EN": "The terraced gardens", "NL": "De terrastuinen"},
"description": {
"FR": "Bassins, allées, points d'eau : le luxe d'un domaine tourné vers la "
"vallée.",
"DE": "Becken, Wege, Wasserstellen: der Luxus eines zum Tal hin "
"ausgerichteten Anwesens.",
"EN": "Pools, walkways, water features: the luxury of an estate turned "
"towards the valley.",
"NL": "Bekkens, paden, waterpartijen: de luxe van een domein gericht op de "
"vallei."},
"lat": 49.80610, "lon": 6.40230,
},
{
"title": {"FR": "Ce que voyait le maître", "DE": "Was der Hausherr sah",
"EN": "What the master saw", "NL": "Wat de heer des huizes zag"},
"description": {
"FR": "D'ici, le domaine surveillait la vallée de la Sûre et la route qui "
"la suivait. Une villa n'est jamais posée au hasard.",
"DE": "Von hier überblickte das Anwesen das Sauertal und die Straße, die "
"ihm folgte. Eine Villa steht nie zufällig.",
"EN": "From here the estate watched the Sûre valley and the road that "
"followed it. A villa is never sited by chance.",
"NL": "Vanhier keek het domein uit over het Sûredal en de weg die het "
"volgde. Een villa ligt nooit toevallig."},
"lat": 49.80590, "lon": 6.40170,
},
]
ESCAPE_STEPS = [
{
"title": {"FR": "Le message du mosaïste", "DE": "Die Botschaft des Mosaizisten",
"EN": "The mosaicist's message", "NL": "De boodschap van de mozaïeklegger"},
"description": {
"FR": "L'atelier a signé son œuvre. Comptez les figures qui entourent le "
"poète : votre premier chiffre est là.",
"DE": "Die Werkstatt hat ihr Werk signiert. Zählen Sie die Figuren um den "
"Dichter: dort steht Ihre erste Ziffer.",
"EN": "The workshop signed its work. Count the figures surrounding the "
"poet: your first digit is there.",
"NL": "Het atelier ondertekende zijn werk. Tel de figuren rond de dichter: "
"daar staat uw eerste cijfer."},
"question": {
"label": {"FR": "Combien de Muses entourent Homère ?",
"DE": "Wie viele Musen umgeben Homer?",
"EN": "How many Muses surround Homer?",
"NL": "Hoeveel Muzen omringen Homerus?"},
"answers": [({"FR": "Neuf", "DE": "Neun", "EN": "Nine", "NL": "Negen"}, True),
({"FR": "Sept", "DE": "Sieben", "EN": "Seven", "NL": "Zeven"}, False),
({"FR": "Douze", "DE": "Zwölf", "EN": "Twelve", "NL": "Twaalf"}, False)],
},
},
{
"title": {"FR": "Le banquet interrompu", "DE": "Das unterbrochene Gastmahl",
"EN": "The interrupted banquet", "NL": "Het onderbroken banket"},
"description": {
"FR": "Dans les tombes de Goeblange-Nospelt, on a déposé de quoi banqueter "
"dans l'au-delà. Un objet trahit l'origine du vin.",
"DE": "In den Gräbern von Goeblange-Nospelt lag alles für ein Gastmahl im "
"Jenseits. Ein Gegenstand verrät die Herkunft des Weins.",
"EN": "In the Goeblange-Nospelt tombs lay everything needed to feast in the "
"afterlife. One object betrays the wine's origin.",
"NL": "In de graven van Goeblange-Nospelt lag alles voor een feestmaal in "
"het hiernamaals. Eén voorwerp verraadt de herkomst van de wijn."},
"question": {
"label": {"FR": "Dans quel récipient transportait-on le vin ?",
"DE": "In welchem Gefäß wurde der Wein transportiert?",
"EN": "In which vessel was wine transported?",
"NL": "In welk vat werd wijn vervoerd?"},
"answers": [({"FR": "Une amphore", "DE": "Eine Amphore",
"EN": "An amphora", "NL": "Een amfoor"}, True),
({"FR": "Une urne", "DE": "Eine Urne",
"EN": "An urn", "NL": "Een urn"}, False),
({"FR": "Un cratère", "DE": "Ein Krater",
"EN": "A krater", "NL": "Een krater"}, False)],
},
},
{
"title": {"FR": "La marque de la manufacture",
"DE": "Die Marke der Manufaktur",
"EN": "The maker's mark", "NL": "Het merk van de fabriek"},
"description": {
"FR": "Montez d'un étage. Les céramiques de l'entre-deux-guerres portent "
"une signature, et un lieu de production.",
"DE": "Ein Stockwerk höher. Die Keramiken der Zwischenkriegszeit tragen "
"eine Signatur und einen Produktionsort.",
"EN": "Go up one floor. The interwar ceramics carry a signature — and a "
"place of manufacture.",
"NL": "Eén verdieping hoger. Het keramiek uit het interbellum draagt een "
"signatuur en een productieplaats."},
"question": {
"label": {"FR": "Où se trouvait la manufacture de Villeroy & Boch au Luxembourg ?",
"DE": "Wo befand sich die Manufaktur Villeroy & Boch in Luxemburg?",
"EN": "Where was the Villeroy & Boch works in Luxembourg?",
"NL": "Waar bevond de fabriek van Villeroy & Boch zich in Luxemburg?"},
"answers": [({"FR": "Septfontaines", "DE": "Septfontaines",
"EN": "Septfontaines", "NL": "Septfontaines"}, True),
({"FR": "Dudelange", "DE": "Düdelingen",
"EN": "Dudelange", "NL": "Dudelange"}, False),
({"FR": "Echternach", "DE": "Echternach",
"EN": "Echternach", "NL": "Echternach"}, False)],
},
},
{
"title": {"FR": "La dernière frappe", "DE": "Die letzte Prägung",
"EN": "The final strike", "NL": "De laatste slag"},
"description": {
"FR": "Au cabinet des médailles, une pièce ferme le coffre. Trouvez-la et "
"le secret de la villa est à vous.",
"DE": "Im Münzkabinett schließt eine Münze die Truhe. Finden Sie sie, und "
"das Geheimnis der Villa gehört Ihnen.",
"EN": "In the coin cabinet, one piece closes the chest. Find it and the "
"villa's secret is yours.",
"NL": "In het penningkabinet sluit één munt de kist. Vind ze en het geheim "
"van de villa is van u."},
"question": {
"label": {"FR": "Quel peuple gaulois occupait la région ?",
"DE": "Welches gallische Volk bewohnte die Region?",
"EN": "Which Gaulish people occupied the region?",
"NL": "Welk Gallisch volk bewoonde de streek?"},
"answers": [({"FR": "Les Trévires", "DE": "Die Treverer",
"EN": "The Treveri", "NL": "De Treveri"}, True),
({"FR": "Les Éburons", "DE": "Die Eburonen",
"EN": "The Eburones", "NL": "De Eburonen"}, False),
({"FR": "Les Rèmes", "DE": "Die Remer",
"EN": "The Remi", "NL": "De Remen"}, False)],
},
},
]
def question_payload(question, order=0):
return {
"order": order,
"label": [{"language": lang,
"value": question["label"].get(lang, question["label"]["FR"])}
for lang in LANGS],
"responses": [
{"order": i, "isGood": good,
"label": [{"language": lang, "value": text.get(lang, text["FR"])}
for lang in LANGS]}
for i, (text, good) in enumerate(question["answers"])
],
}
def seed_parcours(api, state, config_key, label, path_spec, steps, base_map_id=None):
section, created = create_section(api, state, config_key, PARCOURS, label)
api.put("/api/Section", {
**section,
"type": PARCOURS,
"order": 1,
"title": tr(**path_spec["title"]),
"description": tr(**path_spec["description"]),
"showMap": path_spec["show_map"],
"baseSectionMapId": base_map_id,
"guidedPaths": [],
})
existing = api.get(f"/api/SectionParcours/{section['id']}/guided-path") or []
if existing:
print(f" = parcours {config_key} / {label} (déjà peuplé)")
return section
path = api.post(f"/api/SectionParcours/{section['id']}/guided-path", {
"sectionParcoursId": section["id"],
"instanceId": state["instanceId"],
"title": tr(**path_spec["title"]),
"description": tr(**path_spec["description"]),
"isLinear": True,
"requireSuccessToAdvance": path_spec["game"],
"hideNextStepsUntilComplete": path_spec["game"],
"estimatedDurationMinutes": path_spec["minutes"],
"order": 0,
"isGameMode": path_spec["game"],
"gameMessageDebut": [
{"language": lang,
"value": path_spec["start"].get(lang, path_spec["start"]["FR"])}
for lang in LANGS] if path_spec["game"] else [],
"gameMessageFin": [
{"language": lang,
"value": path_spec["end"].get(lang, path_spec["end"]["FR"])}
for lang in LANGS] if path_spec["game"] else [],
})
for order, step in enumerate(steps):
body = {
"guidedPathId": path["id"],
"order": order,
"title": tr(**step["title"]),
"description": tr(**step["description"]),
"isGeoTriggered": "lat" in step,
"contents": [],
"audioIds": [],
"isStepTimer": False,
}
if "lat" in step:
body["geometry"] = point(step["lon"], step["lat"])
body["zoneRadiusMeters"] = 20
if "question" in step:
body["quizQuestions"] = [question_payload(step["question"])]
api.post(f"/api/SectionParcours/guided-path/{path['id']}/guided-step", body)
print(f" + parcours {config_key} / {label} ({len(steps)} étapes)")
return section
def seed_agenda(api, state):
section, created = create_section(api, state, "feschmaart", AGENDA,
"Expositions et rendez-vous")
api.put("/api/Section", {
**section,
"type": AGENDA,
"order": 12,
"title": tr(FR="Expositions et rendez-vous", DE="Ausstellungen und Termine",
EN="Exhibitions and events", NL="Tentoonstellingen en afspraken"),
"description": tr(FR="La programmation des trois sites.",
DE="Das Programm der drei Standorte.",
EN="Programming across the three sites.",
NL="De programmatie van de drie sites."),
"isOnlineAgenda": False,
"resourceIds": [],
"events": [],
})
existing = api.get(f"/api/SectionAgenda/{section['id']}/events") or []
if existing:
print(f" = agenda ({len(existing)} événements déjà là)")
return section
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
for ev in AGENDA_EVENTS:
start = today + timedelta(days=ev["day_offset"], hours=ev["hour"])
api.post(f"/api/SectionAgenda/{section['id']}/event", {
"sectionAgendaId": section["id"],
"label": tr(**ev["title"]),
"description": tr(**ev["description"]),
"dateFrom": utc(start),
"dateTo": utc(start + timedelta(hours=2)),
"dateAdded": utc(today),
"isSynced": False,
})
print(f" + agenda ({len(AGENDA_EVENTS)} événements)")
return section
def main():
api = Api()
state = json.loads(STATE.read_text(encoding="utf-8"))
map_section = seed_map(api, state)
seed_parcours(api, state, "echternach", "Sur les pas du maître", {
"title": {"FR": "Sur les pas du maître de la villa",
"DE": "Auf den Spuren des Villenherrn",
"EN": "In the footsteps of the villa's master",
"NL": "In de voetsporen van de heer van de villa"},
"description": {
"FR": "Cinq étapes en extérieur, déclenchées à l'approche. Environ 40 minutes.",
"DE": "Fünf Stationen im Freien, ausgelöst bei Annäherung. Etwa 40 Minuten.",
"EN": "Five outdoor stops, triggered as you approach. About 40 minutes.",
"NL": "Vijf haltes buiten, geactiveerd bij nadering. Ongeveer 40 minuten."},
"show_map": True, "game": False, "minutes": 40,
"start": {}, "end": {},
}, GUIDED_STEPS_ECHTERNACH, base_map_id=map_section["id"])
seed_parcours(api, state, "feschmaart", "Le secret de la villa", {
"title": {"FR": "Le secret de la villa de Vichten",
"DE": "Das Geheimnis der Villa von Vichten",
"EN": "The secret of the Vichten villa",
"NL": "Het geheim van de villa van Vichten"},
"description": {
"FR": "Quatre énigmes à travers les collections. Comptez une heure.",
"DE": "Vier Rätsel quer durch die Sammlungen. Rechnen Sie mit einer Stunde.",
"EN": "Four riddles across the collections. Allow an hour.",
"NL": "Vier raadsels doorheen de collecties. Reken op een uur."},
"show_map": False, "game": True, "minutes": 60,
"start": {
"FR": "Un mosaïste du III<sup>e</sup> siècle a caché un message dans son "
"pavement. Quatre salles, quatre indices. À vous de jouer.",
"DE": "Ein Mosaizist des 3. Jahrhunderts verbarg eine Botschaft in seinem "
"Boden. Vier Säle, vier Hinweise. Sie sind am Zug.",
"EN": "A third-century mosaicist hid a message in his floor. Four rooms, "
"four clues. Your turn.",
"NL": "Een mozaïeklegger uit de derde eeuw verborg een boodschap in zijn "
"vloer. Vier zalen, vier aanwijzingen. Aan u."},
"end": {
"FR": "Le message est reconstitué. Vous avez traversé mille huit cents ans "
"en une heure.",
"DE": "Die Botschaft ist entschlüsselt. Sie haben tausendachthundert Jahre "
"in einer Stunde durchquert.",
"EN": "The message is restored. You have crossed eighteen centuries in an "
"hour.",
"NL": "De boodschap is hersteld. U hebt achttien eeuwen in een uur "
"doorkruist."},
}, ESCAPE_STEPS)
seed_agenda(api, state)
STATE.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
print("\nétape 3 terminée")
if __name__ == "__main__":
main()