Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 0 additions & 22 deletions api/.openapi-generator/FILES
Original file line number Diff line number Diff line change
Expand Up @@ -52,25 +52,3 @@ src/feeds_gen/models/search_feeds200_response.py
src/feeds_gen/models/source_info.py
src/feeds_gen/models/validation_report.py
src/feeds_gen/security_api.py
src/user_service/impl/__init__.py
src/user_service_gen/apis/__init__.py
src/user_service_gen/apis/notifications_api.py
src/user_service_gen/apis/notifications_api_base.py
src/user_service_gen/apis/subscriptions_api.py
src/user_service_gen/apis/subscriptions_api_base.py
src/user_service_gen/apis/users_api.py
src/user_service_gen/apis/users_api_base.py
src/user_service_gen/main.py
src/user_service_gen/models/__init__.py
src/user_service_gen/models/create_notification_subscription_request.py
src/user_service_gen/models/extra_models.py
src/user_service_gen/models/feature_flag.py
src/user_service_gen/models/feed_subscription_summary.py
src/user_service_gen/models/notification_subscription.py
src/user_service_gen/models/notification_type.py
src/user_service_gen/models/subscription_feed.py
src/user_service_gen/models/subscription_feed_group.py
src/user_service_gen/models/update_notification_subscription_request.py
src/user_service_gen/models/update_user_request.py
src/user_service_gen/models/user_profile.py
src/user_service_gen/security_api.py
55 changes: 54 additions & 1 deletion api/src/shared/common/seal_criteria.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from datetime import datetime, timedelta
from enum import Enum
from typing import Dict, Final, Optional
from typing import Dict, Final, Iterable, Optional, Tuple

from shared.common.error_handling import raise_internal_http_error, unknown_seal_criterion

Expand Down Expand Up @@ -61,6 +61,20 @@ def is_verdict(self) -> bool:
return self in (CriterionStatus.PASS, CriterionStatus.FAIL)


class SealStatus(str, Enum):
"""The feed-level seal outcome."""

GRANTED = "granted"
NOT_GRANTED = "not_granted"
UNKNOWN = "unknown"
NEVER_EVALUATED = "never_evaluated"

@property
def is_answer(self) -> bool:
"""True for GRANTED and NOT_GRANTED - the two values that actually decide the seal."""
return self in (SealStatus.GRANTED, SealStatus.NOT_GRANTED)


class CriterionPhase(str, Enum):
"""Which of the two debouncing mechanisms is currently acting on a criterion.

Expand Down Expand Up @@ -111,10 +125,49 @@ class CriterionPhase(str, Enum):
)


def roll_up_seal_status(
criteria: Iterable[Tuple[CriterionStatus, bool]],
) -> SealStatus:
"""The feed-level seal outcome from its criteria.
* every criterion NEVER_EVALUATED -> seal NEVER_EVALUATED.
* any criterion failing or on probation -> seal NOT_GRANTED, whatever the rest say.
* any remaining criterion NEVER_EVALUATED -> seal UNKNOWN.
* otherwise every criterion is a confirmed pass and none is on probation -> seal GRANTED.
"""
in_scope = [
(status, on_probation) for status, on_probation in criteria if status is not CriterionStatus.NOT_APPLICABLE
]
if not in_scope:
# Every criterion is NOT_APPLICABLE, so there is nothing left to judge the feed by.
return SealStatus.NEVER_EVALUATED

unjudged = sum(1 for status, _ in in_scope if status is CriterionStatus.NEVER_EVALUATED)
if unjudged == len(in_scope):
# Every criterion that isn't NOT_APPLICABLE is NEVER_EVALUATED
return SealStatus.NEVER_EVALUATED

# One criterion is enough to deny the seal, so this is decidable even with the rest unjudged.
# A pass on probation denies it too: probation withholds the criterion whatever its status.
denied = any(
status is CriterionStatus.FAIL or (status is CriterionStatus.PASS and on_probation)
for status, on_probation in in_scope
)
if denied:
return SealStatus.NOT_GRANTED
if unjudged:
# Nothing denies the seal, but not everything has been judged: it cannot be granted yet.
return SealStatus.UNKNOWN

return SealStatus.GRANTED


# Stable: how long we must have been tracking a feed - measured from its
# `feed.created_at` - before it can be called stable.
TRACKING_PERIOD: Final[timedelta] = timedelta(days=180)

# Available: how far back to look for an availability check
AVAILABILITY_LOOKBACK: Final[timedelta] = timedelta(hours=24)

# Fresh / future coverage: how far ahead the latest dataset's service coverage must reach
FUTURE_COVERAGE_HORIZON: Final[timedelta] = timedelta(days=7)

Expand Down
21 changes: 20 additions & 1 deletion api/src/shared/db_models/feed_reliability_report_impl.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
from feeds_gen.models.feed_reliability_report import FeedReliabilityReport
from shared.common.seal_criteria import SealCriterionName, resolve_criterion
from shared.common.seal_criteria import (
PROBATION_EXEMPT_CRITERIA,
CriterionStatus,
SealCriterionName,
resolve_criterion,
roll_up_seal_status,
)
from shared.database_gen.sqlacodegen_models import Gtfsfeed as GtfsfeedOrm
from shared.database_gen.sqlacodegen_models import SealCriterion as SealCriterionOrm
from shared.db_models.reliability_criterion_impl import ReliabilityCriterionImpl


def _seal_status_of(criterion_rows: list[SealCriterionOrm]) -> str:
"""The feed-level seal status derived from a feed's `seal_criterion` rows."""
return roll_up_seal_status(
(
CriterionStatus(row.confirmed_status),
row.probation_start is not None and row.criterion not in PROBATION_EXEMPT_CRITERIA,
)
for row in criterion_rows
).value


