# -*- 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 IIIe 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()