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
+153 -3
View File
@@ -23,6 +23,7 @@ from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import (
Accepted,
Asset,
CurrentDisplay,
Device,
DisplayRequest,
ErrorResponse,
@@ -31,9 +32,11 @@ from pigway_frame_flow.models import (
Health,
History,
ImageContent,
ImageURLConfig,
Job,
JobInput,
Operation,
Options,
Playlist,
PlaylistInput,
Profile,
@@ -46,8 +49,10 @@ from pigway_frame_flow.models import (
TemplateInfo,
URLContent,
Version,
WeatherConfig,
WeatherView,
)
from pigway_frame_flow.providers.weather import MockWeatherProvider
from pigway_frame_flow.providers.weather import ConfigurableWeatherProvider
from pigway_frame_flow.renderers.images import Assets, convert
from pigway_frame_flow.renderers.templates import TemplateRenderer
from pigway_frame_flow.scheduler.engine import Scheduler
@@ -61,9 +66,17 @@ def create_app(settings=None, start_worker=True):
(settings.data_dir / kind).mkdir(parents=True, exist_ok=True)
settings.config_dir.mkdir(parents=True, exist_ok=True)
db = Database(settings.data_dir / "frameflow.db")
import json
image_config = db.one("SELECT payload FROM app_config WHERE key='image_url_hosts'")
if image_config:
settings.image_url_hosts = ImageURLConfig.model_validate(
json.loads(image_config["payload"])
).allowed_hosts
registry = DeviceRegistry(settings)
assets = Assets(db, settings)
renderer = TemplateRenderer(db, settings, MockWeatherProvider(), assets)
weather = ConfigurableWeatherProvider(db)
renderer = TemplateRenderer(db, settings, weather, assets)
service = DisplayService(db, settings, registry, assets, renderer)
scheduler = Scheduler(service)
seed_client(db, settings.api_token)
@@ -737,9 +750,145 @@ def create_app(settings=None, start_worker=True):
api_token_configured=bool(settings.api_token),
timezone=settings.timezone,
device_id=settings.device_id,
weather_provider="mock",
weather_provider=weather.config().provider,
)
@app.get(
"/api/v1/devices/{device_id}/current-content",
response_model=CurrentDisplay,
tags=["Devices"],
dependencies=[Depends(require("devices:read"))],
)
def current_content(device_id: str):
return service.current_content(device_id)
@app.get(
"/api/v1/providers/weather",
response_model=WeatherView,
tags=["Providers"],
dependencies=[Depends(require("templates:read"))],
)
def weather_view():
values = weather.fetch()
return WeatherView(config=weather.config(), **values)
@app.put(
"/api/v1/settings/weather",
response_model=WeatherConfig,
tags=["Settings"],
dependencies=[Depends(require("settings:write"))],
)
def weather_config(payload: WeatherConfig):
return weather.configure(payload)
@app.get(
"/api/v1/assets/{asset_id}/source",
response_model=dict[str, Any],
tags=["Templates"],
dependencies=[Depends(require("templates:read"))],
)
def asset_source(asset_id: str):
assets.info(asset_id)
row = db.one("SELECT payload FROM asset_sources WHERE asset_id=?", (asset_id,))
import json
return {
"source_known": bool(row),
"source": json.loads(row["payload"]) if row else None,
}
@app.get(
"/api/v1/jobs/{job_id}",
response_model=Job,
tags=["Automation"],
dependencies=[Depends(require("jobs:read"))],
)
def get_job(job_id: str):
job = next((j for j in scheduler.jobs() if j["id"] == job_id), None)
if not job:
raise FlowError(E.NOT_FOUND, "Job not found", 404)
return job
@app.post(
"/api/v1/jobs/{job_id}/run",
response_model=Accepted,
status_code=202,
tags=["Automation"],
dependencies=[Depends(require("jobs:write"))],
)
def run_job(
job_id: str,
request: Request,
idempotency_key: str | None = Header(default=None),
):
job = JobInput.model_validate(
{k: v for k, v in get_job(job_id).items() if k in JobInput.model_fields}
)
write_guard(job.device_id)
if job.source.type == "restore":
return submit(request, "restore", {}, job.device_id, idempotency_key)
payload = DisplayRequest(
device_id=job.device_id,
content=job.source,
options=Options(
duration=job.display_duration,
restore_previous=job.restore_previous,
priority=job.priority,
),
)
service.validate(payload)
return submit(
request,
"display",
payload.model_dump(mode="json"),
job.device_id,
idempotency_key,
)
@app.post(
"/api/v1/jobs/{job_id}/preview",
response_model=Accepted,
status_code=202,
tags=["Automation"],
dependencies=[Depends(require("render:write"))],
)
def preview_job(
job_id: str,
request: Request,
idempotency_key: str | None = Header(default=None),
):
source = get_job(job_id)["source"]
if source["type"] != "template":
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "Job preview requires a template source", 422
)
return submit(
request, "render", source, get_job(job_id)["device_id"], idempotency_key
)
@app.get(
"/api/v1/settings/image-url",
response_model=ImageURLConfig,
tags=["Settings"],
dependencies=[Depends(require("admin"))],
)
def image_url_config():
return ImageURLConfig(allowed_hosts=settings.image_url_hosts)
@app.put(
"/api/v1/settings/image-url",
response_model=ImageURLConfig,
tags=["Settings"],
dependencies=[Depends(require("settings:write"))],
)
def set_image_url_config(payload: ImageURLConfig):
settings.image_url_hosts = payload.allowed_hosts
db.execute(
"INSERT OR REPLACE INTO app_config VALUES('image_url_hosts',?)",
(encode(payload.model_dump()),),
)
return payload
@app.get("/docs", include_in_schema=False)
def api_docs():
return get_swagger_ui_html(
@@ -764,6 +913,7 @@ def create_app(settings=None, start_worker=True):
def static(filename: str):
if filename not in [
"app.js",
"features.js",
"style.css",
"i18n.js",
"swagger-ui-bundle.js",