Live ops wall + pluggable camera providers with licence gating (server-26#198) #208
@@ -147,7 +147,7 @@ class Catalog:
|
||||
self._next_attempt = time.time() + ttl
|
||||
except Exception as e:
|
||||
self._next_attempt = time.time() + min(ttl, RETRY_AFTER_FAILURE_S)
|
||||
self.error = f"{type(e).__name__}: {e}"[:300]
|
||||
self.error = safe_error(e)
|
||||
logger.warning(f"cameras/{self.provider.meta.id}: refresh failed, serving {len(self.items)} cached: {self.error}")
|
||||
return self
|
||||
|
||||
@@ -158,6 +158,17 @@ class Catalog:
|
||||
|
||||
# ── helpers shared by adapters ───────────────────────────────────────────────
|
||||
|
||||
# Query strings are where API keys travel (?key=...). httpx puts the full URL in
|
||||
# HTTPStatusError text, and this text reaches every signed-in user through the
|
||||
# status fields of GET /cameras and /traffic/511, so it is dropped here.
|
||||
_URL_QUERY = re.compile(r"(https?://[^\s'\"?#]+)\?[^\s'\"#]*")
|
||||
|
||||
|
||||
def safe_error(e: BaseException) -> str:
|
||||
"""Error text safe to show users: URL query strings removed, length capped."""
|
||||
return _URL_QUERY.sub(r"\1?[redacted]", f"{type(e).__name__}: {e}")[:300]
|
||||
|
||||
|
||||
def make_camera(provider_id: str, raw_id: Any, lat: float, lon: float, *, name: str = "", roadway: str = "",
|
||||
direction: str = "", image_url: Optional[str] = None, video_url: Optional[str] = None,
|
||||
heading_deg: Optional[float] = None) -> Camera:
|
||||
|
||||
@@ -177,9 +177,12 @@ class CalgaryProvider(CameraProvider):
|
||||
meta = ProviderMeta(
|
||||
id="calgary", name="Open Calgary traffic cameras", region="Calgary, Alberta",
|
||||
coverage=(50.8, -114.5, 51.3, -113.8),
|
||||
# UNVERIFIED like Ontario 511: the OGL - Calgary grant is per GEV, not read
|
||||
# first-hand (camera-sources.md: "Yes, attribution (per GEV)"), so it is gated.
|
||||
licence=LicenceInfo(
|
||||
status=Licence.ATTRIBUTION_REQUIRED, label="Commercial OK with attribution (OGL - City of Calgary)",
|
||||
terms="Open Government Licence - City of Calgary: reuse permitted with attribution.",
|
||||
status=Licence.UNVERIFIED,
|
||||
label="Licence UNVERIFIED -- OGL - City of Calgary per GEV, terms page not read first-hand",
|
||||
terms="Open Government Licence - City of Calgary (reuse with attribution) per GEV; not confirmed first-hand.",
|
||||
terms_url="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa",
|
||||
attribution="Contains information licensed under the Open Government Licence - City of Calgary",
|
||||
attribution_url="https://data.calgary.ca"),
|
||||
@@ -215,9 +218,11 @@ class TflProvider(CameraProvider):
|
||||
meta = ProviderMeta(
|
||||
id="tfl", name="TfL JamCams", region="London, UK",
|
||||
coverage=(51.2, -0.7, 51.8, 0.4),
|
||||
# UNVERIFIED: the TfL terms page 403'd during the survey and the grant is
|
||||
# per GEV only (camera-sources.md: "re-read"), so it is gated until re-read.
|
||||
licence=LicenceInfo(
|
||||
status=Licence.ATTRIBUTION_REQUIRED,
|
||||
label="Commercial OK with attribution (\"Powered by TfL Open Data\") -- TfL terms page not re-read",
|
||||
status=Licence.UNVERIFIED,
|
||||
label="Licence UNVERIFIED -- commercial OK with attribution per GEV; TfL terms page not read first-hand",
|
||||
terms="TfL transport data terms: free incl. commercial, with attribution (per GEV; the terms page "
|
||||
"returned 403 during the survey -- re-read before selling).",
|
||||
terms_url="https://tfl.gov.uk/info-for/open-data-users/our-open-data",
|
||||
|
||||
@@ -38,6 +38,7 @@ import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
from app.internal.cameras.base import safe_error
|
||||
|
||||
LEGACY_BASE = "https://511ny.org/api"
|
||||
CAMERAS_TTL_S = 60 * 60 # camera list is near-static
|
||||
@@ -73,7 +74,7 @@ class _Feed:
|
||||
self._next_attempt = time.time() + self.ttl_s
|
||||
except Exception as e:
|
||||
self._next_attempt = time.time() + min(self.ttl_s, RETRY_AFTER_FAILURE_S)
|
||||
self.error = f"{type(e).__name__}: {e}"[:300]
|
||||
self.error = safe_error(e) # never echo ?key= to viewers
|
||||
logger.warning(f"511NY {self.path} refresh failed, serving {len(self.items)} cached: {self.error}")
|
||||
return self
|
||||
|
||||
|
||||
@@ -111,6 +111,18 @@ def test_gated_licence_requires_acknowledgement(store):
|
||||
assert store.audit[-1]["details"]["provider_id"] == "ny511"
|
||||
|
||||
|
||||
def test_licences_not_read_first_hand_are_gated_but_keep_attribution():
|
||||
# TfL (terms page 403'd) and Calgary (per GEV only) are not confirmed, so an
|
||||
# admin must acknowledge before they serve; their credit still renders.
|
||||
for pid in ("tfl", "calgary", "ontario511"):
|
||||
lic = registry.PROVIDERS[pid].meta.licence
|
||||
assert lic.status == Licence.UNVERIFIED and lic.gated, pid
|
||||
assert registry.PROVIDERS["tfl"].meta.licence.attribution == "Powered by TfL Open Data"
|
||||
with pytest.raises(registry.ProviderError) as e:
|
||||
run(registry.set_enabled("tfl", True, ("u", "e")))
|
||||
assert e.value.extra["requires_acknowledgement"] is True
|
||||
|
||||
|
||||
def test_gated_enable_refused_when_audit_cannot_be_written(store):
|
||||
with patch.object(registry, "_audit", AsyncMock(side_effect=RuntimeError("firestore down"))):
|
||||
with pytest.raises(registry.ProviderError) as e:
|
||||
@@ -141,7 +153,7 @@ def test_unreadable_settings_fail_closed():
|
||||
|
||||
def test_query_returns_attribution_and_skips_providers_outside_bbox(store):
|
||||
run(registry.set_enabled("nsw", True, ("u", "e")))
|
||||
run(registry.set_enabled("tfl", True, ("u", "e")))
|
||||
run(registry.set_enabled("tfl", True, ("u", "e"), acknowledge_licence=True))
|
||||
cam = base.make_camera("nsw", "x", -33.87, 151.2, name="Sydney", image_url="https://webcams.transport.nsw.gov.au/a.jpg")
|
||||
nsw_fetch, tfl_fetch = AsyncMock(return_value=[cam]), AsyncMock(return_value=[])
|
||||
with patch.object(registry.PROVIDERS["nsw"], "fetch_catalog", nsw_fetch), \
|
||||
@@ -277,6 +289,18 @@ def test_ny511_provider_wraps_legacy_client_and_carries_daa_metadata():
|
||||
assert "with all faults" in lic.disclaimer and lic.gated
|
||||
|
||||
|
||||
def test_refresh_error_never_echoes_an_api_key():
|
||||
import httpx
|
||||
req = httpx.Request("GET", "https://511on.ca/api/v2/get/cameras", params={"format": "json", "key": "SECRET123"})
|
||||
with pytest.raises(httpx.HTTPStatusError) as raised: # httpx's own message carries the full URL
|
||||
httpx.Response(403, request=req).raise_for_status()
|
||||
err = raised.value
|
||||
cat = registry.CATALOGS["ontario511"]
|
||||
with patch.object(cat.provider, "fetch_catalog", AsyncMock(side_effect=err)):
|
||||
run(cat.get())
|
||||
assert "SECRET123" not in cat.error and "403" in cat.error and "511on.ca/api/v2/get/cameras" in cat.error
|
||||
|
||||
|
||||
def test_failed_refresh_keeps_last_good_catalog():
|
||||
cat = registry.CATALOGS["nsw"]
|
||||
cam = base.make_camera("nsw", "x", -33.8, 151.2)
|
||||
|
||||
@@ -60,8 +60,11 @@ export function useMapCameras(map: L.Map, enabled: boolean) {
|
||||
return useCameraFetch(
|
||||
enabled
|
||||
? () => {
|
||||
// Clamp: zoomed out (or panned past the antimeridian) Leaflet bounds run
|
||||
// past ±180/±90, and GET /cameras answers 422 to an out-of-range bbox.
|
||||
const b = map.getBounds().pad(BBOX_PAD);
|
||||
return { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() };
|
||||
const clamp = (v: number, lim: number) => Math.max(-lim, Math.min(lim, v));
|
||||
return { south: clamp(b.getSouth(), 90), west: clamp(b.getWest(), 180), north: clamp(b.getNorth(), 90), east: clamp(b.getEast(), 180) };
|
||||
}
|
||||
: null,
|
||||
[map, enabled],
|
||||
|
||||
Reference in New Issue
Block a user