neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Ship the ten biggest French cities as an import bundle
2dac4a5 · Gabriel Morell · 2026-09-08 19:19
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