class FeedReliabilityReportImpl(FeedReliabilityReport):
"""Implementation of the `FeedReliabilityReport` model.

Expand Down Expand Up @@ -58,6 +76,7 @@ def from_orm(cls, feed: GtfsfeedOrm | None) -> FeedReliabilityReport | None:
return cls(
feed_id=feed.stable_id,
has_seal=bool(seal.has_seal) if seal is not None else False,
seal_status=_seal_status_of(criterion_rows),
earned_at=seal.seal_earned_at if seal is not None else None,
lost_at=seal.seal_lost_at if seal is not None else None,
evaluated_at=max(evaluated_ats) if evaluated_ats else None,
Expand Down
36 changes: 36 additions & 0 deletions api/tests/unittest/test_feeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@ def test_gtfs_feed_reliability_never_evaluated(client: TestClient):
body = response.json()
assert body["feed_id"] == TEST_GTFS_FEED_STABLE_IDS[0]
assert body["has_seal"] is False
assert body["seal_status"] == "never_evaluated", "no criterion row at all, so nothing was ever decided"
assert body["on_probation"] is False
assert len(body["criteria"]) == 6
assert {criterion["status"] for criterion in body["criteria"]} == {"never_evaluated"}
Expand Down Expand Up @@ -566,6 +567,41 @@ def test_gtfs_feed_get_embeds_reliability_seal(client: TestClient):
assert seal["evaluated_at"] is not None


def test_gtfs_feed_reliability_reports_an_undecided_seal_as_unknown(client: TestClient):
"""`has_seal: false` covers three different things; `seal_status` is what separates them.

A feed whose criteria have not all been judged is not a feed that was judged and failed, and a
client has to be able to tell those apart. The status is derived from the criterion rows rather
than stored, so one passing criterion beside one never-evaluated one is all it takes.
"""
feed_stable_id = TEST_GTFS_FEED_STABLE_IDS[2]
criteria = {
"official": {"observed_status": "pass", "confirmed_status": "pass", "evaluated_at": SEAL_NOW},
"available": {"observed_status": "unknown", "confirmed_status": "never_evaluated", "evaluated_at": SEAL_NOW},
}
with _seal_rows(feed_stable_id, has_seal=False, criteria=criteria):
response = client.request("GET", f"/v1/gtfs_feeds/{feed_stable_id}/reliability", headers=authHeaders)

assert response.status_code == 200, f"Response status code was {response.status_code} instead of 200"
body = response.json()
assert body["has_seal"] is False
assert body["seal_status"] == "unknown"


def test_gtfs_feed_reliability_reports_a_judged_seal_as_granted(client: TestClient):
"""The other side of the same coin: every criterion in scope judged, and all passing."""
feed_stable_id = TEST_GTFS_FEED_STABLE_IDS[3]
criteria = {
criterion: {"observed_status": "pass", "confirmed_status": "pass", "evaluated_at": SEAL_NOW}
for criterion in ("official", "stable", "available", "compliant", "fresh_coverage", "fresh_continuous")
}
with _seal_rows(feed_stable_id, has_seal=True, criteria=criteria):
response = client.request("GET", f"/v1/gtfs_feeds/{feed_stable_id}/reliability", headers=authHeaders)

assert response.status_code == 200, f"Response status code was {response.status_code} instead of 200"
assert response.json()["seal_status"] == "granted"


def test_gtfs_feed_get_without_seal_reports_null(client: TestClient):
"""A feed that has never been evaluated reports a null summary rather than an empty object."""
response = client.request(
Expand Down
13 changes: 13 additions & 0 deletions docs/DatabaseCatalogAPI.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,19 @@ components:
description: Whether the feed currently holds the Seal of Reliability.
type: boolean
example: false
seal_status:
description: >
Descriptive status of the feed's seal. `has_seal` is true only when this is `granted`.
`not_granted`: at least one criterion is failing. `unknown`: none is failing, but not
every criterion has been evaluated yet. `never_evaluated`: none of the criteria has
been evaluated yet.
type: string
enum:
- granted
- not_granted
- unknown
- never_evaluated
example: granted
earned_at:
description: When the feed most recently earned the seal, in ISO 8601 date-time format.
type: string
Expand Down
31 changes: 31 additions & 0 deletions docs/OperationsAPI.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1211,6 +1211,17 @@ components:
description: Whether the feed currently holds the Seal of Reliability.
type: boolean
example: false
seal_status:
description: >
Descriptive status of the feed's seal. `has_seal` is true only when this is `granted`. `not_granted`: at least one criterion is failing. `unknown`: none is failing, but not every criterion has been evaluated yet. `never_evaluated`: none of the criteria has been evaluated yet.

type: string
enum:
- granted
- not_granted
- unknown
- never_evaluated
example: granted
earned_at:
description: When the feed most recently earned the seal, in ISO 8601 date-time format.
type: string
Expand Down Expand Up @@ -2270,6 +2281,11 @@ components:
The type of realtime entry:







* vp - vehicle positions
* tu - trip updates
* sa - service alerts
Expand Down Expand Up @@ -2396,6 +2412,11 @@ components:
The type of realtime entry:







* vp - vehicle positions
* tu - trip updates
* sa - service alerts
Expand Down Expand Up @@ -2497,6 +2518,11 @@ components:
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.
Expand All @@ -2516,6 +2542,11 @@ components:
Describes data type of a feed. Should be one of







* `gtfs` GTFS feed.
* `gtfs_rt` GTFS-RT feed.
* `gbfs` GBFS feed.
Expand Down
Loading