Skip to content
130 changes: 129 additions & 1 deletion api/src/shared/common/license_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from typing import List, Tuple, Optional

from shared.common.db_utils import normalize_url, normalize_url_str
from shared.database_gen.sqlacodegen_models import License, FeedLicenseChange
from shared.database_gen.sqlacodegen_models import License, FeedLicenseChange, Feed


@dataclass
Expand Down Expand Up @@ -534,3 +534,131 @@ def assign_license_by_url(
)

return best


@dataclass
class PropagateLicenseAffectedFeedResult:
"""Describes a single feed affected by a license propagation."""

feed_id: str
previous_license_id: Optional[str]
data_type: Optional[str]


@dataclass
class PropagateLicenseResult:
"""Result of a license propagation operation.

Attributes:
license_id: The license ID that was propagated.
license_url: The original license URL provided for matching.
normalized_license_url: Normalized form of the license URL used for matching.
dry_run: Whether this was a dry-run (no changes persisted).
override: Whether feeds with an existing license_id were also updated.
total_feeds_with_same_url: Total feeds sharing the same normalized license URL.
affected_feeds_count: Number of feeds that were (or would be) updated.
affected_feeds: List of affected feed descriptors.
"""

license_id: str
license_url: str
normalized_license_url: str
dry_run: bool
override: bool
total_feeds_with_same_url: int
affected_feeds_count: int
affected_feeds: List[PropagateLicenseAffectedFeedResult]


def propagate_license_by_url(
license_id: str,
license_url: str,
db_session: Session,
*,
dry_run: bool = True,
override: bool = False,
) -> PropagateLicenseResult:
"""Propagate a license ID to all feeds sharing the same normalized license URL.

Finds all published (non-unpublished) feeds whose license_url normalizes to the
same value as ``license_url``, then optionally updates their ``license_id`` and
creates ``FeedLicenseChange`` audit records.

Args:
license_id: The license ID to propagate. Must exist in the ``license`` table.
license_url: The reference URL whose normalized form is used for matching.
db_session: Active SQLAlchemy session.
dry_run: When True (default), compute results without persisting changes.
override: When False (default), only update feeds where ``license_id IS NULL``.
When True, also update feeds that already have a different ``license_id``.

Returns:
A ``PropagateLicenseResult`` describing the outcome.

Raises:
ValueError: If ``license_id`` does not exist in the database.
"""
existing_license = db_session.get(License, license_id)
if existing_license is None:
raise ValueError(f"License '{license_id}' not found in the database.")

normalized_url = normalize_url_str(license_url)

# Find all feeds with the same normalized license URL.
# Use the same SQL normalization pattern as get_feed_query_by_normalized_url.
candidate_query = db_session.query(Feed).filter(
Feed.license_url.isnot(None),
Feed.operational_status != "unpublished",
normalized_url == func.lower(func.trim(normalize_url(Feed.license_url))),
)
all_candidates = candidate_query.all()
total_feeds_with_same_url = len(all_candidates)

if override:
feeds_to_update = [f for f in all_candidates if f.license_id != license_id]
else:
feeds_to_update = [f for f in all_candidates if f.license_id is None]

affected: List[PropagateLicenseAffectedFeedResult] = []
for feed in feeds_to_update:
affected.append(
PropagateLicenseAffectedFeedResult(
feed_id=feed.stable_id,
previous_license_id=feed.license_id,
data_type=feed.data_type,
)
)
if not dry_run:
feed.license_id = license_id
db_session.add(
FeedLicenseChange(
feed_id=feed.id,
feed_license_url=feed.license_url,
matched_license_id=license_id,
confidence=1.0,
match_type="propagated",
matched_source="propagate_match",
verified=True,
)
)

logging.info(
"propagate_license_by_url: license_id=%s url=%s dry_run=%s override=%s " "total_with_url=%d affected=%d",
license_id,
license_url,
dry_run,
override,
total_feeds_with_same_url,
len(affected),
)

return PropagateLicenseResult(
license_id=license_id,
license_url=license_url,
normalized_license_url=normalized_url,
dry_run=dry_run,
override=override,
total_feeds_with_same_url=total_feeds_with_same_url,
affected_feeds_count=len(affected),
affected_feeds=affected,
)
142 changes: 142 additions & 0 deletions docs/OperationsAPI.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,34 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/MatchingLicenses"
/v1/operations/licenses:propagate_match:
post:
description: >
Propagate a license ID to all feeds sharing the same normalized license URL. Use dry_run=true (the default) to preview which feeds would be updated without persisting any changes.

tags:
- "licenses"
operationId: propagateMatchLicense
security:
- ApiKeyAuth: []
requestBody:
description: Payload containing the license ID to propagate and the license URL used for matching.
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PropagateLicenseRequest"
responses:
"200":
description: The result of the propagation, including the list of affected feeds.
content:
application/json:
schema:
$ref: "#/components/schemas/PropagateLicenseResponse"
"422":
description: Validation error (e.g. missing required fields).
"500":
description: Internal server error.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should consider adding a 400 response here, since it can be sent back here:

