diff --git a/api/src/feeds/impl/feeds_api_impl.py b/api/src/feeds/impl/feeds_api_impl.py
index 998090152..262390db2 100644
--- a/api/src/feeds/impl/feeds_api_impl.py
+++ b/api/src/feeds/impl/feeds_api_impl.py
@@ -1,6 +1,7 @@
from datetime import datetime
from typing import List, Union, TypeVar
+from sqlalchemy import or_
from sqlalchemy import select
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.query import Query
@@ -39,7 +40,6 @@
from feeds_gen.models.gtfs_feed import GtfsFeed
from feeds_gen.models.gtfs_rt_feed import GtfsRTFeed
from middleware.request_context import is_user_email_restricted
-from sqlalchemy import or_
from utils.date_utils import valid_iso_date
from utils.location_translation import (
create_location_translation_object,
@@ -89,12 +89,15 @@ def get_feeds(
status: str,
provider: str,
producer_url: str,
+ is_official: bool,
) -> List[BasicFeed]:
"""Get some (or all) feeds from the Mobility Database."""
feed_filter = FeedFilter(
status=status, provider__ilike=provider, producer_url__ilike=producer_url, stable_id=None
)
feed_query = feed_filter.filter(Database().get_query_model(Feed))
+ if is_official:
+ feed_query = feed_query.filter(Feed.official)
feed_query = feed_query.filter(Feed.data_type != "gbfs") # Filter out GBFS feeds
feed_query = feed_query.filter(
or_(
@@ -221,6 +224,7 @@ def get_gtfs_feeds(
dataset_latitudes: str,
dataset_longitudes: str,
bounding_filter_method: str,
+ is_official: bool,
) -> List[GtfsFeed]:
"""Get some (or all) GTFS feeds from the Mobility Database."""
gtfs_feed_filter = GtfsFeedFilter(
@@ -258,9 +262,10 @@ def get_gtfs_feeds(
*BasicFeedImpl.get_joinedload_options(),
)
.order_by(Gtfsfeed.provider, Gtfsfeed.stable_id)
- .limit(limit)
- .offset(offset)
)
+ if is_official:
+ feed_query = feed_query.filter(Feed.official)
+ feed_query = feed_query.limit(limit).offset(offset)
return self._get_response(feed_query, GtfsFeedImpl)
def get_gtfs_rt_feed(
@@ -311,6 +316,7 @@ def get_gtfs_rt_feeds(
country_code: str,
subdivision_name: str,
municipality: str,
+ is_official: bool,
) -> List[GtfsRTFeed]:
"""Get some (or all) GTFS Realtime feeds from the Mobility Database."""
entity_types_list = entity_types.split(",") if entity_types else None
@@ -359,9 +365,10 @@ def get_gtfs_rt_feeds(
*BasicFeedImpl.get_joinedload_options(),
)
.order_by(Gtfsrealtimefeed.provider, Gtfsrealtimefeed.stable_id)
- .limit(limit)
- .offset(offset)
)
+ if is_official:
+ feed_query = feed_query.filter(Feed.official)
+ feed_query = feed_query.limit(limit).offset(offset)
return self._get_response(feed_query, GtfsRTFeedImpl)
@staticmethod
diff --git a/api/src/feeds/impl/models/basic_feed_impl.py b/api/src/feeds/impl/models/basic_feed_impl.py
index 19bce6662..489d0cd2f 100644
--- a/api/src/feeds/impl/models/basic_feed_impl.py
+++ b/api/src/feeds/impl/models/basic_feed_impl.py
@@ -23,10 +23,14 @@ class Config:
def from_orm(cls, feed: Feed | None, _=None) -> BasicFeed | None:
if not feed:
return None
+ latest_official_status = None
+ if len(feed.officialstatushistories) > 0:
+ latest_official_status = max(feed.officialstatushistories, key=lambda x: x.timestamp).is_official
return cls(
id=feed.stable_id,
data_type=feed.data_type,
status=feed.status,
+ official=latest_official_status,
created_at=feed.created_at,
external_ids=sorted(
[ExternalIdImpl.from_orm(item) for item in feed.externalids], key=lambda x: x.external_id
@@ -48,7 +52,12 @@ def from_orm(cls, feed: Feed | None, _=None) -> BasicFeed | None:
@staticmethod
def get_joinedload_options() -> [_AbstractLoad]:
"""Returns common joinedload options for feeds queries."""
- return [joinedload(Feed.locations), joinedload(Feed.externalids), joinedload(Feed.redirectingids)]
+ return [
+ joinedload(Feed.locations),
+ joinedload(Feed.externalids),
+ joinedload(Feed.redirectingids),
+ joinedload(Feed.officialstatushistories),
+ ]
class BasicFeedImpl(BaseFeedImpl, BasicFeed):
diff --git a/api/src/feeds/impl/models/search_feed_item_result_impl.py b/api/src/feeds/impl/models/search_feed_item_result_impl.py
index d0e062bf9..ab3a98700 100644
--- a/api/src/feeds/impl/models/search_feed_item_result_impl.py
+++ b/api/src/feeds/impl/models/search_feed_item_result_impl.py
@@ -25,6 +25,7 @@ def from_orm_gtfs(cls, feed_search_row):
external_ids=feed_search_row.external_ids,
provider=feed_search_row.provider,
feed_name=feed_search_row.feed_name,
+ official=feed_search_row.official,
note=feed_search_row.note,
feed_contact_email=feed_search_row.feed_contact_email,
source_info=SourceInfo(
@@ -58,6 +59,7 @@ def from_orm_gtfs_rt(cls, feed_search_row):
external_ids=feed_search_row.external_ids,
provider=feed_search_row.provider,
feed_name=feed_search_row.feed_name,
+ official=feed_search_row.official,
note=feed_search_row.note,
feed_contact_email=feed_search_row.feed_contact_email,
source_info=SourceInfo(
diff --git a/api/src/feeds/impl/search_api_impl.py b/api/src/feeds/impl/search_api_impl.py
index e8906b13d..2519868d2 100644
--- a/api/src/feeds/impl/search_api_impl.py
+++ b/api/src/feeds/impl/search_api_impl.py
@@ -31,7 +31,7 @@ def get_parsed_search_tsquery(search_query: str) -> str:
return func.plainto_tsquery("english", unaccent(parsed_query))
@staticmethod
- def add_search_query_filters(query, search_query, data_type, feed_id, status) -> Query:
+ def add_search_query_filters(query, search_query, data_type, feed_id, status, is_official) -> Query:
"""
Add filters to the search query.
Filter values are trimmed and converted to lowercase.
@@ -53,6 +53,8 @@ def add_search_query_filters(query, search_query, data_type, feed_id, status) ->
status_list = [s.strip().lower() for s in status[0].split(",") if s]
if status_list:
query = query.where(t_feedsearch.c.status.in_([s.strip().lower() for s in status_list]))
+ if is_official is not None and is_official:
+ query = query.where(t_feedsearch.c.official == is_official)
if search_query and len(search_query.strip()) > 0:
query = query.filter(
t_feedsearch.c.document.op("@@")(SearchApiImpl.get_parsed_search_tsquery(search_query))
@@ -60,15 +62,23 @@ def add_search_query_filters(query, search_query, data_type, feed_id, status) ->
return query
@staticmethod
- def create_count_search_query(status: List[str], feed_id: str, data_type: str, search_query: str) -> Query:
+ def create_count_search_query(
+ status: List[str],
+ feed_id: str,
+ data_type: str,
+ is_official: bool,
+ search_query: str,
+ ) -> Query:
"""
Create a search query for the database.
"""
query = select(func.count(t_feedsearch.c.feed_id))
- return SearchApiImpl.add_search_query_filters(query, search_query, data_type, feed_id, status)
+ return SearchApiImpl.add_search_query_filters(query, search_query, data_type, feed_id, status, is_official)
@staticmethod
- def create_search_query(status: List[str], feed_id: str, data_type: str, search_query: str) -> Query:
+ def create_search_query(
+ status: List[str], feed_id: str, data_type: str, is_official: bool, search_query: str
+ ) -> Query:
"""
Create a search query for the database.
"""
@@ -80,7 +90,7 @@ def create_search_query(status: List[str], feed_id: str, data_type: str, search_
rank_expression,
*feed_search_columns,
)
- query = SearchApiImpl.add_search_query_filters(query, search_query, data_type, feed_id, status)
+ query = SearchApiImpl.add_search_query_filters(query, search_query, data_type, feed_id, status, is_official)
return query.order_by(rank_expression.desc())
def search_feeds(
@@ -90,17 +100,18 @@ def search_feeds(
status: List[str],
feed_id: str,
data_type: str,
+ is_official: bool,
search_query: str,
) -> SearchFeeds200Response:
"""Search feeds using full-text search on feed, location and provider's information."""
- query = self.create_search_query(status, feed_id, data_type, search_query)
+ query = self.create_search_query(status, feed_id, data_type, is_official, search_query)
feed_rows = Database().select(
query=query,
limit=limit,
offset=offset,
)
feed_total_count = Database().select(
- query=self.create_count_search_query(status, feed_id, data_type, search_query),
+ query=self.create_count_search_query(status, feed_id, data_type, is_official, search_query),
)
if feed_rows is None or feed_total_count is None:
return SearchFeeds200Response(
diff --git a/api/src/scripts/populate_db_test_data.py b/api/src/scripts/populate_db_test_data.py
index c12e0ff0f..5beb7649b 100644
--- a/api/src/scripts/populate_db_test_data.py
+++ b/api/src/scripts/populate_db_test_data.py
@@ -14,6 +14,7 @@
Feature,
t_feedsearch,
Location,
+ Officialstatushistory,
)
from scripts.populate_db import set_up_configs, DatabasePopulateHelper
from utils.logger import Logger
@@ -172,6 +173,14 @@ def populate_test_feeds(self, feeds_data):
)
locations.append(location)
feed.locations = locations
+ if "official" in feed_data:
+ official_status_history = Officialstatushistory(
+ feed_id=feed.id,
+ is_official=feed_data["official"],
+ reviewer_email="dev@test.com",
+ timestamp=feed_data["created_at"],
+ )
+ feed.officialstatushistories.append(official_status_history)
self.db.session.add(feed)
logger.info(f"Added feed {feed.stable_id}")
diff --git a/api/tests/integration/test_data/extra_test_data.json b/api/tests/integration/test_data/extra_test_data.json
index e963c3c6b..68f247782 100644
--- a/api/tests/integration/test_data/extra_test_data.json
+++ b/api/tests/integration/test_data/extra_test_data.json
@@ -6,6 +6,7 @@
"status": "active",
"created_at": "2024-02-08T00:00:00Z",
"provider": "BlaBlaCar Bus",
+ "official": true,
"feed_name": "",
"note": "",
"feed_contact_email": "",
@@ -43,6 +44,7 @@
"status": "active",
"created_at": "2024-02-08T00:00:00Z",
"provider": "BlaBlaCar Bus",
+ "official": false,
"feed_name": "",
"note": "",
"feed_contact_email": "",
@@ -80,6 +82,7 @@
"status": "active",
"created_at": "2024-02-08T00:00:00Z",
"provider": "BlaBlaCar Bus",
+ "official": true,
"feed_name": "",
"note": "",
"feed_contact_email": "",
diff --git a/api/tests/integration/test_database.py b/api/tests/integration/test_database.py
index c819d870a..a30fa595a 100644
--- a/api/tests/integration/test_database.py
+++ b/api/tests/integration/test_database.py
@@ -100,7 +100,7 @@ def test_bounding_box_disjoint(latitudes, longitudes, method, expected_found, te
def test_merge_gtfs_feed(test_database):
results = {
feed.id: feed
- for feed in FeedsApiImpl().get_gtfs_feeds(None, None, None, None, None, None, None, None, None, None)
+ for feed in FeedsApiImpl().get_gtfs_feeds(None, None, None, None, None, None, None, None, None, None, None)
if feed.id in TEST_GTFS_FEED_STABLE_IDS
}
assert len(results) == len(TEST_GTFS_FEED_STABLE_IDS)
diff --git a/api/tests/integration/test_feeds_api.py b/api/tests/integration/test_feeds_api.py
index f762a8f8b..0d2e2a6a6 100644
--- a/api/tests/integration/test_feeds_api.py
+++ b/api/tests/integration/test_feeds_api.py
@@ -246,6 +246,44 @@ def test_feeds_gtfs_rt_id_get(client: TestClient):
assert response.status_code == 200
+@pytest.mark.parametrize(
+ "endpoint",
+ [
+ "/v1/gtfs_feeds",
+ "/v1/gtfs_rt_feeds",
+ "/v1/feeds",
+ ],
+)
+def test_feeds_filter_by_official(client: TestClient, endpoint):
+ # 1 - Test with official=false should return all feeds
+ response_no_filter = client.request(
+ "GET",
+ endpoint,
+ headers=authHeaders,
+ )
+ assert response_no_filter.status_code == 200
+ response_no_filter_json = response_no_filter.json()
+ response_official_false = client.request(
+ "GET",
+ endpoint,
+ headers=authHeaders,
+ params=[("is_official", "false")],
+ )
+ assert response_official_false.status_code == 200
+ response_official_false_json = response_official_false.json()
+ assert response_no_filter_json == response_official_false_json, "official=false parameter should return all feeds"
+ # 2 - Test with official=true should return at least one feed
+ response = client.request(
+ "GET",
+ endpoint,
+ headers=authHeaders,
+ params=[("is_official", "true")],
+ )
+ assert response.status_code == 200
+ json_response = response.json()
+ assert len(json_response) < len(response_no_filter_json), "Not all feeds are official"
+
+
def test_non_existent_gtfs_rt_feed_get(client: TestClient):
"""Test case for feeds_gtfs_rt_id_get with a non-existent feed"""
response = client.request(
diff --git a/api/tests/integration/test_search_api.py b/api/tests/integration/test_search_api.py
index 93f2fd15a..7148820f4 100644
--- a/api/tests/integration/test_search_api.py
+++ b/api/tests/integration/test_search_api.py
@@ -388,3 +388,26 @@ def test_search_feeds_filter_accents(client: TestClient, values: dict):
assert len(response_body.results) == len(values["expected_ids"])
assert response_body.total == len(values["expected_ids"])
assert all(result.id in values["expected_ids"] for result in response_body.results)
+
+
+def test_search_filter_by_official_status(client: TestClient):
+ """
+ Retrieve feeds with the official status.
+ """
+ params = [
+ ("limit", 100),
+ ("offset", 0),
+ ("is_official", "true"),
+ ]
+ headers = {
+ "Authentication": "special-key",
+ }
+ response = client.request(
+ "GET",
+ "/v1/search",
+ headers=headers,
+ params=params,
+ )
+ # Parse the response body into a Python object
+ response_body = SearchFeeds200Response.parse_obj(response.json())
+ assert response_body.total == 2, "There should be 2 official feeds in extra_test_data.json"
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 54ef891ce..db699ddae 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
@@ -23,6 +23,7 @@ def __init__(self, **kwargs):
data_type="gtfs",
status="active",
feed_name="feed_name",
+ official=None,
note="note",
feed_contact_email="feed_contact_email",
producer_url="producer_url",
diff --git a/docs/DatabaseCatalogAPI.yaml b/docs/DatabaseCatalogAPI.yaml
index 467c7dfd5..1ddf2c731 100644
--- a/docs/DatabaseCatalogAPI.yaml
+++ b/docs/DatabaseCatalogAPI.yaml
@@ -47,6 +47,7 @@ paths:
- $ref: "#/components/parameters/status"
- $ref: "#/components/parameters/provider"
- $ref: "#/components/parameters/producer_url"
+ - $ref: "#/components/parameters/is_official_query_param"
security:
- Authentication: [ ]
@@ -97,6 +98,7 @@ paths:
- $ref: "#/components/parameters/dataset_latitudes"
- $ref: "#/components/parameters/dataset_longitudes"
- $ref: "#/components/parameters/bounding_filter_method"
+ - $ref: "#/components/parameters/is_official_query_param"
security:
- Authentication: []
@@ -123,6 +125,7 @@ paths:
- $ref: "#/components/parameters/country_code"
- $ref: "#/components/parameters/subdivision_name"
- $ref: "#/components/parameters/municipality"
+ - $ref: "#/components/parameters/is_official_query_param"
security:
- Authentication: []
responses:
@@ -279,6 +282,7 @@ paths:
- $ref: "#/components/parameters/statuses"
- $ref: "#/components/parameters/feed_id_query_param"
- $ref: "#/components/parameters/data_type_query_param"
+ - $ref: "#/components/parameters/is_official_query_param"
- $ref: "#/components/parameters/search_text_query_param"
security:
- Authentication: []
@@ -352,6 +356,12 @@ components:
type: string
example: 2023-07-10T22:06:00Z
format: date-time
+ official:
+ description: >
+ 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
external_ids:
$ref: "#/components/schemas/ExternalIds"
provider:
@@ -463,6 +473,12 @@ components:
type: string
example: 2023-07-10T22:06:00Z
format: date-time
+ official:
+ description: >
+ 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
external_ids:
$ref: "#/components/schemas/ExternalIds"
provider:
@@ -993,6 +1009,15 @@ components:
type: boolean
default: false
+ is_official_query_param:
+ name: is_official
+ in: query
+ description: If true, only return official feeds.
+ required: False
+ schema:
+ type: boolean
+ default: false
+
limit_query_param:
name: limit
in: query
diff --git a/liquibase/changelog.xml b/liquibase/changelog.xml
index 5c86b287c..5b6442927 100644
--- a/liquibase/changelog.xml
+++ b/liquibase/changelog.xml
@@ -31,4 +31,6 @@
+
+
\ No newline at end of file
diff --git a/liquibase/changes/feat_794.sql b/liquibase/changes/feat_794.sql
new file mode 100644
index 000000000..37dfb135e
--- /dev/null
+++ b/liquibase/changes/feat_794.sql
@@ -0,0 +1,173 @@
+-- Dropping the materialized view if it exists as we cannot update it
+DROP MATERIALIZED VIEW IF EXISTS FeedSearch;
+
+CREATE MATERIALIZED VIEW FeedSearch AS
+SELECT
+ -- feed
+ Feed.stable_id AS feed_stable_id,
+ Feed.id AS feed_id,
+ Feed.data_type,
+ Feed.status,
+ Feed.feed_name,
+ Feed.note,
+ Feed.feed_contact_email,
+ -- source
+ Feed.producer_url,
+ Feed.authentication_info_url,
+ Feed.authentication_type,
+ Feed.api_key_parameter_name,
+ Feed.license_url,
+ Feed.provider,
+ Feed.operational_status,
+ -- official status
+ Latest_official_status.is_official AS official,
+ -- latest_dataset
+ Latest_dataset.id AS latest_dataset_id,
+ Latest_dataset.hosted_url AS latest_dataset_hosted_url,
+ Latest_dataset.downloaded_at AS latest_dataset_downloaded_at,
+ Latest_dataset.bounding_box AS latest_dataset_bounding_box,
+ Latest_dataset.hash AS latest_dataset_hash,
+ -- external_ids
+ ExternalIdJoin.external_ids,
+ -- redirect_ids
+ RedirectingIdJoin.redirect_ids,
+ -- feed gtfs_rt references
+ FeedReferenceJoin.feed_reference_ids,
+ -- feed gtfs_rt entities
+ EntityTypeFeedJoin.entities,
+ -- locations
+ FeedLocationJoin.locations,
+ -- translations
+ FeedCountryTranslationJoin.translations AS country_translations,
+ FeedSubdivisionNameTranslationJoin.translations AS subdivision_name_translations,
+ FeedMunicipalityTranslationJoin.translations AS municipality_translations,
+ -- full-text searchable document
+ setweight(to_tsvector('english', coalesce(unaccent(Feed.feed_name), '')), 'C') ||
+ setweight(to_tsvector('english', coalesce(unaccent(Feed.provider), '')), 'C') ||
+ setweight(to_tsvector('english', coalesce(unaccent((
+ SELECT string_agg(
+ coalesce(location->>'country_code', '') || ' ' ||
+ coalesce(location->>'country', '') || ' ' ||
+ coalesce(location->>'subdivision_name', '') || ' ' ||
+ coalesce(location->>'municipality', ''),
+ ' '
+ )
+ FROM json_array_elements(FeedLocationJoin.locations) AS location
+ )), '')), 'A') ||
+ setweight(to_tsvector('english', coalesce(unaccent((
+ SELECT string_agg(
+ coalesce(translation->>'value', ''),
+ ' '
+ )
+ FROM json_array_elements(FeedCountryTranslationJoin.translations) AS translation
+ )), '')), 'A') ||
+ setweight(to_tsvector('english', coalesce(unaccent((
+ SELECT string_agg(
+ coalesce(translation->>'value', ''),
+ ' '
+ )
+ FROM json_array_elements(FeedSubdivisionNameTranslationJoin.translations) AS translation
+ )), '')), 'A') ||
+ setweight(to_tsvector('english', coalesce(unaccent((
+ SELECT string_agg(
+ coalesce(translation->>'value', ''),
+ ' '
+ )
+ FROM json_array_elements(FeedMunicipalityTranslationJoin.translations) AS translation
+ )), '')), 'A') AS document
+FROM Feed
+LEFT JOIN (
+ SELECT *
+ FROM gtfsdataset
+ WHERE latest = true
+) AS Latest_dataset ON Latest_dataset.feed_id = Feed.id AND Feed.data_type = 'gtfs'
+LEFT JOIN (
+ SELECT
+ feed_id,
+ json_agg(json_build_object('external_id', associated_id, 'source', source)) AS external_ids
+ FROM externalid
+ GROUP BY feed_id
+) AS ExternalIdJoin ON ExternalIdJoin.feed_id = Feed.id
+LEFT JOIN (
+ SELECT
+ gtfs_rt_feed_id,
+ array_agg(FeedReferenceJoinInnerQuery.stable_id) AS feed_reference_ids
+ FROM FeedReference
+ LEFT JOIN Feed AS FeedReferenceJoinInnerQuery ON FeedReferenceJoinInnerQuery.id = FeedReference.gtfs_feed_id
+ GROUP BY gtfs_rt_feed_id
+) AS FeedReferenceJoin ON FeedReferenceJoin.gtfs_rt_feed_id = Feed.id AND Feed.data_type = 'gtfs_rt'
+LEFT JOIN (
+ SELECT
+ target_id,
+ json_agg(json_build_object('target_id', target_id, 'comment', redirect_comment)) AS redirect_ids
+ FROM RedirectingId
+ GROUP BY target_id
+) AS RedirectingIdJoin ON RedirectingIdJoin.target_id = Feed.id
+LEFT JOIN (
+ SELECT
+ LocationFeed.feed_id,
+ json_agg(json_build_object('country', country, 'country_code', country_code, 'subdivision_name',
+ subdivision_name, 'municipality', municipality)) AS locations
+ FROM Location
+ LEFT JOIN LocationFeed ON LocationFeed.location_id = Location.id
+ GROUP BY LocationFeed.feed_id
+) AS FeedLocationJoin ON FeedLocationJoin.feed_id = Feed.id
+LEFT JOIN (
+ SELECT DISTINCT ON (feed_id) *
+ FROM officialstatushistory
+ ORDER BY feed_id, timestamp DESC
+) AS Latest_official_status ON Latest_official_status.feed_id = Feed.id
+LEFT JOIN (
+ SELECT
+ LocationFeed.feed_id,
+ json_agg(json_build_object('value', Translation.value, 'key', Translation.key)) AS translations
+ FROM Location
+ LEFT JOIN Translation ON Location.country = Translation.key
+ LEFT JOIN LocationFeed ON LocationFeed.location_id = Location.id
+ WHERE Translation.language_code = 'en'
+ AND Translation.type = 'country'
+ AND Location.country IS NOT NULL
+ GROUP BY LocationFeed.feed_id
+) AS FeedCountryTranslationJoin ON FeedCountryTranslationJoin.feed_id = Feed.id
+LEFT JOIN (
+ SELECT
+ LocationFeed.feed_id,
+ json_agg(json_build_object('value', Translation.value, 'key', Translation.key)) AS translations
+ FROM Location
+ LEFT JOIN Translation ON Location.subdivision_name = Translation.key
+ LEFT JOIN LocationFeed ON LocationFeed.location_id = Location.id
+ WHERE Translation.language_code = 'en'
+ AND Translation.type = 'subdivision_name'
+ AND Location.subdivision_name IS NOT NULL
+ GROUP BY LocationFeed.feed_id
+) AS FeedSubdivisionNameTranslationJoin ON FeedSubdivisionNameTranslationJoin.feed_id = Feed.id
+LEFT JOIN (
+ SELECT
+ LocationFeed.feed_id,
+ json_agg(json_build_object('value', Translation.value, 'key', Translation.key)) AS translations
+ FROM Location
+ LEFT JOIN Translation ON Location.municipality = Translation.key
+ LEFT JOIN LocationFeed ON LocationFeed.location_id = Location.id
+ WHERE Translation.language_code = 'en'
+ AND Translation.type = 'municipality'
+ AND Location.municipality IS NOT NULL
+ GROUP BY LocationFeed.feed_id
+) AS FeedMunicipalityTranslationJoin ON FeedMunicipalityTranslationJoin.feed_id = Feed.id
+LEFT JOIN (
+ SELECT
+ feed_id,
+ array_agg(entity_name) AS entities
+ FROM EntityTypeFeed
+ GROUP BY feed_id
+) AS EntityTypeFeedJoin ON EntityTypeFeedJoin.feed_id = Feed.id AND Feed.data_type = 'gtfs_rt'
+;
+
+
+-- This index allows concurrent refresh on the materialized view avoiding table locks
+CREATE UNIQUE INDEX idx_unique_feed_id ON FeedSearch(feed_id);
+
+-- Indices for feedsearch view optimization
+CREATE INDEX feedsearch_document_idx ON FeedSearch USING GIN(document);
+CREATE INDEX feedsearch_feed_stable_id ON FeedSearch(feed_stable_id);
+CREATE INDEX feedsearch_data_type ON FeedSearch(data_type);
+CREATE INDEX feedsearch_status ON FeedSearch(status);
diff --git a/liquibase/changes/feat_794_2.sql b/liquibase/changes/feat_794_2.sql
new file mode 100644
index 000000000..a217d4e8f
--- /dev/null
+++ b/liquibase/changes/feat_794_2.sql
@@ -0,0 +1 @@
+ALTER TABLE Feed ADD COLUMN official BOOLEAN DEFAULT NULL;
\ No newline at end of file
diff --git a/web-app/src/app/services/feeds/types.ts b/web-app/src/app/services/feeds/types.ts
index ddfd98b28..6961f0adc 100644
--- a/web-app/src/app/services/feeds/types.ts
+++ b/web-app/src/app/services/feeds/types.ts
@@ -138,6 +138,12 @@ export interface components {
* @example "2023-07-10T22:06:00.000Z"
*/
created_at?: string;
+ /**
+ * @description A boolean value indicating if the feed is official or not. Official feeds are provided by the transit agency or a trusted source.
+ *
+ * @example true
+ */
+ official?: boolean;
external_ids?: components['schemas']['ExternalIds'];
/**
* @description A commonly used name for the transit provider included in the feed.
@@ -202,6 +208,12 @@ export interface components {
* @example "2023-07-10T22:06:00.000Z"
*/
created_at?: string;
+ /**
+ * @description A boolean value indicating if the feed is official or not. Official feeds are provided by the transit agency or a trusted source.
+ *
+ * @example true
+ */
+ official?: boolean;
external_ids?: components['schemas']['ExternalIds'];
/**
* @description A commonly used name for the transit provider included in the feed.
@@ -495,6 +507,8 @@ export interface components {
| 'disjoint';
/** @description If true, only return the latest dataset. */
latest_query_param?: boolean;
+ /** @description If true, only return official feeds. */
+ is_official_query_param?: boolean;
/** @description The number of items to be returned. */
limit_query_param?: number;
/** @description Offset of the first item to return. */
@@ -545,6 +559,7 @@ export interface operations {
status?: components['parameters']['status'];
provider?: components['parameters']['provider'];
producer_url?: components['parameters']['producer_url'];
+ is_official?: components['parameters']['is_official_query_param'];
};
};
responses: {
@@ -586,6 +601,7 @@ export interface operations {
dataset_latitudes?: components['parameters']['dataset_latitudes'];
dataset_longitudes?: components['parameters']['dataset_longitudes'];
bounding_filter_method?: components['parameters']['bounding_filter_method'];
+ is_official?: components['parameters']['is_official_query_param'];
};
};
responses: {
@@ -609,6 +625,7 @@ export interface operations {
country_code?: components['parameters']['country_code'];
subdivision_name?: components['parameters']['subdivision_name'];
municipality?: components['parameters']['municipality'];
+ is_official?: components['parameters']['is_official_query_param'];
};
};
responses: {
@@ -746,6 +763,7 @@ export interface operations {
status?: components['parameters']['statuses'];
feed_id?: components['parameters']['feed_id_query_param'];
data_type?: components['parameters']['data_type_query_param'];
+ is_official?: components['parameters']['is_official_query_param'];
search_query?: components['parameters']['search_text_query_param'];
};
};