Ship the ten biggest French cities as an import bundle

2dac4a5 · Gabriel Morell · 2026-09-08 19:19

14 files +267 -3
Message
{commit_body(@commit)}

Files changed

modified README.md
+16 −0
@@ -109,3 +109,19 @@ the sibling app. That is expected, not a corrupt migration history.
109 109 - `room_sanctum` (http://localhost:4002) serves the UI and should return 200.
110 110 - `room_hermes` (http://localhost:4001) has no root route — its `get "/"` is commented out
111 111 in the router — so **404 on `/` is normal** and means the app is healthy.
112 +
113 +#### Bundled imports
114 +
115 +`apps/room_sanctum/priv/imports/` holds ready-made source bundles for the
116 +**Import Offerings** page (Offerings → Import). Paste a file's contents into the
117 +box, check the ten green cards, and hit *Import Valid Sources*.
118 +
119 +- [`france_top_ten.json`](apps/room_sanctum/priv/imports/france_top_ten.json) —
120 + GTFS and GTFS-RT for the ten largest French cities, compiled from
121 + [transport.data.gouv.fr](https://transport.data.gouv.fr/api/datasets), the
122 + Point d'Accès National.
123 +
124 +Sources arrive **disabled**, so importing does not kick off ten static imports at
125 +once; toggle the ones you want. Three of the ten are schedule-only — Paris, Lyon
126 +and Strasbourg publish realtime as SIRI or behind a personal API key rather than
127 +as open GTFS-RT — and each source's notes say why.
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/import_component.ex
+6 −3
@@ -39,7 +39,7 @@ defmodule RoomSanctumWeb.SourceLive.ImportComponent do
39 39 valid_sources =
40 40 socket.assigns.validation_results
41 41 |> Enum.filter(fn {_source, changeset} -> changeset.valid? end)
42 − |> Enum.map(fn {source, _changeset} -> source end)
42 + |> Enum.map(fn {{source, _index}, _changeset} -> source end)
43 43
44 44 imported_count = import_valid_sources(valid_sources)
45 45
@@ -73,10 +73,13 @@ defmodule RoomSanctumWeb.SourceLive.ImportComponent do
73 73 source = %Source{}
74 74 changeset = Configuration.change_source(source, source_attrs)
75 75
76 − {source_attrs, changeset}
76 + # Paired with its index because the template shows "#3 of 10"
77 + # next to each result, and a source that fails to parse has no
78 + # name to identify it by.
79 + {{source_attrs, index}, changeset}
77 80 end)
78 81
79 − parsed_sources = Enum.map(results, fn {source_attrs, _changeset} -> source_attrs end)
82 + parsed_sources = Enum.map(results, fn {{source_attrs, _index}, _changeset} -> source_attrs end)
80 83 validation_results = results
81 84
82 85 {:ok, {parsed_sources, validation_results}}
added apps/room_sanctum/priv/imports/france_top_ten.json
+154 −0
@@ -0,0 +1,154 @@
1 +{
2 + "note": "Paste into Offerings -> Import Offerings. Sources arrive disabled; toggle the ones you want. Compiled from https://transport.data.gouv.fr/api/datasets (the Point d'Acces National), 2026-09-08.",
3 + "sources": [
4 + {
5 + "name": "Paris - Ile-de-France Mobilites",
6 + "type": "gtfs",
7 + "enabled": false,
8 + "public": true,
9 + "notes": "Every IDFM network: Metro, RER, Transilien, tram, bus. The PAN lists no GTFS-RT for IDFM -- realtime is behind PRIM (https://prim.iledefrance-mobilites.fr), which wants a per-account key. To add it, put the PRIM GTFS-RT URL in the trip updates field and an `apikey: <your key>` header on this source. The static zip is the largest in this bundle; expect a long first import.",
10 + "meta": { "tint": "indigo", "run_period": 604800 },
11 + "config": {
12 + "__type__": "gtfs",
13 + "url": "https://eu.ftp.opendatasoft.com/stif/GTFS/IDFM-gtfs.zip",
14 + "tz": "Europe/Paris"
15 + }
16 + },
17 + {
18 + "name": "Marseille - Metropole Aix-Marseille-Provence",
19 + "type": "gtfs",
20 + "enabled": false,
21 + "public": true,
22 + "notes": "The `referentiel complet` feed: RTM plus every other network in the metropole (Aixenbus, Ciotabus, Cartreize, and the rest). Only service alerts are published as GTFS-RT; trip updates and vehicle positions are SIRI only.",
23 + "meta": { "tint": "indigo", "run_period": 604800 },
24 + "config": {
25 + "__type__": "gtfs",
26 + "url": "https://app.mecatran.com/utw/ws/gtfsfeed/static/mamp?apiKey=60327e505a214c77303f52206f11483069257343",
27 + "url_rt_sa": "https://api-mobilite.rbgl.fr/api/v1/mamp/getServiceAlerts",
28 + "rt_period_sa": 600,
29 + "tz": "Europe/Paris"
30 + }
31 + },
32 + {
33 + "name": "Lyon - TCL",
34 + "type": "gtfs",
35 + "enabled": false,
36 + "public": true,
37 + "notes": "SYTRAL Mobilites. Grand Lyon's own download (download.data.grandlyon.com) now answers 401 without an account, so this uses the mirrored `GTFS modifie` resource the PAN lists, which does not. No GTFS-RT: TCL publishes realtime as SIRI Lite only.",
38 + "meta": { "tint": "indigo", "run_period": 604800 },
39 + "config": {
40 + "__type__": "gtfs",
41 + "url": "https://gtech-transit-prod.apigee.net/v1/google/gtfs/odbl/lyon_tcl.zip?apikey=BasyG6OFZXgXnzWdQLTwJFGcGmeOs204&secret=gNo6F5PhQpsGRBCK",
42 + "tz": "Europe/Paris"
43 + }
44 + },
45 + {
46 + "name": "Toulouse - Tisseo",
47 + "type": "gtfs",
48 + "enabled": false,
49 + "public": true,
50 + "notes": "One realtime feed carrying both trip updates and service alerts, so it goes in the shared field rather than being listed twice.",
51 + "meta": { "tint": "indigo", "run_period": 604800 },
52 + "config": {
53 + "__type__": "gtfs",
54 + "url": "https://data.toulouse-metropole.fr/explore/dataset/tisseo-gtfs/files/fc1dda89077cf37e4f7521760e0ef4e9/download/",
55 + "url_rt_shared": "https://api.tisseo.fr/opendata/gtfsrt/GtfsRt.pb",
56 + "tz": "Europe/Paris"
57 + }
58 + },
59 + {
60 + "name": "Nice - Lignes d'Azur",
61 + "type": "gtfs",
62 + "enabled": false,
63 + "public": true,
64 + "notes": "Trip updates and vehicle positions from enRoute's ARA gateway. No service alerts feed.",
65 + "meta": { "tint": "indigo", "run_period": 604800 },
66 + "config": {
67 + "__type__": "gtfs",
68 + "url": "https://chouette.enroute.mobi/api/v1/datas/OpendataRLA/gtfs.zip",
69 + "url_rt_tu": "https://ara-api.enroute.mobi/rla/gtfs/trip-updates",
70 + "url_rt_vp": "https://ara-api.enroute.mobi/rla/gtfs/vehicle-positions",
71 + "tz": "Europe/Paris"
72 + }
73 + },
74 + {
75 + "name": "Nantes - Naolib",
76 + "type": "gtfs",
77 + "enabled": false,
78 + "public": true,
79 + "notes": "Realtime comes through the PAN proxy, which allows one request per 30 seconds per resource and answers 429 above that -- hence 60s trip updates and 600s alerts rather than the defaults.",
80 + "meta": { "tint": "indigo", "run_period": 604800 },
81 + "config": {
82 + "__type__": "gtfs",
83 + "url": "https://data.nantesmetropole.fr/explore/dataset/244400404_transports_commun_naolib_nantes_metropole_gtfs/files/0cc0469a72de54ee045cb66d1a21de9e/download/",
84 + "url_rt_tu": "https://proxy.transport.data.gouv.fr/resource/naolib-nantes-gtfs-rt-trip-update",
85 + "url_rt_sa": "https://proxy.transport.data.gouv.fr/resource/naolib-nantes-gtfs-rt-alerts",
86 + "rt_period_tu": 60,
87 + "rt_period_sa": 600,
88 + "tz": "Europe/Paris"
89 + }
90 + },
91 + {
92 + "name": "Montpellier - TaM",
93 + "type": "gtfs",
94 + "enabled": false,
95 + "public": true,
96 + "notes": "The urbain network, with all three realtime kinds. TaM publishes a second, suburbain network at the same paths with /Suburbain/ in place of /Urbain/; add it as its own source if you want it.",
97 + "meta": { "tint": "indigo", "run_period": 604800 },
98 + "config": {
99 + "__type__": "gtfs",
100 + "url": "https://data.montpellier3m.fr/GTFS/Urbain/GTFS.zip",
101 + "url_rt_tu": "https://data.montpellier3m.fr/GTFS/Urbain/TripUpdate.pb",
102 + "url_rt_vp": "https://data.montpellier3m.fr/GTFS/Urbain/VehiclePosition.pb",
103 + "url_rt_sa": "https://data.montpellier3m.fr/GTFS/Urbain/Alert.pb",
104 + "rt_period_sa": 600,
105 + "tz": "Europe/Paris"
106 + }
107 + },
108 + {
109 + "name": "Strasbourg - CTS",
110 + "type": "gtfs",
111 + "enabled": false,
112 + "public": true,
113 + "notes": "No GTFS-RT: the Eurometropole publishes realtime as SIRI Lite only.",
114 + "meta": { "tint": "indigo", "run_period": 604800 },
115 + "config": {
116 + "__type__": "gtfs",
117 + "url": "https://opendata.cts-strasbourg.eu/google_transit.zip",
118 + "tz": "Europe/Paris"
119 + }
120 + },
121 + {
122 + "name": "Bordeaux - TBM",
123 + "type": "gtfs",
124 + "enabled": false,
125 + "public": true,
126 + "notes": "All three realtime kinds. The apiKey in these URLs is Bordeaux Metropole's published open-data key, not a personal one.",
127 + "meta": { "tint": "indigo", "run_period": 604800 },
128 + "config": {
129 + "__type__": "gtfs",
130 + "url": "https://bdx.mecatran.com/utw/ws/gtfsfeed/static/bordeaux?apiKey=opendata-bordeaux-metropole-flux-gtfs-rt",
131 + "url_rt_tu": "https://bdx.mecatran.com/utw/ws/gtfsfeed/realtime/bordeaux?apiKey=opendata-bordeaux-metropole-flux-gtfs-rt",
132 + "url_rt_vp": "https://bdx.mecatran.com/utw/ws/gtfsfeed/vehicles/bordeaux?apiKey=opendata-bordeaux-metropole-flux-gtfs-rt",
133 + "url_rt_sa": "https://bdx.mecatran.com/utw/ws/gtfsfeed/alerts/bordeaux?apiKey=opendata-bordeaux-metropole-flux-gtfs-rt",
134 + "rt_period_sa": 600,
135 + "tz": "Europe/Paris"
136 + }
137 + },
138 + {
139 + "name": "Lille - Ilevia",
140 + "type": "gtfs",
141 + "enabled": false,
142 + "public": true,
143 + "notes": "Trip updates only, through the PAN proxy -- so 60s rather than the default, for the same rate limit as Nantes.",
144 + "meta": { "tint": "indigo", "run_period": 604800 },
145 + "config": {
146 + "__type__": "gtfs",
147 + "url": "https://media.ilevia.fr/opendata/gtfs.zip",
148 + "url_rt_tu": "https://proxy.transport.data.gouv.fr/resource/ilevia-lille-gtfs-rt",
149 + "rt_period_tu": 60,
150 + "tz": "Europe/Paris"
151 + }
152 + }
153 + ]
154 +}
added apps/room_sanctum/test/room_sanctum/france_import_bundle_test.exs
+91 −0
@@ -0,0 +1,91 @@
1 +defmodule RoomSanctum.FranceImportBundleTest do
2 + @moduledoc """
3 + The shipped France bundle has to survive the Import Offerings page.
4 +
5 + It is a hand-written file rather than something exported from a running
6 + instance, so nothing else would notice a typo in a field name -- the page
7 + would simply show ten red cards to whoever pasted it.
8 + """
9 +
10 + use ExUnit.Case, async: true
11 +
12 + alias RoomSanctum.Configuration
13 + alias RoomSanctum.Configuration.Source
14 +
15 + @bundle Application.app_dir(:room_sanctum, "priv/imports/france_top_ten.json")
16 +
17 + defp sources do
18 + @bundle |> File.read!() |> Poison.decode!() |> Map.fetch!("sources")
19 + end
20 +
21 + # What the import page does to a pasted source before validating it.
22 + defp changeset(source) do
23 + attrs =
24 + source
25 + |> Map.drop(["id", "inserted_at", "updated_at"])
26 + |> Map.put("user_id", 1)
27 + |> atomize_keys()
28 +
29 + Configuration.change_source(%Source{}, attrs)
30 + end
31 +
32 + defp atomize_keys(map) when is_map(map) do
33 + map
34 + |> Map.new(fn {k, v} ->
35 + key = if is_binary(k), do: String.to_existing_atom(k), else: k
36 + {key, atomize_keys(v)}
37 + end)
38 + rescue
39 + ArgumentError -> map
40 + end
41 +
42 + defp atomize_keys(value), do: value
43 +
44 + test "the bundle is the ten cities, each a gtfs source" do
45 + names = Enum.map(sources(), & &1["name"])
46 +
47 + assert length(names) == 10
48 +
49 + for city <- ~w(Paris Marseille Lyon Toulouse Nice Nantes Montpellier
50 + Strasbourg Bordeaux Lille) do
51 + assert Enum.any?(names, &String.starts_with?(&1, city)),
52 + "no source for #{city}, only: #{inspect(names)}"
53 + end
54 +
55 + assert Enum.all?(sources(), &(&1["type"] == "gtfs"))
56 + end
57 +
58 + test "every source validates the way the import page validates it" do
59 + for source <- sources() do
60 + cs = changeset(source)
61 + assert cs.valid?, "#{source["name"]}: #{inspect(cs.errors)}"
62 + end
63 + end
64 +
65 + test "every feed is https, and on Paris time" do
66 + for source <- sources(), {field, url} <- source["config"], field != "__type__" do
67 + case field do
68 + "tz" -> assert url == "Europe/Paris", "#{source["name"]} is not on Paris time"
69 + "url" <> _ -> assert String.starts_with?(url, "https://"), "#{source["name"]}: #{url}"
70 + _ -> :ok
71 + end
72 + end
73 + end
74 +
75 + test "sources arrive disabled, so ten static imports do not start at once" do
76 + assert Enum.all?(sources(), &(&1["enabled"] == false))
77 + end
78 +
79 + test "the feeds behind the PAN proxy poll slower than its rate limit" do
80 + # One request per 30 seconds per resource; above that it answers 429.
81 + for source <- sources(), {field, url} <- source["config"], is_binary(url) do
82 + if String.starts_with?(url, "https://proxy.transport.data.gouv.fr/") do
83 + kind = String.replace_prefix(field, "url_rt_", "")
84 + period = source["config"]["rt_period_#{kind}"]
85 +
86 + assert is_integer(period) and period >= 30,
87 + "#{source["name"]} polls #{field} every #{inspect(period)}s"
88 + end
89 + end
90 + end
91 +end

Parents: a1ad701