feat: content provenance and complete frame management workflows
This commit is contained in:
@@ -517,6 +517,20 @@
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
],
|
||||
"get": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/playlists": {
|
||||
@@ -610,5 +624,131 @@
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/devices/{device_id}/current-content": {
|
||||
"get": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/providers/weather": {
|
||||
"get": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/settings/weather": {
|
||||
"put": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/assets/{asset_id}/source": {
|
||||
"get": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/jobs/{job_id}/run": {
|
||||
"post": [
|
||||
"202",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/jobs/{job_id}/preview": {
|
||||
"post": [
|
||||
"202",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/settings/image-url": {
|
||||
"get": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
],
|
||||
"put": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
from pigway_frame_flow.display.service import DisplayService
|
||||
from pigway_frame_flow.models import DisplayRequest, TemplateContent
|
||||
|
||||
|
||||
def display(service, content):
|
||||
request = DisplayRequest(content=content)
|
||||
operation = service.submit(
|
||||
"display",
|
||||
request.model_dump(mode="json"),
|
||||
"living-room",
|
||||
"admin",
|
||||
"source-test",
|
||||
)
|
||||
service.process_one()
|
||||
service.sync()
|
||||
assert service.operation(operation)["status"] == "COMPLETED"
|
||||
return operation
|
||||
|
||||
|
||||
def test_vendor_image_has_unknown_template(service):
|
||||
current = service.current_content("living-room")
|
||||
assert current["image_id"] == 1
|
||||
assert current["source"] is None and not current["source_known"]
|
||||
|
||||
|
||||
def test_template_snapshot_survives_changed_defaults_and_restart(service):
|
||||
display(
|
||||
service,
|
||||
{
|
||||
"type": "template",
|
||||
"template": "quote",
|
||||
"data": {"quote": "Original text"},
|
||||
"profile": "grayscale",
|
||||
},
|
||||
)
|
||||
first = service.current_content("living-room")
|
||||
assert first["source"]["data"]["quote"] == "Original text"
|
||||
assert first["source"]["profile"] == "grayscale"
|
||||
service.db.execute(
|
||||
"INSERT OR REPLACE INTO template_config VALUES(?,?)",
|
||||
("quote", '{"quote":"Changed defaults"}'),
|
||||
)
|
||||
restarted = DisplayService(
|
||||
service.db, service.settings, service.registry, service.assets, service.renderer
|
||||
)
|
||||
assert restarted.current_content("living-room")["source"] == first["source"]
|
||||
|
||||
|
||||
def test_pre_rendered_asset_retains_template_on_display(service):
|
||||
asset = service.content_asset(
|
||||
TemplateContent(
|
||||
type="template", template="calendar", data={"date": "2026-10-02"}
|
||||
)
|
||||
)
|
||||
display(service, {"type": "image", "asset_id": asset["asset_id"]})
|
||||
assert service.current_content("living-room")["source"]["template"] == "calendar"
|
||||
|
||||
|
||||
def test_external_switch_does_not_claim_old_template(service):
|
||||
display(service, {"type": "template", "template": "quote", "data": {}})
|
||||
service.registry.get("living-room").client.display(1)
|
||||
service.registry.get("living-room").invalidate()
|
||||
assert service.current_content("living-room")["source"] is None
|
||||
|
||||
|
||||
def test_restore_returns_original_source(service):
|
||||
display(
|
||||
service, {"type": "template", "template": "quote", "data": {"quote": "Art"}}
|
||||
)
|
||||
display(service, {"type": "template", "template": "calendar", "data": {}})
|
||||
operation = service.submit("restore", {}, "living-room", "admin", "restore-source")
|
||||
service.process_one()
|
||||
service.sync()
|
||||
assert service.operation(operation)["status"] == "COMPLETED"
|
||||
assert service.current_content("living-room")["source"]["template"] == "quote"
|
||||
|
||||
|
||||
def test_asset_source_api(client, service):
|
||||
asset = service.content_asset(
|
||||
TemplateContent(type="template", template="quote", data={"quote": "Source"})
|
||||
)
|
||||
response = client.get("/api/v1/assets/" + asset["asset_id"] + "/source")
|
||||
assert (
|
||||
response.status_code == 200
|
||||
and response.json()["source"]["data"]["quote"] == "Source"
|
||||
)
|
||||
assert client.get("/api/v1/devices/living-room/current-content").status_code == 200
|
||||
@@ -0,0 +1,127 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
from pigway_frame_flow.models import WeatherConfig
|
||||
from pigway_frame_flow.providers.weather import ConfigurableWeatherProvider
|
||||
|
||||
|
||||
def transport():
|
||||
response = Mock()
|
||||
response.json.return_value = {
|
||||
"current": {
|
||||
"temperature_2m": 24,
|
||||
"weather_code": 3,
|
||||
"time": "2026-10-02T10:00",
|
||||
},
|
||||
"daily": {
|
||||
"time": ["2026-10-02"],
|
||||
"weather_code": [61],
|
||||
"temperature_2m_min": [20],
|
||||
"temperature_2m_max": [26],
|
||||
"precipitation_probability_max": [80],
|
||||
},
|
||||
}
|
||||
return Mock(return_value=response)
|
||||
|
||||
|
||||
def test_current_forecast_and_cache(service):
|
||||
get = transport()
|
||||
provider = ConfigurableWeatherProvider(service.db, get)
|
||||
provider.configure(WeatherConfig(provider="open-meteo"))
|
||||
assert provider.get_current()["temperature"] == "24°C"
|
||||
assert provider.get_forecast()[0]["rain_probability"] == 80
|
||||
assert get.call_count == 1
|
||||
assert get.call_args.kwargs["timeout"] == 8
|
||||
assert not get.call_args.kwargs["allow_redirects"]
|
||||
|
||||
|
||||
def test_weather_stale_and_error_backoff(service):
|
||||
get = transport()
|
||||
provider = ConfigurableWeatherProvider(service.db, get)
|
||||
provider.configure(WeatherConfig(provider="open-meteo"))
|
||||
provider.get_current()
|
||||
provider.next_fetch = 0
|
||||
get.side_effect = TimeoutError("private transport detail")
|
||||
assert provider.get_current()["stale"]
|
||||
assert provider.get_current()["temperature"] == "24°C"
|
||||
assert get.call_count == 2
|
||||
assert "private" not in str(provider.get_current())
|
||||
|
||||
|
||||
def test_unavailable_never_masquerades_as_mock(service):
|
||||
get = Mock(side_effect=TimeoutError("secret"))
|
||||
provider = ConfigurableWeatherProvider(service.db, get)
|
||||
provider.configure(WeatherConfig(provider="open-meteo"))
|
||||
current = provider.get_current()
|
||||
assert not current["mock"] and not current["available"] and current["stale"]
|
||||
assert "secret" not in str(current)
|
||||
|
||||
|
||||
def test_provider_settings_persist_and_validate(client, service):
|
||||
assert client.get("/api/v1/providers/weather").json()["current"]["mock"]
|
||||
assert (
|
||||
client.put("/api/v1/settings/weather", json={"latitude": 100}).status_code
|
||||
== 422
|
||||
)
|
||||
assert (
|
||||
client.put(
|
||||
"/api/v1/settings/weather",
|
||||
json={
|
||||
"provider": "mock",
|
||||
"location": "Test town",
|
||||
"latitude": 30,
|
||||
"longitude": 121,
|
||||
},
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert ConfigurableWeatherProvider(service.db).config().location == "Test town"
|
||||
|
||||
|
||||
def test_manual_job_run_and_preview_are_distinct(client, service):
|
||||
job = client.post(
|
||||
"/api/v1/jobs",
|
||||
json={
|
||||
"name": "Preview",
|
||||
"enabled": False,
|
||||
"schedule": {"type": "daily", "time": "07:00"},
|
||||
"source": {"type": "template", "template": "quote", "data": {}},
|
||||
},
|
||||
).json()
|
||||
assert client.get("/api/v1/jobs/" + job["id"]).status_code == 200
|
||||
preview = client.post("/api/v1/jobs/" + job["id"] + "/preview").json()
|
||||
service.process_one()
|
||||
assert service.operation(preview["operation_id"])["status"] == "COMPLETED"
|
||||
assert service.registry.get("living-room").client.uploads == 0
|
||||
service.settings.mock = False
|
||||
assert client.post("/api/v1/jobs/" + job["id"] + "/run").status_code == 403
|
||||
service.settings.mock = True
|
||||
first = client.post(
|
||||
"/api/v1/jobs/" + job["id"] + "/run", headers={"Idempotency-Key": "manual-test"}
|
||||
)
|
||||
second = client.post(
|
||||
"/api/v1/jobs/" + job["id"] + "/run", headers={"Idempotency-Key": "manual-test"}
|
||||
)
|
||||
assert first.status_code == 202 and first.json() == second.json()
|
||||
|
||||
|
||||
def test_image_url_allowlist_is_persistent_and_restrictive(client, service):
|
||||
assert client.get("/api/v1/settings/image-url").json()["allowed_hosts"] == []
|
||||
assert (
|
||||
client.put(
|
||||
"/api/v1/settings/image-url",
|
||||
json={"allowed_hosts": ["https://example.com"]},
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
assert (
|
||||
client.put(
|
||||
"/api/v1/settings/image-url", json={"allowed_hosts": ["*.example.com"]}
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
assert client.put(
|
||||
"/api/v1/settings/image-url",
|
||||
json={"allowed_hosts": ["EXAMPLE.com", "example.com"]},
|
||||
).json()["allowed_hosts"] == ["example.com"]
|
||||
assert service.settings.image_url_hosts == ["example.com"]
|
||||
assert service.db.one("SELECT payload FROM app_config WHERE key='image_url_hosts'")
|
||||
Reference in New Issue
Block a user