feat: content provenance and complete frame management workflows

This commit is contained in:
Pigway
2026-10-02 09:19:25 +08:00
parent f7c890c551
commit e67b4ceb6e
18 changed files with 918 additions and 18 deletions
+140
View File
@@ -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"
]
}
}
+87
View File
@@ -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
+127
View File
@@ -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'")