- 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.
443 lines
21 KiB
Python
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()
|