Repository navigation
feat: add location lookup, children and feed counts to the locations API - #1884
Draft
Alessandro100 wants to merge 10 commits into
Draft
Alessandro100 wants to merge 10 commits into
Alessandro100 wants to merge 10 commits into
Conversation
Add GET /v1/locations/directory and GET /v1/locations/directory/feeds to browse feeds by continent > country > subdivision > municipality, based on the feeds' declared locations. Counts are split by data type and roll up to every ancestor; feeds are listed by relevance to the requested node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 of 5 tasks
The directory endpoints take a `country` slug (e.g. `portugal`) instead of `country_code`, so web URLs read /locations/europe/portugal. Slugs come from a static table so library upgrades cannot change published URLs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Directory nodes used the country name declared on the feeds (e.g. "Sverige"), which did not match the English slug in the URL. Country names now come from pycountry's short English names, with the same rewordings as the slugs, and a test checks every name slugifies to its slug. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/v1/search accepts the directory's continent, country, subdivision and municipality parameters, with the same validation, and a `sort` parameter (`created_at` by default, or `location` for relevance to the requested location). Location filters combine with the other search filters. BREAKING CHANGE: /v1/locations/directory/feeds is removed; call /v1/search with the same location parameters, status=active,inactive,development,future and sort=location instead. The endpoint was never released. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the Location-table directory with OSM geopolygons: - /v1/locations: continent, parent_location_id, path, has_feeds, include_feed_counts and sort (admin_level or feed_count); results add continent fields, slug, path_slugs and feed_counts; location_type=continent lists continents. - /v1/search: continent and location_id filters replace the country/subdivision/municipality slugs; sort=location ranks feeds by their share of stops inside the location. - continent and country_continent tables, seeded verbatim from country_converter's seven-continent classification by scripts/country_continent_seed.py. - GeopolygonLocationSearch: continent_code, slug (suffixed with the osm_id when siblings collide), path_slugs and an indexed path; one country row per ISO code. - Remove /v1/locations/directory and the Location-based directory code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GTFS Realtime feeds have no stops, so almost none are reverse-geolocated and the location directory missed them. A realtime feed without location group of its own now takes the groups of the schedule feeds it references, in feed counts and in the /v1/search location filters and sort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`path=canada/quebec/` returned no location because the value was only trimmed and lowercased. Slashes at both ends are now stripped, and a path of only slashes counts as no path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each locations endpoint now has one job: /v1/locations searches,
/v1/locations/{location_id} and /v1/locations/lookup?path= return one
location, and /v1/locations/{location_id}/children lists its children.
include=feed_counts,children adds counts and the first page of children
to a single location.
Continents become locations with fixed negative ids (-1 to -7) and are
the parent of their countries; paths start at the continent. /v1/search
takes location_id or location_path instead of continent.
BREAKING CHANGE: /v1/locations results now include the continents,
path_names starts with the continent, and a country's
parent_location_id is its continent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Feed counts, has_feeds and the search location filter read the union of each feed's own location groups and the groups realtime feeds inherit from their schedule feeds. They filtered that union by location afterwards; PostgreSQL cannot push the filter into the inherited branch (a join) and estimates it at one row, so it rebuilt the inherited groups once per location group in a nested loop: minutes for a continent on dev. Each branch now applies the location filter, joins the feed and applies the counted-feed rules itself, and the union is only aggregated. has_feeds counts the candidate locations once instead of one subquery per row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With has_feeds or sort=feed_count, the locations list counted the feeds of the matching locations up to four times: in the count query, in the has_feeds filter of the page query, and in the counts used to sort, which filtered by has_feeds again. One query now counts them once and uses the counts to filter (inner join), sort, page and return the total (window count); a page past the last one runs a small count query instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary:
Backs the feed location directory on mobilitydatabase.org (
/locations). It is built on the OSM geopolygon read model rather than the feeds' declaredLocationrows: places, hierarchy and names come fromGeopolygonLocationSearch, and feeds link to places through their reverse-geolocated stops (FeedOsmLocationGroup->OsmLocationGroupGeopolygon). A GTFS Realtime feed, which has no stops, takes the places of the schedule feeds it references.Each locations endpoint has one job:
/v1/locationssearches,/v1/locations/{location_id}and/v1/locations/lookupreturn one place, and/v1/locations/{location_id}/childrenlists a place's children.GET /v1/locations(search and filter, list only):continent(slug or code; the continent row included),has_feeds,include=feed_countsandsort(admin_level, the current order, orfeed_count).location_type=continentreturns the seven continents.GET /v1/locations/{location_id}: one place, or 404.GET /v1/locations/lookup?path=...: the same place, addressed by its path (e.g.north-america/canada/quebec), or 404. The path is case-insensitive and leading or trailing slashes are ignored.include: on the two single-place endpoints,include=feed_counts,childrenadds the place's counts and the first page of its direct children.has_feeds,sort,limitandoffsetthen apply to the children.GET /v1/locations/{location_id}/children: a place's direct children, paged, with the samehas_feeds,include=feed_countsandsort. 404 for an unknown id.location_idand apath:-1Africa,-2Antarctica,-3Asia,-4Europe,-5North America,-6Oceania,-7South America. Each continent is the parent of its countries.path,path_slugsandpath_namesstart at the continent, so a location'spathis its web URL after/locations/.continent_code,slug,path,path_slugsandfeed_counts(GTFS / GTFS-RT / GBFS / total). A place returned by/{location_id}orlookup(LocationDetail) also haschildren, filled only wheninclude=childrenis requested.location_id,location_type,slug,path,path_slugsandpath_namesare marked required in the spec.GET /v1/search:location_id(continents included) orlocation_pathrestricts results to feeds with stops in that place, sub-areas included. Pass one, not both (400). An unknown path returns no feed.sort=locationranks feeds by the share of their stops inside the place (a city bus feed before a regional train), then by provider, feed name and stable id. It requireslocation_idorlocation_path.continentandcountry_continenttables.liquibase/changes/feat_location_directory.sql(250 countries). The mapping is taken as is fromcountry_converter's seven-continent classification, which follows Our World in Data's region definitions, with no overrides.scripts/country_continent_seed.pyregenerates the seed; the library is a dev-only dependency.feat_location_directory_continent_ids.sqladds the continents' fixed ids. It is a separate changeset because dev already ran the first one.GeopolygonLocationSearchview:continent_code,slug,path_slugsand an indexedpath./{location_id}before/lookup.utils/route_order.pymoves fixed paths first for the locations router only./v1/locations/directoryendpoint;country/subdivision/municipalityandcontinentsearch parameters;parent_location_id,pathandinclude_feed_countsparameters of/v1/locations;Location-based directory code.Changes for existing
/v1/locationsusers (released since v1.16.0):path_namesstarts with the continent (["North America", "Canada", "Québec"]).display_nameis unchanged.parent_location_idis its continent's id instead of null.Design notes:
active,inactive,developmentorfuture, with the sameoperational_statusrule as/v1/search. A feed counts in every place where it has stops, so a regional feed appears under each municipality it serves. Continent counts are distinct feeds, never sums of country counts. Counts are only computed when asked for (include=feed_counts,has_feeds,sort=feed_count).-.-<location_id>appended. On dev, 1,274 parent and slug pairs collide, mostly same-named US townships directly under their state.location_id, not paths.feedreference), with their stop counts. A realtime feed with its own groups keeps only those.has_feedscheck and location search applies the location filter inside each branch of that union, and joins the feed there too. PostgreSQL cannot push a filter into the inherited branch (a join) and estimates it at about one row, so filtering the union afterwards made it rebuild the inherited groups once per location group: continent requests took minutes on dev, some hitting the 300 s timeout.has_feedsorsort=feed_count, one query counts the feeds of the matching locations and uses the counts to filter, sort, page and return the total (a window count). It used to count them up to four times.Expected behavior:
/v1/locations?location_type=continent&include=feed_countslists the seven continents (ids -1 to -7) with counts./v1/locations/lookup?path=north-america/canada/quebec&include=feed_counts,children&has_feeds=true&sort=feed_countreturns Québec with its children that have feeds, most feeds first./v1/locations/lookup?path=atlantisreturns 404./v1/locations/-5/children?has_feeds=true&include=feed_counts&sort=feed_countlists the North American countries that have feeds; each country'sparent_location_idis-5./v1/search?location_path=north-america/canada/quebec&status=active,inactive,development,future&sort=locationlists Québec's feeds, as doeslocation_id=<Québec's id>.location_idandlocation_path;sort=locationwithout either of them;include=childrenon the list or children endpoint;sort.Testing tips:
Tests:
api/tests/integration/location_directory/: a geopolygon fixture covering continents, duplicate country boundaries, slug collisions, feed counts, realtime feeds located through their schedule feed, and every endpoint and filter above.api/tests/unittest/test_locations_api_impl.py.Full suite: the full
api/suite, run with the pytest commandscripts/api-tests.shuses: 882 passed. 6user_serviceearly-access tests fail locally because my test database lacks theearly_access_*tables; this PR doesn't touchuser_service. flake8 and black are clean.Migration:
liquibase/changelog.xml).REFRESH MATERIALIZED VIEW CONCURRENTLYworks on the new view.Dev: the schema is migrated (the continent ids changeset and the view rebuild) and the API is deployed to dev; the web PR's preview uses it.
Before merging: this PR redefines
GeopolygonLocationSearch, which the geopolygon loader refreshes and/v1/locationsalready reads; it's worth a review from whoever owns it.Web client: feat: add feed location directory mobilitydatabase-web#259
Run the unit tests with
./scripts/api-tests.shto make sure you didn't break anything (suite run with the script's pytest command directly, see above)Add or update any needed documentation to the repo (OpenAPI spec)
Format the title like "feat: [new feature short description]". Title must follow the Conventional Commit Specification(https://www.conventionalcommits.org/en/v1.0.0/).
Linked all relevant issues
Include screenshot(s) showing how this pull request works and fixes the issue(s)
Generated with Claude Code