diff --git a/api/src/shared/db_models/feed_impl.py b/api/src/shared/db_models/feed_impl.py
index bfdd67a4b..26c613965 100644
--- a/api/src/shared/db_models/feed_impl.py
+++ b/api/src/shared/db_models/feed_impl.py
@@ -1,6 +1,11 @@
+import logging
+
+from sqlalchemy.orm import Session
+
+from shared.database.database import with_db_session
from shared.db_models.basic_feed_impl import BaseFeedImpl
from feeds_gen.models.feed import Feed
-from shared.database_gen.sqlacodegen_models import Feed as FeedOrm
+from shared.database_gen.sqlacodegen_models import Feed as FeedOrm, License
from shared.db_models.external_id_impl import ExternalIdImpl
from shared.db_models.feed_related_link_impl import FeedRelatedLinkImpl
@@ -33,10 +38,18 @@ def from_orm(cls, feed_orm: FeedOrm | None) -> Feed | None:
return feed
@classmethod
- def to_orm_from_dict(cls, feed_dict: dict | None) -> FeedOrm | None:
+ @with_db_session
+ def to_orm_from_dict(cls, feed_dict: dict | None, db_session: Session | None = None) -> FeedOrm | None:
"""Convert a dictionary representation of a feed to a SQLAlchemy Feed ORM object."""
if not feed_dict:
return None
+ license_id = None
+ if feed_dict.get("license_id") is not None:
+ license_orm = db_session.query(License).get(feed_dict["license_id"])
+ if not license_orm:
+ logging.warning("License with id %s not found.", feed_dict["license_id"])
+ license_id = license_orm.id if license_orm else None
+
result: Feed = FeedOrm(
id=feed_dict.get("id"),
stable_id=feed_dict.get("stable_id"),
@@ -51,6 +64,8 @@ def to_orm_from_dict(cls, feed_dict: dict | None) -> FeedOrm | None:
authentication_info_url=feed_dict.get("authentication_info_url"),
api_key_parameter_name=feed_dict.get("api_key_parameter_name"),
license_url=feed_dict.get("license_url"),
+ license_id=license_id,
+ license_notes=feed_dict.get("license_notes"),
status=feed_dict.get("status"),
official=feed_dict.get("official"),
official_updated_at=feed_dict.get("official_updated_at"),
diff --git a/docs/OperationsAPI.yaml b/docs/OperationsAPI.yaml
index e77ade116..e6190a40c 100644
--- a/docs/OperationsAPI.yaml
+++ b/docs/OperationsAPI.yaml
@@ -17,6 +17,8 @@ info:
tags:
- name: "operations"
description: "Mobility Database Operations"
+ - name: "licenses"
+ description: "Licenses of the Mobility Database"
paths:
/v1/operations/feeds:
get:
@@ -226,7 +228,7 @@ paths:
A feed with the producer_url already exists.
500:
- description: "An internal server error occurred. \n"
+ description: "An internal server error occurred. \n"
put:
description: Update the specified GTFS-RT feed in the Mobility Database.
tags:
@@ -262,6 +264,45 @@ paths:
description: >
An internal server error occurred.
+ /v1/operations/licenses:
+ get:
+ description: Get the list of all licenses in the DB.
+ tags:
+ - "licenses"
+ operationId: getLicenses
+ parameters:
+ - $ref: "#/components/parameters/limit_query_param_licenses_endpoint"
+ - $ref: "#/components/parameters/offset"
+ - $ref: "#/components/parameters/search_text_query_param_license"
+ - $ref: "#/components/parameters/license_is_spdx_query_param"
+ security:
+ - Authentication: []
+ responses:
+ 200:
+ description: Successful pull of the licenses info.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Licenses"
+ /v1/operations/licenses/{id}:
+ parameters:
+ - $ref: "#/components/parameters/license_id_path_param"
+ get:
+ description: Get the specified license from the Mobility Database, including the license rules.
+ tags:
+ - "licenses"
+ operationId: getLicense
+ security:
+ - Authentication: []
+ responses:
+ 200:
+ description: >
+ Successful pull of the license info for the provided ID.
+
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LicenseWithRules"
components:
schemas:
Redirect:
@@ -289,8 +330,8 @@ components:
- gtfs_rt
- gbfs
example: gtfs
- # Have to put the enum inline because of a bug in openapi-generator
- # $ref: "#/components/schemas/DataType"
+ # Have to put the enum inline because of a bug in openapi-generator
+ # $ref: "#/components/schemas/DataType"
created_at:
description: The date and time the feed was added to the database, in ISO 8601 date-time format.
type: string
@@ -319,8 +360,8 @@ components:
discriminator:
propertyName: data_type
mapping:
- gtfs: '#/components/schemas/GtfsFeed'
- gtfs_rt: '#/components/schemas/GtfsRTFeed'
+ gtfs: "#/components/schemas/GtfsFeed"
+ gtfs_rt: "#/components/schemas/GtfsRTFeed"
properties:
status:
description: >
@@ -367,7 +408,7 @@ components:
type: array
items:
- $ref: '#/components/schemas/FeedRelatedLink'
+ $ref: "#/components/schemas/FeedRelatedLink"
FeedRelatedLink:
type: object
properties:
@@ -595,8 +636,8 @@ components:
* vp - vehicle positions
* tu - trip updates
* sa - service alerts
- # Have to put the enum inline because of a bug in openapi-generator
- # $ref: "#/components/schemas/EntityTypes"
+ # Have to put the enum inline because of a bug in openapi-generator
+ # $ref: "#/components/schemas/EntityTypes"
feed_references:
description: A list of the GTFS feeds that the real time source is associated with, represented by their MDB source IDs.
type: array
@@ -627,8 +668,8 @@ components:
- gtfs_rt
- gbfs
example: gtfs
- # Have to put the enum inline because of a bug in openapi-generator
- # $ref: "#/components/schemas/DataType"
+ # Have to put the enum inline because of a bug in openapi-generator
+ # $ref: "#/components/schemas/DataType"
status:
description: >
Describes status of the Feed. Should be one of
@@ -646,8 +687,8 @@ components:
- development
- future
example: deprecated
- # Have to put the enum inline because of a bug in openapi-generator
- # $ref: "#/components/schemas/FeedStatus"
+ # Have to put the enum inline because of a bug in openapi-generator
+ # $ref: "#/components/schemas/FeedStatus"
created_at:
description: The date and time the feed was added to the database, in ISO 8601 date-time format.
type: string
@@ -703,8 +744,8 @@ components:
* vp - vehicle positions
* tu - trip updates
* sa - service alerts
- # Have to put the enum inline because of a bug in openapi-generator
- # $ref: "#/components/schemas/EntityTypes"
+ # Have to put the enum inline because of a bug in openapi-generator
+ # $ref: "#/components/schemas/EntityTypes"
versions:
type: array
items:
@@ -809,24 +850,24 @@ components:
type: integer
example: 3
minimum: 0
- # Have to put the enum inline because of a bug in openapi-generator
- # EntityTypes:
- # type: array
- # items:
- # $ref: "#/components/schemas/EntityType"
-
- # EntityType:
- # type: string
- # enum:
- # - vp
- # - tu
- # - sa
- # example: vp
- # description: >
- # The type of realtime entry:
- # * vp - vehicle positions
- # * tu - trip updates
- # * sa - service alerts
+ # Have to put the enum inline because of a bug in openapi-generator
+ # EntityTypes:
+ # type: array
+ # items:
+ # $ref: "#/components/schemas/EntityType"
+
+ # EntityType:
+ # type: string
+ # enum:
+ # - vp
+ # - tu
+ # - sa
+ # example: vp
+ # description: >
+ # The type of realtime entry:
+ # * vp - vehicle positions
+ # * tu - trip updates
+ # * sa - service alerts
ExternalIds:
type: array
items:
@@ -885,6 +926,18 @@ components:
type: string
format: url
example: https://www.ladottransit.com/dla.html
+ license_id:
+ description: Id of the feed license that can be used to query the license endpoint.
+ type: string
+ example: 0BSD
+ license_is_spdx:
+ description: true if the license is SPDX. false if not.
+ type: boolean
+ example: true
+ license_notes:
+ description: Notes concerning the relation between the feed and the license.
+ type: string
+ example: Detected locale/jurisdiction port 'nl'. SPDX does not list ported CC licenses; using canonical ID.
Locations:
type: array
items:
@@ -912,23 +965,23 @@ components:
description: Primary municipality in english in which the transit system is located.
type: string
example: Los Angeles
- # Have to put the enum inline because of a bug in openapi-generator
- # FeedStatus:
- # description: >
- # Describes status of the Feed. Should be one of
- # * `active` Feed should be used in public trip planners.
- # * `deprecated` Feed is explicitly deprecated and should not be used in public trip planners.
- # * `inactive` Feed hasn't been recently updated and should be used at risk of providing outdated information.
- # * `development` Feed is being used for development purposes and should not be used in public trip planners.
- # * `future` Feed is not yet active but will be in the future
- # type: string
- # enum:
- # - active
- # - deprecated
- # - inactive
- # - development
- # - future
- # example: active
+ # Have to put the enum inline because of a bug in openapi-generator
+ # FeedStatus:
+ # description: >
+ # Describes status of the Feed. Should be one of
+ # * `active` Feed should be used in public trip planners.
+ # * `deprecated` Feed is explicitly deprecated and should not be used in public trip planners.
+ # * `inactive` Feed hasn't been recently updated and should be used at risk of providing outdated information.
+ # * `development` Feed is being used for development purposes and should not be used in public trip planners.
+ # * `future` Feed is not yet active but will be in the future
+ # type: string
+ # enum:
+ # - active
+ # - deprecated
+ # - inactive
+ # - development
+ # - future
+ # example: active
BasicDataset:
type: object
properties:
@@ -1072,6 +1125,78 @@ components:
format: url
description: HTML validation report URL
example: https://storage.googleapis.com/mobilitydata-datasets-dev/mdb-10/mdb-10-202312181718/mdb-10-202312181718-report-4_2_0.html
+ LicenseRule:
+ type: object
+ properties:
+ name:
+ description: Name of the rule.
+ type: string
+ example: commercial-use
+ label:
+ description: Label of the rule.
+ type: string
+ example: Commercial use
+ description:
+ description: Description of the rule.
+ type: string
+ example: This license allows the software or data to be used for commercial purposes.
+ type:
+ description: Type of rule.
+ type: string
+ enum:
+ - permission
+ - condition
+ - limitation
+ LicenseBase:
+ type: object
+ properties:
+ id:
+ description: Unique identifier for the license.
+ type: string
+ example: 0BSD
+ type:
+ type: string
+ description: The type of license.
+ example: standard
+ is_spdx:
+ type: boolean
+ description: True if the license ID is an SPDX identifier.
+ name:
+ type: string
+ description: The user facing name of the license.
+ example: BSD Zero Clause License
+ url:
+ description: A URL where to find the license for the feed.
+ type: string
+ format: url
+ example: https://www.ladottransit.com/dla.html
+ description:
+ type: string
+ description: The description of the license.
+ example: This is the OBSD license.
+ created_at:
+ description: The date and time the license was added to the database, in ISO 8601 date-time format.
+ type: string
+ example: 2023-07-10T22:06:00Z
+ format: date-time
+ updated_at:
+ description: The last date and time the license was updated in the database, in ISO 8601 date-time format.
+ type: string
+ example: 2023-07-10T22:06:00Z
+ format: date-time
+ LicenseWithRules:
+ allOf:
+ - $ref: "#/components/schemas/LicenseBase"
+ - type: object
+ properties:
+ license_rules:
+ type: array
+ items:
+ $ref: "#/components/schemas/LicenseRule"
+ Licenses:
+ type: array
+ items:
+ $ref: "#/components/schemas/LicenseBase"
OperationCreateRequestGtfsFeed:
x-operation: true
type: object
@@ -1142,6 +1267,7 @@ components:
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.
+
type: string
example: Bus
note:
@@ -1181,6 +1307,8 @@ components:
example: vp
description: >
The type of realtime entry:
+
+
* vp - vehicle positions
* tu - trip updates
* sa - service alerts
@@ -1195,13 +1323,14 @@ components:
related_links:
description: >
A list of related links for the feed.
+
type: array
items:
- $ref: '#/components/schemas/FeedRelatedLink'
+ $ref: '#/components/schemas/FeedRelatedLink'
required:
- source_info
- operational_status
- - entity_types
+ - entity_types
OperationFeed:
x-operation: true
allOf:
@@ -1298,6 +1427,8 @@ components:
example: vp
description: >
The type of realtime entry:
+
+
* vp - vehicle positions
* tu - trip updates
* sa - service alerts
@@ -1373,6 +1504,8 @@ components:
x-operation: true
description: >
Describes status of the Feed. Should be one of
+
+
* `active` Feed should be used in public trip planners.
* `deprecated` Feed is explicitly deprecated and should not be used in public trip planners.
* `inactive` Feed hasn't been recently updated and should be used at risk of providing outdated information.
@@ -1390,6 +1523,8 @@ components:
x-operation: true
description: >
Describes data type of a feed. Should be one of
+
+
* `gtfs` GTFS feed.
* `gtfs_rt` GTFS-RT feed.
* `gbfs` GBFS feed.
@@ -1409,6 +1544,55 @@ components:
schema:
type: string
example: mdb-1210
+ license_id_path_param:
+ x-operation: true
+ name: id
+ in: path
+ description: The license ID of the requested license.
+ required: True
+ schema:
+ type: string
+ example: 0BSD
+ license_is_spdx_query_param:
+ x-operation: true
+ name: is_spdx
+ in: query
+ description: True if the license ID is SPDX.
+ required: False
+ schema:
+ type: boolean
+ example: true
+ limit_query_param_licenses_endpoint:
+ x-operation: true
+ name: limit
+ in: query
+ description: The number of items to be returned.
+ required: False
+ schema:
+ type: integer
+ minimum: 0
+ maximum: 100
+ default: 100
+ example: 10
+ search_text_query_param_license:
+ x-operation: true
+ name: search_query
+ in: query
+ description: General search query to match against license name and ID
+ required: False
+ schema:
+ type: string
+ offset:
+ x-operation: true
+ name: offset
+ in: query
+ description: Offset of the first item to return.
+ required: False
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ example: 0
securitySchemes:
ApiKeyAuth:
type: apiKey
diff --git a/functions-python/operations_api/.openapi-generator/FILES b/functions-python/operations_api/.openapi-generator/FILES
index b785b0b1f..c0587d7b0 100644
--- a/functions-python/operations_api/.openapi-generator/FILES
+++ b/functions-python/operations_api/.openapi-generator/FILES
@@ -1,4 +1,6 @@
src/feeds_gen/apis/__init__.py
+src/feeds_gen/apis/licenses_api.py
+src/feeds_gen/apis/licenses_api_base.py
src/feeds_gen/apis/operations_api.py
src/feeds_gen/apis/operations_api_base.py
src/feeds_gen/main.py
@@ -22,6 +24,9 @@ src/feeds_gen/models/gtfs_feed.py
src/feeds_gen/models/gtfs_rt_feed.py
src/feeds_gen/models/latest_dataset.py
src/feeds_gen/models/latest_dataset_validation_report.py
+src/feeds_gen/models/license_base.py
+src/feeds_gen/models/license_rule.py
+src/feeds_gen/models/license_with_rules.py
src/feeds_gen/models/location.py
src/feeds_gen/models/metadata.py
src/feeds_gen/models/operation_create_request_gtfs_feed.py
diff --git a/functions-python/operations_api/src/feeds_operations/impl/feeds_operations_impl.py b/functions-python/operations_api/src/feeds_operations/impl/feeds_operations_impl.py
index 9ded741bd..79e838c23 100644
--- a/functions-python/operations_api/src/feeds_operations/impl/feeds_operations_impl.py
+++ b/functions-python/operations_api/src/feeds_operations/impl/feeds_operations_impl.py
@@ -368,10 +368,22 @@ async def create_gtfs_feed(
db_session.add(new_feed)
db_session.commit()
created_feed = db_session.get(Gtfsfeed, new_feed.id)
- trigger_dataset_download(
- created_feed,
- get_execution_id(get_request_context(), "feed-created-process"),
- )
+ if created_feed is None:
+ raise HTTPException(
+ status_code=500,
+ detail=f"Failed to create GTFS feed with URL: {new_feed.producer_url}",
+ )
+ try:
+ trigger_dataset_download(
+ created_feed,
+ get_execution_id(get_request_context(), "feed-created-process"),
+ )
+ except Exception as exc:
+ logging.error(
+ "Failed to trigger dataset download for feed ID: %s. Error: %s",
+ created_feed.stable_id,
+ exc,
+ )
logging.info("Created new GTFS feed with ID: %s", new_feed.stable_id)
refreshed = refresh_materialized_view(db_session, t_feedsearch.name)
logging.info("Materialized view %s refreshed: %s", t_feedsearch.name, refreshed)
diff --git a/functions-python/operations_api/src/feeds_operations/impl/licenses_api_impl.py b/functions-python/operations_api/src/feeds_operations/impl/licenses_api_impl.py
new file mode 100644
index 000000000..557cde386
--- /dev/null
+++ b/functions-python/operations_api/src/feeds_operations/impl/licenses_api_impl.py
@@ -0,0 +1,109 @@
+#
+# MobilityData 2025
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+import logging
+from typing import List, Optional
+
+from fastapi import HTTPException
+from pydantic import StrictStr
+from sqlalchemy import func, or_
+from sqlalchemy.orm import Session
+
+from feeds_gen.apis.licenses_api_base import BaseLicensesApi
+from feeds_gen.models.license_base import LicenseBase
+from feeds_gen.models.license_with_rules import LicenseWithRules
+from shared.database.database import with_db_session
+from shared.database_gen.sqlacodegen_models import License as OrmLicense
+from shared.db_models.license_base_impl import LicenseBaseImpl
+from shared.db_models.license_with_rules_impl import LicenseWithRulesImpl
+
+
+class LicensesApiImpl(BaseLicensesApi):
+ """Implementation of the Licenses API.
+
+ This class provides concrete implementations for the generated
+ `BaseLicensesApi` methods, mirroring the style used in
+ `OperationsApiImpl` and relying on the shared SQLAlchemy models.
+ """
+
+ @with_db_session
+ async def get_license(
+ self,
+ id: StrictStr,
+ db_session: Session = None,
+ ) -> LicenseWithRules:
+ """Get the specified license from the Mobility Database.
+
+ Raises 404 if the license is not found.
+ """
+ logging.info("Fetching license with id: %s", id)
+
+ license_orm: Optional[OrmLicense] = db_session.get(OrmLicense, id)
+ if license_orm is None:
+ logging.warning("License not found: %s", id)
+ raise HTTPException(status_code=404, detail="License not found")
+
+ # Build Pydantic model from ORM object attributes
+ return LicenseWithRulesImpl.from_orm(license_orm)
+
+ @with_db_session
+ async def get_licenses(
+ self,
+ limit: int,
+ offset: int,
+ search_query: Optional[StrictStr] = None,
+ is_spdx: Optional[bool] = None,
+ db_session: Session = None,
+ ) -> List[LicenseBase]:
+ """Get the list of licenses from the Mobility Database.
+
+ Supports pagination via `limit` and `offset`, optional
+ case-insensitive text search on license name / id, and
+ optional filtering by SPDX status.
+ """
+
+ logging.info(
+ "Fetching licenses with limit=%s offset=%s search_query=%s is_spdx=%s",
+ limit,
+ offset,
+ search_query,
+ is_spdx,
+ )
+
+ query = db_session.query(OrmLicense)
+
+ # Text search by name or id
+ if search_query and search_query.strip():
+ pattern = f"%{search_query.strip()}%"
+ try:
+ conditions = [
+ func.lower(OrmLicense.name).like(func.lower(pattern)),
+ func.lower(OrmLicense.id).like(func.lower(pattern)),
+ ]
+ query = query.filter(or_(*conditions))
+ except Exception as exc: # defensive; shouldn't normally trigger
+ logging.error("Failed applying search filter: %s", exc)
+
+ # Optional SPDX filter
+ if is_spdx is not None:
+ query = query.filter(OrmLicense.is_spdx == is_spdx)
+
+ query = query.order_by(OrmLicense.id).offset(offset).limit(limit)
+ items: List[OrmLicense] = query.all()
+
+ logging.info("Fetched %d licenses", len(items))
+
+ return [LicenseBaseImpl.from_orm(item) for item in items]
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 5ee106237..94944ea53 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
@@ -58,6 +58,9 @@ def from_orm(cls, obj: Gtfsfeed | None) -> UpdateRequestGtfsFeed | None:
authentication_info_url=obj.authentication_info_url,
api_key_parameter_name=obj.api_key_parameter_name,
license_url=obj.license_url,
+ license_id=obj.license_id,
+ license_notes=obj.license_notes,
+ license_is_spdx=obj.license.is_spdx if obj.license else None,
),
redirects=sorted(
[RedirectImpl.from_orm(item) for item in obj.redirectingids],
@@ -123,7 +126,22 @@ def to_orm(
)
else update_request.source_info.license_url
)
-
+ entity.license_id = (
+ None
+ if (
+ update_request.source_info is None
+ or update_request.source_info.license_id is None
+ )
+ else update_request.source_info.license_id
+ )
+ entity.license_notes = (
+ None
+ if (
+ update_request.source_info is None
+ or update_request.source_info.license_notes is None
+ )
+ else update_request.source_info.license_notes
+ )
redirecting_ids = (
[]
if update_request.redirects is 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 373991e07..0a9de6b9e 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
@@ -64,6 +64,9 @@ def from_orm(cls, obj: Gtfsrealtimefeed | None) -> UpdateRequestGtfsRtFeed | Non
authentication_info_url=obj.authentication_info_url,
api_key_parameter_name=obj.api_key_parameter_name,
license_url=obj.license_url,
+ license_id=obj.license_id,
+ license_notes=obj.license_notes,
+ license_is_spdx=obj.license.is_spdx if obj.license else None,
),
redirects=sorted(
[RedirectImpl.from_orm(item) for item in obj.redirectingids],
diff --git a/functions-python/operations_api/src/main.py b/functions-python/operations_api/src/main.py
index 7a24f2eec..ebe1bf498 100644
--- a/functions-python/operations_api/src/main.py
+++ b/functions-python/operations_api/src/main.py
@@ -17,6 +17,7 @@
from flask import Request, Response
from fastapi import FastAPI
from feeds_gen.apis.operations_api import router as FeedsApiRouter
+from feeds_gen.apis.licenses_api import router as LicenseApiRouter
import functions_framework
import asyncio
@@ -34,6 +35,7 @@
# Add here middlewares that should be applied to all routes.
app.add_middleware(RequestContextMiddleware)
app.include_router(FeedsApiRouter)
+app.include_router(LicenseApiRouter)
def build_scope_from_wsgi(request: Request) -> dict:
diff --git a/functions-python/operations_api/tests/conftest.py b/functions-python/operations_api/tests/conftest.py
index 2e5956e5f..2829887de 100644
--- a/functions-python/operations_api/tests/conftest.py
+++ b/functions-python/operations_api/tests/conftest.py
@@ -20,6 +20,8 @@
Gtfsfeed,
Gtfsrealtimefeed,
Entitytype,
+ License,
+ Rule,
)
from test_shared.test_utils.database_utils import clean_testing_db, default_db_url
@@ -50,6 +52,7 @@
authentication_info_url="authentication_info_url",
api_key_parameter_name="api_key_parameter_name",
license_url="license_url",
+ license_id="MIT",
stable_id="mdb-40",
status="active",
feed_contact_email="feed_contact_email",
@@ -75,6 +78,44 @@
gtfs_rt_feeds=[],
)
+# Test license objects used by LicensesApiImpl tests
+license_std_mit = License(
+ id="MIT",
+ type="standard",
+ is_spdx=True,
+ name="MIT License",
+ url="https://opensource.org/licenses/MIT",
+ description="A short and permissive license.",
+)
+
+license_custom_test = License(
+ id="custom-test",
+ type="custom",
+ is_spdx=False,
+ name="Custom Test License",
+ url="https://example.com/custom-test-license",
+ description="Custom license used for testing.",
+)
+
+# Test rules associated to licenses
+rule_attribution = Rule(
+ name="attribution",
+ label="Attribution required",
+ type="condition",
+ description="Must attribute the data source when using the data.",
+)
+
+rule_share_alike = Rule(
+ name="share-alike",
+ label="Share alike",
+ type="condition",
+ description="Derivative works must be shared under the same terms.",
+)
+
+# Attach rules to licenses so LicenseWithRules has content
+license_std_mit.rules = [rule_attribution]
+license_custom_test.rules = [rule_attribution, rule_share_alike]
+
@with_db_session(db_url=default_db_url)
def populate_database(db_session):
@@ -84,9 +125,12 @@ def populate_database(db_session):
- 1 GTFS Realtime feeds
"""
db_session.add(feed_mdb_41)
- # session.flush()
db_session.add(feed_mdb_40)
db_session.add(feed_mdb_400)
+ db_session.add(rule_attribution)
+ db_session.add(rule_share_alike)
+ db_session.add(license_std_mit)
+ db_session.add(license_custom_test)
db_session.commit()
diff --git a/functions-python/operations_api/tests/feeds_operations/impl/test_feeds_operations_impl_gtfs.py b/functions-python/operations_api/tests/feeds_operations/impl/test_feeds_operations_impl_gtfs.py
index 33fa202aa..cb8e24818 100644
--- a/functions-python/operations_api/tests/feeds_operations/impl/test_feeds_operations_impl_gtfs.py
+++ b/functions-python/operations_api/tests/feeds_operations/impl/test_feeds_operations_impl_gtfs.py
@@ -29,6 +29,9 @@ def update_request_gtfs_feed():
authentication_info_url=feed_mdb_40.authentication_info_url,
api_key_parameter_name=feed_mdb_40.api_key_parameter_name,
license_url=feed_mdb_40.license_url,
+ license_id=feed_mdb_40.license_id,
+ license_notes=feed_mdb_40.license_notes,
+ license_is_spdx=True,
),
redirects=[],
operational_status_action="no_change",
diff --git a/functions-python/operations_api/tests/feeds_operations/impl/test_licenses_api_impl.py b/functions-python/operations_api/tests/feeds_operations/impl/test_licenses_api_impl.py
new file mode 100644
index 000000000..42415776b
--- /dev/null
+++ b/functions-python/operations_api/tests/feeds_operations/impl/test_licenses_api_impl.py
@@ -0,0 +1,159 @@
+import pytest
+from fastapi import HTTPException
+
+from feeds_operations.impl.licenses_api_impl import LicensesApiImpl
+from feeds_gen.models.license_with_rules import LicenseWithRules
+from feeds_gen.models.license_base import LicenseBase
+from shared.database.database import Database
+from shared.database_gen.sqlacodegen_models import License as OrmLicense
+from test_shared.test_utils.database_utils import default_db_url
+
+
+@pytest.fixture
+def db_session():
+ db = Database(feeds_database_url=default_db_url)
+ with db.start_db_session() as session:
+ yield session
+
+
+@pytest.mark.asyncio
+async def test_get_license_success(db_session):
+ # Arrange: pick an existing license id from the database
+ existing = db_session.query(OrmLicense).first()
+ if existing is None:
+ pytest.skip("No licenses in test database to validate get_license")
+
+ api = LicensesApiImpl()
+
+ # Act
+ result = await api.get_license(existing.id)
+
+ # Assert
+ assert isinstance(result, LicenseWithRules)
+ assert result.id == existing.id
+ assert result.name == existing.name
+
+
+@pytest.mark.asyncio
+async def test_get_license_not_found():
+ api = LicensesApiImpl()
+
+ with pytest.raises(HTTPException) as exc_info:
+ await api.get_license("non-existent-license-id")
+
+ assert exc_info.value.status_code == 404
+ assert exc_info.value.detail == "License not found"
+
+
+@pytest.mark.asyncio
+async def test_get_licenses_basic_pagination(db_session):
+ # Ensure there is at least one license; otherwise, skip.
+ count = db_session.query(OrmLicense).count()
+ if count == 0:
+ pytest.skip("No licenses in test database to validate get_licenses")
+
+ api = LicensesApiImpl()
+
+ # Act
+ result = await api.get_licenses(limit=10, offset=0)
+
+ # Assert
+ assert isinstance(result, list)
+ assert all(isinstance(item, LicenseBase) for item in result)
+ # We don't assert exact size because DB contents may vary, but
+ # if there are licenses in DB, result should not be empty.
+ assert len(result) > 0
+
+
+@pytest.mark.asyncio
+async def test_get_licenses_with_search_query(db_session):
+ # Take a license from the DB and use part of its name as a search query
+ existing = db_session.query(OrmLicense).first()
+ if existing is None or not existing.name:
+ pytest.skip("No suitable license data in DB to test search_query")
+
+ fragment = existing.name[:3]
+ api = LicensesApiImpl()
+
+ result = await api.get_licenses(limit=50, offset=0, search_query=fragment)
+
+ assert isinstance(result, list)
+ # True positive: the reference license must be present
+ assert any(item.id == existing.id for item in result)
+
+ # False positives: every returned item must match the search criteria
+ # (assuming search is done against name, adapt if implementation differs)
+ lowered_fragment = fragment.lower()
+ assert all(
+ (item.name or "").lower().find(lowered_fragment) != -1 for item in result
+ ), "Search results contain licenses whose names do not match the search fragment"
+
+
+@pytest.mark.asyncio
+async def test_get_licenses_filter_is_spdx_true(db_session):
+ # There should be at least one SPDX license from test data
+ spdx_count = (
+ db_session.query(OrmLicense).filter(OrmLicense.is_spdx.is_(True)).count()
+ )
+ if spdx_count == 0:
+ pytest.skip("No SPDX licenses in test database to validate is_spdx filter")
+
+ api = LicensesApiImpl()
+ result = await api.get_licenses(limit=10, offset=0, is_spdx=True)
+
+ assert isinstance(result, list)
+ assert len(result) == spdx_count
+ assert all(item.is_spdx is True for item in result)
+
+
+@pytest.mark.asyncio
+async def test_get_licenses_filter_is_spdx_false(db_session):
+ # There should be at least one non-SPDX license from test data
+ non_spdx_count = (
+ db_session.query(OrmLicense).filter(OrmLicense.is_spdx.is_(False)).count()
+ )
+ if non_spdx_count == 0:
+ pytest.skip("No non-SPDX licenses in test database to validate is_spdx filter")
+
+ api = LicensesApiImpl()
+ result = await api.get_licenses(limit=10, offset=0, is_spdx=False)
+
+ assert isinstance(result, list)
+ assert len(result) == non_spdx_count
+ assert all(item.is_spdx is False for item in result)
+
+
+@pytest.mark.asyncio
+async def test_get_license_includes_rules(db_session):
+ """Ensure that get_license returns associated rules in LicenseWithRules."""
+ existing = (
+ db_session.query(OrmLicense)
+ .filter(OrmLicense.id == "custom-test")
+ .one_or_none()
+ )
+ if existing is None:
+ pytest.skip("Fixture license 'custom-test' not found in test database")
+
+ api = LicensesApiImpl()
+ result = await api.get_license("custom-test")
+
+ assert isinstance(result, LicenseWithRules)
+ # LicenseWithRules exposes rules under the license_rules field
+ assert result.license_rules is not None
+ rule_names = sorted(rule.name for rule in result.license_rules)
+ assert rule_names == ["attribution", "share-alike"]
+
+
+@pytest.mark.asyncio
+async def test_get_licenses_includes_rules_for_each_item(db_session):
+ """Ensure that licenses returned by get_licenses can expose rule data via get_license."""
+ api = LicensesApiImpl()
+ results = await api.get_licenses(limit=10, offset=0)
+
+ assert results
+ for item in results:
+ if item.id == "MIT":
+ detailed = await api.get_license("MIT")
+ assert isinstance(detailed, LicenseWithRules)
+ assert detailed.license_rules is not None
+ assert [r.name for r in detailed.license_rules] == ["attribution"]
diff --git a/liquibase/changelog.xml b/liquibase/changelog.xml
index 79c9f1201..5e7738113 100644
--- a/liquibase/changelog.xml
+++ b/liquibase/changelog.xml
@@ -85,5 +85,8 @@
-
+
+
+
+
diff --git a/liquibase/changes/feat_1432_1.sql b/liquibase/changes/feat_1432_1.sql
new file mode 100644
index 000000000..cdd051cff
--- /dev/null
+++ b/liquibase/changes/feat_1432_1.sql
@@ -0,0 +1,4 @@
+-- Create indexes to enhance SQL queries running like commands against license id and name
+
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_license_lower_id
+ ON license ((lower(id)));
diff --git a/liquibase/changes/feat_1432_2.sql b/liquibase/changes/feat_1432_2.sql
new file mode 100644
index 000000000..86d6762f4
--- /dev/null
+++ b/liquibase/changes/feat_1432_2.sql
@@ -0,0 +1,4 @@
+-- Create indexes to enhance SQL queries running like commands against license id and name
+
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_license_lower_name
+ ON license ((lower(name)));
diff --git a/liquibase/changes/feat_1432_3.sql b/liquibase/changes/feat_1432_3.sql
new file mode 100644
index 000000000..75b45d5b5
--- /dev/null
+++ b/liquibase/changes/feat_1432_3.sql
@@ -0,0 +1,5 @@
+-- Create indexes to enhance SQL queries running like commands against license id and name
+-- This changeset is executed after enabling the pg_trgm extension in a different transaction
+
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_license_lower_name_trgm
+ ON license USING gin (lower(name) gin_trgm_ops);
\ No newline at end of file
diff --git a/liquibase/changes/feat_1432_indexes.xml b/liquibase/changes/feat_1432_indexes.xml
new file mode 100644
index 000000000..d35ee79f5
--- /dev/null
+++ b/liquibase/changes/feat_1432_indexes.xml
@@ -0,0 +1,25 @@
+
+
+
+
+ CREATE EXTENSION IF NOT EXISTS pg_trgm;
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file