From f07573989cddf39b4d92543117a6920a9d210401 Mon Sep 17 00:00:00 2001 From: jcpitre Date: Thu, 30 Jul 2026 09:25:41 -0400 Subject: [PATCH 1/4] Added seasonal flag. --- api/src/scripts/populate_db_gtfs.py | 5 ++ api/src/shared/db_models/feed_impl.py | 2 + .../db_models/search_feed_item_result_impl.py | 2 + .../populate_tests/test_data/sources_test.csv | 16 ++-- .../populate_tests/test_populate.py | 80 +++++++++++++++++++ .../integration/test_data/sources_test.csv | 12 +-- .../unittest/models/test_basic_feed_impl.py | 4 + .../test_search_feed_item_result_impl.py | 3 + docs/DatabaseCatalogAPI.yaml | 24 +++++- docs/OperationsAPI.yaml | 46 +++++++++++ .../models/update_request_gtfs_feed_impl.py | 3 + .../update_request_gtfs_rt_feed_impl.py | 3 + .../test_update_request_gtfs_feed_impl.py | 5 ++ liquibase/changelog.xml | 2 + liquibase/changes/feat_1774.sql | 6 ++ liquibase/materialized_views/feed_search.sql | 3 + 16 files changed, 203 insertions(+), 13 deletions(-) create mode 100644 liquibase/changes/feat_1774.sql diff --git a/api/src/scripts/populate_db_gtfs.py b/api/src/scripts/populate_db_gtfs.py index 7bce278f4..550e08d4f 100644 --- a/api/src/scripts/populate_db_gtfs.py +++ b/api/src/scripts/populate_db_gtfs.py @@ -290,6 +290,11 @@ def populate_db(self, session: "Session", fetch_url: bool = True): is_producer_url_unstable_from_csv = self.get_safe_boolean_value(row, "is_producer_url_unstable", None) if is_producer_url_unstable_from_csv is not None: feed.is_producer_url_unstable = is_producer_url_unstable_from_csv + # Three-state flag from the catalog CSV ("True"/"False"/empty). An empty cell leaves the DB + # value untouched (same as is_official), so a re-import never wipes an operator-set value. + is_seasonal_from_csv = self.get_safe_boolean_value(row, "is_seasonal", None) + if is_seasonal_from_csv is not None: + feed.seasonal = is_seasonal_from_csv feed.authentication_type = str(int(float(self.get_safe_value(row, "urls.authentication_type", "0")))) feed.authentication_info_url = self.get_safe_value(row, "urls.authentication_info", "") feed.api_key_parameter_name = self.get_safe_value(row, "urls.api_key_parameter_name", "") diff --git a/api/src/shared/db_models/feed_impl.py b/api/src/shared/db_models/feed_impl.py index abcd9c3d3..5dd6ebc2f 100644 --- a/api/src/shared/db_models/feed_impl.py +++ b/api/src/shared/db_models/feed_impl.py @@ -32,6 +32,7 @@ def from_orm(cls, feed_orm: FeedOrm | None) -> Feed | None: feed.status = feed_orm.status feed.official = feed_orm.official feed.official_updated_at = feed_orm.official_updated_at + feed.seasonal = feed_orm.seasonal feed.feed_name = feed_orm.feed_name feed.related_links = [FeedRelatedLinkImpl.from_orm(related_link) for related_link in feed_orm.feedrelatedlinks] feed.note = feed_orm.note @@ -70,6 +71,7 @@ def to_orm_from_dict(cls, feed_dict: dict | None, db_session: Session | None = N status=feed_dict.get("status"), official=feed_dict.get("official"), official_updated_at=feed_dict.get("official_updated_at"), + seasonal=feed_dict.get("seasonal"), feed_name=feed_dict.get("feed_name"), note=feed_dict.get("note"), externalids=( diff --git a/api/src/shared/db_models/search_feed_item_result_impl.py b/api/src/shared/db_models/search_feed_item_result_impl.py index d342fb46b..ac1a7b037 100644 --- a/api/src/shared/db_models/search_feed_item_result_impl.py +++ b/api/src/shared/db_models/search_feed_item_result_impl.py @@ -30,6 +30,7 @@ def from_orm_gtfs(cls, feed_search_row: t_feedsearch): provider=feed_search_row.provider, feed_name=feed_search_row.feed_name, official=feed_search_row.official, + seasonal=feed_search_row.seasonal, note=feed_search_row.note, feed_contact_email=feed_search_row.feed_contact_email, source_info=SourceInfo( @@ -116,6 +117,7 @@ def from_orm_gtfs_rt(cls, feed_search_row): provider=feed_search_row.provider, feed_name=feed_search_row.feed_name, official=feed_search_row.official, + seasonal=feed_search_row.seasonal, note=feed_search_row.note, feed_contact_email=feed_search_row.feed_contact_email, source_info=SourceInfo( diff --git a/api/tests/integration/populate_tests/test_data/sources_test.csv b/api/tests/integration/populate_tests/test_data/sources_test.csv index 2004b7100..6bbd6a46f 100644 --- a/api/tests/integration/populate_tests/test_data/sources_test.csv +++ b/api/tests/integration/populate_tests/test_data/sources_test.csv @@ -3,15 +3,19 @@ # and see the effect. # We want to make sure the is_official flag is handled properly in that case. # Normally changing to True or False should change the original value but changing to an empty cell should not -mdb_source_id,data_type,entity_type,location.country_code,location.subdivision_name,location.municipality,provider,is_official,name,note,feed_contact_email,static_reference,urls.direct_download,urls.authentication_type,urls.authentication_info,urls.api_key_parameter_name,urls.latest,urls.license,location.bounding_box.minimum_latitude,location.bounding_box.maximum_latitude,location.bounding_box.minimum_longitude,location.bounding_box.maximum_longitude,location.bounding_box.extracted_on,status,features,redirect.id,redirect.comment,is_producer_url_unstable -# For mdb-40, change is_official from TRUE to empty. Should retain True# For mdb-40, change is_official from TRUE to empty. Should retain True -40,gtfs,,CA,Ontario,London,London Transit Commission,,,,croy@londontransit.ca,,http://www.londontransit.ca/gtfsfeed/google_transit.zip,0,,,https://storage.googleapis.com/storage/v1/b/mdb-latest/o/ca-ontario-london-transit-commission-gtfs-2.zip?alt=media,https://www.londontransit.ca/open-data/ltcs-open-data-terms-of-use/,42.905244,43.051188,-81.36311,-81.137591,2022-02-22T19:51:34+00:00,inactive,,,, +mdb_source_id,data_type,entity_type,location.country_code,location.subdivision_name,location.municipality,provider,is_official,name,note,feed_contact_email,static_reference,urls.direct_download,urls.authentication_type,urls.authentication_info,urls.api_key_parameter_name,urls.latest,urls.license,location.bounding_box.minimum_latitude,location.bounding_box.maximum_latitude,location.bounding_box.minimum_longitude,location.bounding_box.maximum_longitude,location.bounding_box.extracted_on,status,features,redirect.id,redirect.comment,is_producer_url_unstable,is_seasonal +# For mdb-40, change is_official from TRUE to empty. Should retain True. +# Also leave is_seasonal empty here: should retain the base TRUE value. +40,gtfs,,CA,Ontario,London,London Transit Commission,,,,croy@londontransit.ca,,http://www.londontransit.ca/gtfsfeed/google_transit.zip,0,,,https://storage.googleapis.com/storage/v1/b/mdb-latest/o/ca-ontario-london-transit-commission-gtfs-2.zip?alt=media,https://www.londontransit.ca/open-data/ltcs-open-data-terms-of-use/,42.905244,43.051188,-81.36311,-81.137591,2022-02-22T19:51:34+00:00,inactive,,,,, # For mdb-50, change is_official from FALSE to empty. Should retain False. # Also leave is_producer_url_unstable empty here: should retain the base FALSE value. -50,gtfs,,CA,Ontario,Barrie,ZBarrie Transit,,,,,,http://www.myridebarrie.ca/gtfs/Google_transit.zip,,,,https://storage.googleapis.com/storage/v1/b/mdb-latest/o/ca-ontario-barrie-transit-gtfs-3.zip?alt=media,https://www.barrie.ca/services-payments/transportation-parking/barrie-transit/barrie-gtfs,44.3218044,44.42020676,-79.74063237,-79.61089569,2022-03-01T22:43:25+00:00,deprecated,,40|mdb-702,Some|Comment, +# Also leave is_seasonal empty here: should retain the base FALSE value. +50,gtfs,,CA,Ontario,Barrie,ZBarrie Transit,,,,,,http://www.myridebarrie.ca/gtfs/Google_transit.zip,,,,https://storage.googleapis.com/storage/v1/b/mdb-latest/o/ca-ontario-barrie-transit-gtfs-3.zip?alt=media,https://www.barrie.ca/services-payments/transportation-parking/barrie-transit/barrie-gtfs,44.3218044,44.42020676,-79.74063237,-79.61089569,2022-03-01T22:43:25+00:00,deprecated,,40|mdb-702,Some|Comment,, # For mdb-1562, change is_official from FALSE to TRUE. Should change to True. # Also change is_producer_url_unstable from base FALSE to TRUE: should change to True. -1562,gtfs-rt,sa,CA,BC,Vancouver,Vancouver-Transit(éèàçíóúČ),TRUE,Realtime(ŘŤÜÎ),,,50,http://foo.org/google_transit.zip,0,,,,,,,,,,active,,10,,TRUE +# Also change is_seasonal from base FALSE to TRUE: should change to True. +1562,gtfs-rt,sa,CA,BC,Vancouver,Vancouver-Transit(éèàçíóúČ),TRUE,Realtime(ŘŤÜÎ),,,50,http://foo.org/google_transit.zip,0,,,,,,,,,,active,,10,,TRUE,TRUE # For mdb-1563, change is_official from TRUE to FALSE. Should change to False. # Also change is_producer_url_unstable from base TRUE to FALSE: should change to False. -1563,gtfs-rt,tu,US,SomeState,SomeCity,SomeCity Bus,FALSE,RT,,,mdb-50,http://bar.com,0,,,,,,,,,,inactive,,10,,FALSE +# Also change is_seasonal from base TRUE to FALSE: should change to False. +1563,gtfs-rt,tu,US,SomeState,SomeCity,SomeCity Bus,FALSE,RT,,,mdb-50,http://bar.com,0,,,,,,,,,,inactive,,10,,FALSE,FALSE diff --git a/api/tests/integration/populate_tests/test_populate.py b/api/tests/integration/populate_tests/test_populate.py index 35f990a6b..27bb5e52a 100644 --- a/api/tests/integration/populate_tests/test_populate.py +++ b/api/tests/integration/populate_tests/test_populate.py @@ -135,6 +135,86 @@ def test_is_producer_url_unstable_defaults_null(client: TestClient, values): assert json_response["source_info"].get("is_producer_url_unstable") is None, values["assert_fail_message"] +@pytest.mark.parametrize( + "values", + [ + { + "feed_id": "mdb-40", + "expected_seasonal": True, + "assert_fail_message": "mdb-40, is_seasonal changed from TRUE to empty. Should retain True.", + }, + { + "feed_id": "mdb-50", + "expected_seasonal": False, + "assert_fail_message": "mdb-50, is_seasonal changed from FALSE to empty. Should retain False.", + }, + { + "feed_id": "mdb-1562", + "expected_seasonal": True, + "assert_fail_message": "mdb-1562, is_seasonal FALSE->TRUE should change to True.", + }, + { + "feed_id": "mdb-1563", + "expected_seasonal": False, + "assert_fail_message": "mdb-1563, is_seasonal TRUE->FALSE should change to False.", + }, + ], + ids=[ + "seasonal_change_true_to_empty", + "seasonal_change_false_to_empty", + "seasonal_change_false_to_true", + "seasonal_change_true_to_false", + ], +) +def test_is_seasonal_overwrite(client: TestClient, values): + """An empty CSV cell must retain the stored value; an explicit True/False must overwrite it.""" + feed_id = values["feed_id"] + expected = values["expected_seasonal"] + + response = client.request( + "GET", + "/v1/feeds/{id}".format(id=feed_id), + headers=authHeaders, + ) + + assert response.status_code == 200 + json_response = response.json() + assert json_response["seasonal"] is expected, values["assert_fail_message"] + + +@pytest.mark.parametrize( + "values", + [ + { + "feed_id": "mdb-702", + "assert_fail_message": "mdb-702 has an empty is_seasonal cell; must stay null, not False.", + }, + { + "feed_id": "mdb-1", + "assert_fail_message": "mdb-1's CSV has no is_seasonal column; must default to null.", + }, + ], + ids=[ + "seasonal_empty_cell_stays_null", + "seasonal_absent_column_stays_null", + ], +) +def test_is_seasonal_defaults_null(client: TestClient, values): + """An empty cell or a missing column must leave seasonal as null (never coerced to False).""" + feed_id = values["feed_id"] + + response = client.request( + "GET", + "/v1/feeds/{id}".format(id=feed_id), + headers=authHeaders, + ) + + assert response.status_code == 200 + json_response = response.json() + # Robust to either representation: key present with null, or key omitted. + assert json_response.get("seasonal") is None, values["assert_fail_message"] + + def test_is_feed_reference_overwrite(client: TestClient): feed_id = "mdb-1562" response = client.request( diff --git a/api/tests/integration/test_data/sources_test.csv b/api/tests/integration/test_data/sources_test.csv index 43e19e02c..14cab7b98 100644 --- a/api/tests/integration/test_data/sources_test.csv +++ b/api/tests/integration/test_data/sources_test.csv @@ -1,6 +1,6 @@ -mdb_source_id,data_type,entity_type,location.country_code,location.subdivision_name,location.municipality,provider,is_official,name,note,feed_contact_email,static_reference,urls.direct_download,urls.authentication_type,urls.authentication_info,urls.api_key_parameter_name,urls.latest,urls.license,location.bounding_box.minimum_latitude,location.bounding_box.maximum_latitude,location.bounding_box.minimum_longitude,location.bounding_box.maximum_longitude,location.bounding_box.extracted_on,status,features,redirect.id,redirect.comment,is_producer_url_unstable -40,gtfs,,CA,Ontario,London,London Transit Commission,TRUE,,,croy@londontransit.ca,,http://www.londontransit.ca/gtfsfeed/google_transit.zip,0,,,https://storage.googleapis.com/storage/v1/b/mdb-latest/o/ca-ontario-london-transit-commission-gtfs-2.zip?alt=media,https://www.londontransit.ca/open-data/ltcs-open-data-terms-of-use/,42.905244,43.051188,-81.36311,-81.137591,2022-02-22T19:51:34+00:00,inactive,,,,TRUE -50,gtfs,,CA,Ontario,Barrie,ZBarrie Transit,FALSE,,,,,http://www.myridebarrie.ca/gtfs/Google_transit.zip,,,,https://storage.googleapis.com/storage/v1/b/mdb-latest/o/ca-ontario-barrie-transit-gtfs-3.zip?alt=media,https://www.barrie.ca/services-payments/transportation-parking/barrie-transit/barrie-gtfs,44.3218044,44.42020676,-79.74063237,-79.61089569,2022-03-01T22:43:25+00:00,deprecated,,40|mdb-702,Some|Comment,FALSE -702,gtfs,,CA,[British Columbia,Whistler],BC Transit (Whistler Transit System),,,,,,http://whistler.mapstrat.com/current/google_transit.zip,,,,https://storage.googleapis.com/storage/v1/b/mdb-latest/o/ca-british-columbia-bc-transit-whistler-transit-system-gtfs-702.zip?alt=media,https://www.bctransit.com/open-data/terms-of-use,50.077122,50.159071,-123.043635,-122.926836,2022-03-16T22:05:05+00:00,development,,,, -1562,gtfs-rt,sa,CA,BC,Vancouver,Vancouver-Transit(éèàçíóúČ),True,Realtime(ŘŤÜÎ),,,40|60,http://foo.org/google_transit.zip,0,,,,,,,,,,active,,10,,FALSE -1563,gtfs-rt,tu,US,SomeState,SomeCity,SomeCity Bus,False,RT,,,mdb-50,http://bar.com,0,,,,,,,,,,inactive,,10,,TRUE \ No newline at end of file +mdb_source_id,data_type,entity_type,location.country_code,location.subdivision_name,location.municipality,provider,is_official,name,note,feed_contact_email,static_reference,urls.direct_download,urls.authentication_type,urls.authentication_info,urls.api_key_parameter_name,urls.latest,urls.license,location.bounding_box.minimum_latitude,location.bounding_box.maximum_latitude,location.bounding_box.minimum_longitude,location.bounding_box.maximum_longitude,location.bounding_box.extracted_on,status,features,redirect.id,redirect.comment,is_producer_url_unstable,is_seasonal +40,gtfs,,CA,Ontario,London,London Transit Commission,TRUE,,,croy@londontransit.ca,,http://www.londontransit.ca/gtfsfeed/google_transit.zip,0,,,https://storage.googleapis.com/storage/v1/b/mdb-latest/o/ca-ontario-london-transit-commission-gtfs-2.zip?alt=media,https://www.londontransit.ca/open-data/ltcs-open-data-terms-of-use/,42.905244,43.051188,-81.36311,-81.137591,2022-02-22T19:51:34+00:00,inactive,,,,TRUE,TRUE +50,gtfs,,CA,Ontario,Barrie,ZBarrie Transit,FALSE,,,,,http://www.myridebarrie.ca/gtfs/Google_transit.zip,,,,https://storage.googleapis.com/storage/v1/b/mdb-latest/o/ca-ontario-barrie-transit-gtfs-3.zip?alt=media,https://www.barrie.ca/services-payments/transportation-parking/barrie-transit/barrie-gtfs,44.3218044,44.42020676,-79.74063237,-79.61089569,2022-03-01T22:43:25+00:00,deprecated,,40|mdb-702,Some|Comment,FALSE,FALSE +702,gtfs,,CA,[British Columbia,Whistler],BC Transit (Whistler Transit System),,,,,,http://whistler.mapstrat.com/current/google_transit.zip,,,,https://storage.googleapis.com/storage/v1/b/mdb-latest/o/ca-british-columbia-bc-transit-whistler-transit-system-gtfs-702.zip?alt=media,https://www.bctransit.com/open-data/terms-of-use,50.077122,50.159071,-123.043635,-122.926836,2022-03-16T22:05:05+00:00,development,,,,, +1562,gtfs-rt,sa,CA,BC,Vancouver,Vancouver-Transit(éèàçíóúČ),True,Realtime(ŘŤÜÎ),,,40|60,http://foo.org/google_transit.zip,0,,,,,,,,,,active,,10,,FALSE,FALSE +1563,gtfs-rt,tu,US,SomeState,SomeCity,SomeCity Bus,False,RT,,,mdb-50,http://bar.com,0,,,,,,,,,,inactive,,10,,TRUE,TRUE \ No newline at end of file diff --git a/api/tests/unittest/models/test_basic_feed_impl.py b/api/tests/unittest/models/test_basic_feed_impl.py index 7bed51fc1..5f04a5855 100644 --- a/api/tests/unittest/models/test_basic_feed_impl.py +++ b/api/tests/unittest/models/test_basic_feed_impl.py @@ -32,6 +32,7 @@ note="note", producer_url="producer_url", is_producer_url_unstable=True, + seasonal=True, authentication_type="1", authentication_info_url="authentication_info_url", api_key_parameter_name="api_key_parameter_name", @@ -105,6 +106,7 @@ api_key_parameter_name="api_key_parameter_name", license_url="license_url", ), + seasonal=True, redirects=[ RedirectImpl( target_id="target_id", @@ -181,6 +183,7 @@ def test_to_orm_from_dict_full_payload(self): "feed_contact_email": "contact@example.com", "producer_url": "https://producer.example.com", "is_producer_url_unstable": True, + "seasonal": True, "authentication_type": 1, # should be converted to string "authentication_info_url": "https://auth.example.com", "api_key_parameter_name": "api_key", @@ -215,6 +218,7 @@ def test_to_orm_from_dict_full_payload(self): assert obj.feed_contact_email == "contact@example.com" assert obj.producer_url == "https://producer.example.com" assert obj.is_producer_url_unstable is True + assert obj.seasonal is True # authentication_type coerced to string per implementation assert obj.authentication_type == "1" assert obj.authentication_info_url == "https://auth.example.com" diff --git a/api/tests/unittest/models/test_search_feed_item_result_impl.py b/api/tests/unittest/models/test_search_feed_item_result_impl.py index ec1c2b9db..debf7d821 100644 --- a/api/tests/unittest/models/test_search_feed_item_result_impl.py +++ b/api/tests/unittest/models/test_search_feed_item_result_impl.py @@ -25,6 +25,7 @@ def __init__(self, **kwargs): status="active", feed_name="feed_name", official=None, + seasonal=True, created_at=fake.date_time_this_month(), note="note", feed_contact_email="feed_contact_email", @@ -80,6 +81,7 @@ def test_from_orm_gtfs(self): external_ids=item.external_ids, provider=item.provider, feed_name=item.feed_name, + seasonal=item.seasonal, note=item.note, feed_contact_email=item.feed_contact_email, source_info=SourceInfo( @@ -127,6 +129,7 @@ def test_from_orm_gtfs_rt(self): external_ids=item.external_ids, provider=item.provider, feed_name=item.feed_name, + seasonal=item.seasonal, note=item.note, feed_contact_email=item.feed_contact_email, source_info=SourceInfo( diff --git a/docs/DatabaseCatalogAPI.yaml b/docs/DatabaseCatalogAPI.yaml index f016ba5e1..60c06b275 100644 --- a/docs/DatabaseCatalogAPI.yaml +++ b/docs/DatabaseCatalogAPI.yaml @@ -557,6 +557,17 @@ components: type: string example: 2023-07-10T22:06:00Z format: date-time + seasonal: + description: > + Indicates whether the feed is seasonal, i.e. it only provides service during recurring + periods of the year (for example a summer-only or winter-only service). Seasonal feeds + are excluded from the rolling 7-day service coverage checks. + * true - The feed is seasonal. + * false - The feed is not seasonal. + * null (default) - There is not enough information to determine whether the feed is seasonal. + type: boolean + nullable: true + example: true feed_name: description: > An optional description of the data feed, e.g to specify if the data feed is an aggregate of @@ -846,10 +857,21 @@ components: format: date-time official: description: > - A boolean value indicating if the feed is official or not. + A boolean value indicating if the feed is official or not. Official feeds are provided by the transit agency or a trusted source. type: boolean example: true + seasonal: + description: > + Indicates whether the feed is seasonal, i.e. it only provides service during recurring + periods of the year (for example a summer-only or winter-only service). Seasonal feeds + are excluded from the rolling 7-day service coverage checks. + * true - The feed is seasonal. + * false - The feed is not seasonal. + * null (default) - There is not enough information to determine whether the feed is seasonal. + type: boolean + nullable: true + example: true external_ids: $ref: "#/components/schemas/ExternalIds" description: | diff --git a/docs/OperationsAPI.yaml b/docs/OperationsAPI.yaml index 538f2cef7..baf169d04 100644 --- a/docs/OperationsAPI.yaml +++ b/docs/OperationsAPI.yaml @@ -740,6 +740,17 @@ components: type: string example: 2023-07-10T22:06:00Z format: date-time + seasonal: + description: > + Indicates whether the feed is seasonal, i.e. it only provides service during recurring + periods of the year (for example a summer-only or winter-only service). Seasonal feeds + are excluded from the rolling 7-day service coverage checks. + * true - The feed is seasonal. + * false - The feed is not seasonal. + * null (default) - There is not enough information to determine whether the feed is seasonal. + type: boolean + nullable: true + example: true feed_name: description: > An optional description of the data feed, e.g to specify if the data feed is an aggregate of multiple providers, or which network is represented by the feed. @@ -1046,6 +1057,17 @@ components: type: boolean example: true + seasonal: + description: > + Indicates whether the feed is seasonal, i.e. it only provides service during recurring + periods of the year (for example a summer-only or winter-only service). Seasonal feeds + are excluded from the rolling 7-day service coverage checks. + * true - The feed is seasonal. + * false - The feed is not seasonal. + * null (default) - There is not enough information to determine whether the feed is seasonal. + type: boolean + nullable: true + example: true external_ids: $ref: "#/components/schemas/ExternalIds" description: | @@ -1883,6 +1905,12 @@ components: official: type: boolean description: Whether this is an official feed. + seasonal: + type: boolean + nullable: true + description: > + Whether the feed is seasonal, i.e. it only provides service during recurring periods of + the year (for example a summer-only or winter-only service). Null when unknown. locations: $ref: "#/components/schemas/Locations" related_links: @@ -1944,6 +1972,12 @@ components: official: type: boolean description: Whether this is an official feed. + seasonal: + type: boolean + nullable: true + description: > + Whether the feed is seasonal, i.e. it only provides service during recurring periods of + the year (for example a summer-only or winter-only service). Null when unknown. entity_types: type: array minItems: 1 @@ -2099,6 +2133,12 @@ components: official: type: boolean description: Whether this is an official feed. + seasonal: + type: boolean + nullable: true + description: > + Whether the feed is seasonal, i.e. it only provides service during recurring periods of + the year (for example a summer-only or winter-only service). Null when unknown. propagate_license: type: boolean default: false @@ -2154,6 +2194,12 @@ components: official: type: boolean description: Whether this is an official feed. + seasonal: + type: boolean + nullable: true + description: > + Whether the feed is seasonal, i.e. it only provides service during recurring periods of + the year (for example a summer-only or winter-only service). Null when unknown. propagate_license: type: boolean default: false diff --git a/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_feed_impl.py b/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_feed_impl.py index b7c2ad55d..6a5a34c74 100644 --- a/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_feed_impl.py +++ b/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_feed_impl.py @@ -74,6 +74,7 @@ def from_orm(cls, obj: Gtfsfeed | None) -> UpdateRequestGtfsFeed | None: key=lambda x: x.external_id, ), official=obj.official, + seasonal=obj.seasonal, ) @classmethod @@ -89,6 +90,8 @@ def to_orm( entity.note = update_request.note entity.feed_contact_email = update_request.feed_contact_email entity.official = update_request.official + # Nullable three-state boolean set at the top level of the feed (preserve False and None/unknown). + entity.seasonal = update_request.seasonal entity.producer_url = ( None if ( diff --git a/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_rt_feed_impl.py b/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_rt_feed_impl.py index f276d8404..a64d925af 100644 --- a/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_rt_feed_impl.py +++ b/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_rt_feed_impl.py @@ -86,6 +86,7 @@ def from_orm(cls, obj: Gtfsrealtimefeed | None) -> UpdateRequestGtfsRtFeed | Non ), feed_references=sorted([item.stable_id for item in obj.gtfs_feeds]), official=obj.official, + seasonal=obj.seasonal, ) @classmethod @@ -101,6 +102,8 @@ def to_orm( entity.note = update_request.note entity.feed_contact_email = update_request.feed_contact_email entity.official = update_request.official + # Nullable three-state boolean set at the top level of the feed (preserve False and None/unknown). + entity.seasonal = update_request.seasonal entity.producer_url = ( None if ( diff --git a/functions-python/operations_api/tests/feeds_operations/impl/models/test_update_request_gtfs_feed_impl.py b/functions-python/operations_api/tests/feeds_operations/impl/models/test_update_request_gtfs_feed_impl.py index 9e5ad714e..d0de1f249 100644 --- a/functions-python/operations_api/tests/feeds_operations/impl/models/test_update_request_gtfs_feed_impl.py +++ b/functions-python/operations_api/tests/feeds_operations/impl/models/test_update_request_gtfs_feed_impl.py @@ -23,6 +23,7 @@ def test_from_orm(): feed_contact_email="email@example.com", producer_url="http://producer.url", is_producer_url_unstable=True, + seasonal=True, authentication_type=1, authentication_info_url="http://auth.info.url", api_key_parameter_name="api_key", @@ -40,6 +41,7 @@ def test_from_orm(): assert result.feed_contact_email == "email@example.com" assert result.source_info.producer_url == "http://producer.url" assert result.source_info.is_producer_url_unstable is True + assert result.seasonal is True assert result.source_info.authentication_type == 1 assert result.source_info.authentication_info_url == "http://auth.info.url" assert result.source_info.api_key_parameter_name == "api_key" @@ -63,6 +65,7 @@ def test_to_orm(): feed_name="feed_name", note="note", feed_contact_email="email@example.com", + seasonal=True, source_info=SourceInfo( producer_url="http://producer.url", is_producer_url_unstable=True, @@ -89,6 +92,7 @@ def test_to_orm(): assert result.feed_contact_email == "email@example.com" assert result.producer_url == "http://producer.url" assert result.is_producer_url_unstable is True + assert result.seasonal is True assert result.authentication_type == "1" assert result.authentication_info_url == "http://auth.info.url" assert result.api_key_parameter_name == "api_key" @@ -119,6 +123,7 @@ def test_to_orm_invalid_source_info(): result = UpdateRequestGtfsFeedImpl.to_orm(update_request, entity, session) assert result.producer_url is None assert result.is_producer_url_unstable is None + assert result.seasonal is None assert result.authentication_type is None assert result.authentication_info_url is None assert result.api_key_parameter_name is None diff --git a/liquibase/changelog.xml b/liquibase/changelog.xml index 6c0ca4f5b..40ffe6c68 100644 --- a/liquibase/changelog.xml +++ b/liquibase/changelog.xml @@ -123,6 +123,8 @@ + + diff --git a/liquibase/changes/feat_1774.sql b/liquibase/changes/feat_1774.sql new file mode 100644 index 000000000..b37d42198 --- /dev/null +++ b/liquibase/changes/feat_1774.sql @@ -0,0 +1,6 @@ +-- Issue #1774: Add seasonal to the feed table. +-- Marks whether a feed is seasonal, i.e. it only provides service during recurring +-- periods of the year (e.g. a summer-only or winter-only service). +-- Seasonal feeds are excluded from the rolling 7-day service coverage checks. +-- Nullable three-state boolean: TRUE = seasonal, FALSE = not seasonal, NULL = unknown (default). +ALTER TABLE feed ADD COLUMN IF NOT EXISTS seasonal BOOLEAN; diff --git a/liquibase/materialized_views/feed_search.sql b/liquibase/materialized_views/feed_search.sql index 81f058981..b5aa493b7 100644 --- a/liquibase/materialized_views/feed_search.sql +++ b/liquibase/materialized_views/feed_search.sql @@ -22,6 +22,9 @@ SELECT -- official status Feed.official AS official, + -- seasonal status + Feed.seasonal AS seasonal, + -- created_at Feed.created_at AS created_at, From d4a08a803f5ed4c1b71aae66a37d0f404e0af22e Mon Sep 17 00:00:00 2001 From: jcpitre Date: Thu, 30 Jul 2026 11:32:57 -0400 Subject: [PATCH 2/4] Reordered the liquibase changes properly. --- liquibase/changelog.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/liquibase/changelog.xml b/liquibase/changelog.xml index e27f48faf..8fba16113 100644 --- a/liquibase/changelog.xml +++ b/liquibase/changelog.xml @@ -123,10 +123,10 @@ - - + + From 57d634df6e2b0bec1ca9fb3e3caaea833842d19b Mon Sep 17 00:00:00 2001 From: jcpitre Date: Thu, 30 Jul 2026 14:52:44 -0400 Subject: [PATCH 3/4] Changed seasonal to be a reral boolean instead of true/false/null. --- api/src/scripts/populate_db_gtfs.py | 5 +-- .../populate_tests/test_populate.py | 15 ++++----- .../unittest/models/test_basic_feed_impl.py | 4 ++- docs/DatabaseCatalogAPI.yaml | 16 ++++------ docs/OperationsAPI.yaml | 32 ++++++++----------- .../models/update_request_gtfs_feed_impl.py | 1 - .../update_request_gtfs_rt_feed_impl.py | 1 - .../test_update_request_gtfs_feed_impl.py | 2 +- liquibase/changes/feat_1774.sql | 4 +-- 9 files changed, 36 insertions(+), 44 deletions(-) diff --git a/api/src/scripts/populate_db_gtfs.py b/api/src/scripts/populate_db_gtfs.py index 550e08d4f..86d1b0276 100644 --- a/api/src/scripts/populate_db_gtfs.py +++ b/api/src/scripts/populate_db_gtfs.py @@ -290,8 +290,9 @@ def populate_db(self, session: "Session", fetch_url: bool = True): is_producer_url_unstable_from_csv = self.get_safe_boolean_value(row, "is_producer_url_unstable", None) if is_producer_url_unstable_from_csv is not None: feed.is_producer_url_unstable = is_producer_url_unstable_from_csv - # Three-state flag from the catalog CSV ("True"/"False"/empty). An empty cell leaves the DB - # value untouched (same as is_official), so a re-import never wipes an operator-set value. + # Boolean flag from the catalog CSV ("True"/"False"/empty). The column defaults to FALSE, so a + # new/unmarked feed is non-seasonal. An empty cell leaves the current DB value untouched, so a + # re-import never wipes an operator-set value; only an explicit True/False overwrites it. is_seasonal_from_csv = self.get_safe_boolean_value(row, "is_seasonal", None) if is_seasonal_from_csv is not None: feed.seasonal = is_seasonal_from_csv diff --git a/api/tests/integration/populate_tests/test_populate.py b/api/tests/integration/populate_tests/test_populate.py index 27bb5e52a..072c51cdf 100644 --- a/api/tests/integration/populate_tests/test_populate.py +++ b/api/tests/integration/populate_tests/test_populate.py @@ -187,20 +187,20 @@ def test_is_seasonal_overwrite(client: TestClient, values): [ { "feed_id": "mdb-702", - "assert_fail_message": "mdb-702 has an empty is_seasonal cell; must stay null, not False.", + "assert_fail_message": "mdb-702 has an empty is_seasonal cell; must default to False.", }, { "feed_id": "mdb-1", - "assert_fail_message": "mdb-1's CSV has no is_seasonal column; must default to null.", + "assert_fail_message": "mdb-1's CSV has no is_seasonal column; must default to False.", }, ], ids=[ - "seasonal_empty_cell_stays_null", - "seasonal_absent_column_stays_null", + "seasonal_empty_cell_defaults_false", + "seasonal_absent_column_defaults_false", ], ) -def test_is_seasonal_defaults_null(client: TestClient, values): - """An empty cell or a missing column must leave seasonal as null (never coerced to False).""" +def test_is_seasonal_defaults_false(client: TestClient, values): + """An empty cell or a missing column must default seasonal to False (never null).""" feed_id = values["feed_id"] response = client.request( @@ -211,8 +211,7 @@ def test_is_seasonal_defaults_null(client: TestClient, values): assert response.status_code == 200 json_response = response.json() - # Robust to either representation: key present with null, or key omitted. - assert json_response.get("seasonal") is None, values["assert_fail_message"] + assert json_response["seasonal"] is False, values["assert_fail_message"] def test_is_feed_reference_overwrite(client: TestClient): diff --git a/api/tests/unittest/models/test_basic_feed_impl.py b/api/tests/unittest/models/test_basic_feed_impl.py index 5f04a5855..25340273a 100644 --- a/api/tests/unittest/models/test_basic_feed_impl.py +++ b/api/tests/unittest/models/test_basic_feed_impl.py @@ -149,12 +149,14 @@ def test_from_orm_empty_fields(self): # Test all None values # No error should be raised # Resulting list must be empty and not None - empty_feed_orm = Feed() + # seasonal is a NOT NULL DEFAULT FALSE column, so a persisted feed always carries a boolean. + empty_feed_orm = Feed(seasonal=False) expected_empty_feed = FeedImpl( external_ids=[], redirects=[], source_info=SourceInfo(), related_links=[], + seasonal=False, ) empty_result = FeedImpl.from_orm(empty_feed_orm) assert empty_result == expected_empty_feed diff --git a/docs/DatabaseCatalogAPI.yaml b/docs/DatabaseCatalogAPI.yaml index cec544158..ce6ff72e4 100644 --- a/docs/DatabaseCatalogAPI.yaml +++ b/docs/DatabaseCatalogAPI.yaml @@ -626,12 +626,10 @@ components: description: > Indicates whether the feed is seasonal, i.e. it only provides service during recurring periods of the year (for example a summer-only or winter-only service). Seasonal feeds - are excluded from the rolling 7-day service coverage checks. - * true - The feed is seasonal. - * false - The feed is not seasonal. - * null (default) - There is not enough information to determine whether the feed is seasonal. + are excluded from the rolling 7-day service coverage checks. Defaults to false when the + feed has not been marked as seasonal. type: boolean - nullable: true + default: false example: true feed_name: description: > @@ -930,12 +928,10 @@ components: description: > Indicates whether the feed is seasonal, i.e. it only provides service during recurring periods of the year (for example a summer-only or winter-only service). Seasonal feeds - are excluded from the rolling 7-day service coverage checks. - * true - The feed is seasonal. - * false - The feed is not seasonal. - * null (default) - There is not enough information to determine whether the feed is seasonal. + are excluded from the rolling 7-day service coverage checks. Defaults to false when the + feed has not been marked as seasonal. type: boolean - nullable: true + default: false example: true external_ids: $ref: "#/components/schemas/ExternalIds" diff --git a/docs/OperationsAPI.yaml b/docs/OperationsAPI.yaml index baf169d04..dfe3152c1 100644 --- a/docs/OperationsAPI.yaml +++ b/docs/OperationsAPI.yaml @@ -744,12 +744,10 @@ components: description: > Indicates whether the feed is seasonal, i.e. it only provides service during recurring periods of the year (for example a summer-only or winter-only service). Seasonal feeds - are excluded from the rolling 7-day service coverage checks. - * true - The feed is seasonal. - * false - The feed is not seasonal. - * null (default) - There is not enough information to determine whether the feed is seasonal. + are excluded from the rolling 7-day service coverage checks. Defaults to false when the + feed has not been marked as seasonal. type: boolean - nullable: true + default: false example: true feed_name: description: > @@ -1061,12 +1059,10 @@ components: description: > Indicates whether the feed is seasonal, i.e. it only provides service during recurring periods of the year (for example a summer-only or winter-only service). Seasonal feeds - are excluded from the rolling 7-day service coverage checks. - * true - The feed is seasonal. - * false - The feed is not seasonal. - * null (default) - There is not enough information to determine whether the feed is seasonal. + are excluded from the rolling 7-day service coverage checks. Defaults to false when the + feed has not been marked as seasonal. type: boolean - nullable: true + default: false example: true external_ids: $ref: "#/components/schemas/ExternalIds" @@ -1907,10 +1903,10 @@ components: description: Whether this is an official feed. seasonal: type: boolean - nullable: true + default: false description: > Whether the feed is seasonal, i.e. it only provides service during recurring periods of - the year (for example a summer-only or winter-only service). Null when unknown. + the year (for example a summer-only or winter-only service). Defaults to false. locations: $ref: "#/components/schemas/Locations" related_links: @@ -1974,10 +1970,10 @@ components: description: Whether this is an official feed. seasonal: type: boolean - nullable: true + default: false description: > Whether the feed is seasonal, i.e. it only provides service during recurring periods of - the year (for example a summer-only or winter-only service). Null when unknown. + the year (for example a summer-only or winter-only service). Defaults to false. entity_types: type: array minItems: 1 @@ -2135,10 +2131,10 @@ components: description: Whether this is an official feed. seasonal: type: boolean - nullable: true + default: false description: > Whether the feed is seasonal, i.e. it only provides service during recurring periods of - the year (for example a summer-only or winter-only service). Null when unknown. + the year (for example a summer-only or winter-only service). Defaults to false. propagate_license: type: boolean default: false @@ -2196,10 +2192,10 @@ components: description: Whether this is an official feed. seasonal: type: boolean - nullable: true + default: false description: > Whether the feed is seasonal, i.e. it only provides service during recurring periods of - the year (for example a summer-only or winter-only service). Null when unknown. + the year (for example a summer-only or winter-only service). Defaults to false. propagate_license: type: boolean default: false diff --git a/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_feed_impl.py b/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_feed_impl.py index 6a5a34c74..4c05bd22f 100644 --- a/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_feed_impl.py +++ b/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_feed_impl.py @@ -90,7 +90,6 @@ def to_orm( entity.note = update_request.note entity.feed_contact_email = update_request.feed_contact_email entity.official = update_request.official - # Nullable three-state boolean set at the top level of the feed (preserve False and None/unknown). entity.seasonal = update_request.seasonal entity.producer_url = ( None diff --git a/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_rt_feed_impl.py b/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_rt_feed_impl.py index a64d925af..ebf7a5873 100644 --- a/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_rt_feed_impl.py +++ b/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_rt_feed_impl.py @@ -102,7 +102,6 @@ def to_orm( entity.note = update_request.note entity.feed_contact_email = update_request.feed_contact_email entity.official = update_request.official - # Nullable three-state boolean set at the top level of the feed (preserve False and None/unknown). entity.seasonal = update_request.seasonal entity.producer_url = ( None diff --git a/functions-python/operations_api/tests/feeds_operations/impl/models/test_update_request_gtfs_feed_impl.py b/functions-python/operations_api/tests/feeds_operations/impl/models/test_update_request_gtfs_feed_impl.py index d0de1f249..ebc59e883 100644 --- a/functions-python/operations_api/tests/feeds_operations/impl/models/test_update_request_gtfs_feed_impl.py +++ b/functions-python/operations_api/tests/feeds_operations/impl/models/test_update_request_gtfs_feed_impl.py @@ -123,7 +123,7 @@ def test_to_orm_invalid_source_info(): result = UpdateRequestGtfsFeedImpl.to_orm(update_request, entity, session) assert result.producer_url is None assert result.is_producer_url_unstable is None - assert result.seasonal is None + assert result.seasonal is False assert result.authentication_type is None assert result.authentication_info_url is None assert result.api_key_parameter_name is None diff --git a/liquibase/changes/feat_1774.sql b/liquibase/changes/feat_1774.sql index b37d42198..ad47b262d 100644 --- a/liquibase/changes/feat_1774.sql +++ b/liquibase/changes/feat_1774.sql @@ -2,5 +2,5 @@ -- Marks whether a feed is seasonal, i.e. it only provides service during recurring -- periods of the year (e.g. a summer-only or winter-only service). -- Seasonal feeds are excluded from the rolling 7-day service coverage checks. --- Nullable three-state boolean: TRUE = seasonal, FALSE = not seasonal, NULL = unknown (default). -ALTER TABLE feed ADD COLUMN IF NOT EXISTS seasonal BOOLEAN; +-- Boolean flag defaulting to FALSE (a feed is considered non-seasonal unless explicitly marked). +ALTER TABLE feed ADD COLUMN IF NOT EXISTS seasonal BOOLEAN NOT NULL DEFAULT FALSE; From 142cc471207551ceebbf2824fa94b1420c3783c9 Mon Sep 17 00:00:00 2001 From: jcpitre Date: Thu, 30 Jul 2026 16:01:12 -0400 Subject: [PATCH 4/4] Corrected tests. --- api/tests/unittest/models/test_gbfs_feed_impl.py | 2 ++ api/tests/unittest/models/test_gtfs_feed_impl.py | 2 ++ api/tests/unittest/models/test_gtfs_rt_feed_impl.py | 1 + api/tests/unittest/test_feeds.py | 1 + 4 files changed, 6 insertions(+) diff --git a/api/tests/unittest/models/test_gbfs_feed_impl.py b/api/tests/unittest/models/test_gbfs_feed_impl.py index 621de0513..35b7f3d5f 100644 --- a/api/tests/unittest/models/test_gbfs_feed_impl.py +++ b/api/tests/unittest/models/test_gbfs_feed_impl.py @@ -34,6 +34,7 @@ def setUp(self): created_at=datetime(2024, 1, 1, 10, 0, 0), data_type="gbfs", system_id="sys1", + seasonal=False, operator_url="https://provider.com", locations=[self.location_orm], gbfsversions=[self.version_orm], @@ -75,6 +76,7 @@ def test_from_orm_empty_fields(self): id="feed2", stable_id="feed_stable_2", system_id=None, + seasonal=False, operator_url=None, locations=[], gbfsversions=[], diff --git a/api/tests/unittest/models/test_gtfs_feed_impl.py b/api/tests/unittest/models/test_gtfs_feed_impl.py index 336ace53c..98007ecdb 100644 --- a/api/tests/unittest/models/test_gtfs_feed_impl.py +++ b/api/tests/unittest/models/test_gtfs_feed_impl.py @@ -96,6 +96,7 @@ def create_test_notice(notice_code: str, total_notices: int, severity: str): status="active", feed_contact_email="feed_contact_email", provider="provider", + seasonal=False, locations=[ Location( id="id", @@ -212,6 +213,7 @@ def test_from_orm_empty_fields(self): status=None, feed_contact_email=None, provider=None, + seasonal=False, locations=[], externalids=[], latest_dataset=None, diff --git a/api/tests/unittest/models/test_gtfs_rt_feed_impl.py b/api/tests/unittest/models/test_gtfs_rt_feed_impl.py index 09a478e42..62f96d477 100644 --- a/api/tests/unittest/models/test_gtfs_rt_feed_impl.py +++ b/api/tests/unittest/models/test_gtfs_rt_feed_impl.py @@ -27,6 +27,7 @@ stable_id="id", data_type="gtfs_rt", status="active", + seasonal=False, externalids=[ Externalid( associated_id="associated_id", diff --git a/api/tests/unittest/test_feeds.py b/api/tests/unittest/test_feeds.py index 30c62ce9f..7bf88178c 100644 --- a/api/tests/unittest/test_feeds.py +++ b/api/tests/unittest/test_feeds.py @@ -30,6 +30,7 @@ stable_id="test_id", data_type="gtfs", status="active", + seasonal=False, provider="test_provider", feed_name="test_feed_name", created_at=datetime.fromisoformat("2023-07-10T22:06:00+00:00"),