(thanks copilot)

/v1/operations/licenses/{id}:
parameters:
- $ref: "#/components/parameters/license_id_path_param"
Expand Down Expand Up @@ -1389,6 +1417,88 @@ components:
type: array
items:
$ref: "#/components/schemas/MatchingLicense"
PropagateLicenseAffectedFeed:
x-operation: true
type: object
description: A feed affected by a license propagation operation.
properties:
feed_id:
description: The stable ID of the affected feed.
type: string
example: mdb-42
previous_license_id:
description: The license ID the feed had before propagation (null if none).
type: string
nullable: true
example: null
data_type:
description: The data type of the feed.
type: string
enum: [gtfs, gtfs_rt, gbfs]
example: gtfs
PropagateLicenseRequest:
x-operation: true
type: object
description: Request payload for the propagate_match license endpoint.
required:
- license_id
- license_url
properties:
license_id:
description: The license ID to propagate to matching feeds.
type: string
example: CC-BY-4.0
license_url:
description: The license URL whose normalized form is used to find matching feeds.
type: string
format: url
example: https://creativecommons.org/licenses/by/4.0/deed.nl
dry_run:
description: >
When true (default), compute and return the affected feeds without persisting any changes. Set to false to apply the changes.

type: boolean
default: true
override:
description: >
When false (default), only feeds whose license_id is currently unset (null) are updated. When true, feeds that already have a license_id are also updated.

type: boolean
default: false
PropagateLicenseResponse:
x-operation: true
type: object
description: Result of a license propagation operation.
properties:
license_id:
description: The license ID that was propagated.
type: string
example: CC-BY-4.0
license_url:
description: The original license URL provided for matching.
type: string
example: https://creativecommons.org/licenses/by/4.0/deed.nl
normalized_license_url:
description: The normalized form of the license URL used for matching.
type: string
example: creativecommons.org/licenses/by/4.0/deed.nl
dry_run:
description: Whether this was a dry run (no changes persisted).
type: boolean
override:
description: Whether feeds with an existing license_id were also updated.
type: boolean
total_feeds_with_same_url:
description: Total number of feeds sharing the same normalized license URL (regardless of filter).
type: integer
affected_feeds_count:
description: Number of feeds that were (or would be) updated.
type: integer
affected_feeds:
description: List of feeds that were (or would be) updated.
type: array
items:
$ref: "#/components/schemas/PropagateLicenseAffectedFeed"
OperationCreateRequestGtfsFeed:
x-operation: true
type: object
Expand Down Expand Up @@ -1441,6 +1551,12 @@ components:
type: array
items:
$ref: '#/components/schemas/FeedRelatedLink'
propagate_license:
type: boolean
default: false
description: >
When true, after the feed is created, propagate its license_id to all other feeds sharing the same normalized license_url where license_id is currently unset.

required:
- source_info
- operational_status
Expand Down Expand Up @@ -1504,6 +1620,8 @@ components:





* vp - vehicle positions
* tu - trip updates
* sa - service alerts
Expand All @@ -1522,6 +1640,12 @@ components:
type: array
items:
$ref: '#/components/schemas/FeedRelatedLink'
propagate_license:
type: boolean
default: false
description: >
When true, after the feed is created, propagate its license_id to all other feeds sharing the same normalized license_url where license_id is currently unset.

required:
- source_info
- operational_status
Expand Down Expand Up @@ -1627,6 +1751,8 @@ components:





* vp - vehicle positions
* tu - trip updates
* sa - service alerts
Expand All @@ -1646,6 +1772,12 @@ components:
official:
type: boolean
description: Whether this is an official feed.
propagate_license:
type: boolean
default: false
description: >
When true, after the feed is updated, propagate its license_id to all other feeds sharing the same normalized license_url where license_id is currently unset.

required:
- id
- status
Expand Down Expand Up @@ -1695,6 +1827,12 @@ components:
official:
type: boolean
description: Whether this is an official feed.
propagate_license:
type: boolean
default: false
description: >
When true, after the feed is updated, propagate its license_id to all other feeds sharing the same normalized license_url where license_id is currently unset.

required:
- id
- status
Expand All @@ -1707,6 +1845,8 @@ components:





* `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.
Expand All @@ -1729,6 +1869,8 @@ components:





* `gtfs` GTFS feed.
* `gtfs_rt` GTFS-RT feed.
* `gbfs` GBFS feed.
Expand Down
3 changes: 3 additions & 0 deletions functions-python/operations_api/.openapi-generator/FILES
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ src/feeds_gen/models/operation_create_request_gtfs_rt_feed.py
src/feeds_gen/models/operation_feed.py
src/feeds_gen/models/operation_gtfs_feed.py
src/feeds_gen/models/operation_gtfs_rt_feed.py
src/feeds_gen/models/propagate_license_affected_feed.py
src/feeds_gen/models/propagate_license_request.py
src/feeds_gen/models/propagate_license_response.py
src/feeds_gen/models/redirect.py
src/feeds_gen/models/search_feed_item_result.py
src/feeds_gen/models/source_info.py
Expand Down
Loading
Loading