mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
fix(web): plugin config page survives stored values the schema outgrew (#578)
Two stored shapes broke the config form: * A scalar under a field that is now an object. News' dynamic_duration was a boolean and is becoming an object; render_nested_section did `key in true` and the whole page failed to render. Look into dicts only, and carry a legacy boolean over as the object's `enabled`, so the next save upgrades it without switching the feature off. * A custom feed logo with a path but no id. The template always emitted an empty `logo.id` input, which the save route parsed to null, failing the id's string type on every save. Emit it only when there is an id, as custom-feeds.js already does. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"""Stored values the current schema no longer describes must not break the form.
|
||||
|
||||
Two cases from the news plugin:
|
||||
|
||||
* ``global.dynamic_duration`` used to be a boolean and is now an object. An
|
||||
install that has not saved since still holds ``true``; rendering the nested
|
||||
section did ``key in true`` and the whole config page failed to load.
|
||||
* A custom feed logo stored with a ``path`` but no ``id``. The template always
|
||||
emitted an empty ``logo.id`` input, which the save route parsed to ``None``
|
||||
and the schema rejected (``Expected type string``) -- 400 on every save.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.datastructures import MultiDict
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"global": {"type": "object", "properties": {
|
||||
"dynamic_duration": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"min_duration_seconds": {"type": "integer", "default": 30,
|
||||
"minimum": 10, "maximum": 300},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}, "additionalProperties": False},
|
||||
"feeds": {"type": "object", "properties": {
|
||||
"custom_feeds": {
|
||||
"type": "array",
|
||||
"x-widget": "custom-feeds",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"url": {"type": "string"},
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"logo": {"type": "object", "required": ["path"],
|
||||
"properties": {"id": {"type": "string"},
|
||||
"path": {"type": "string"}}},
|
||||
},
|
||||
"required": ["name", "url"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}, "additionalProperties": False},
|
||||
},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
LOGO_PATH = "assets/plugins/news/uploads/verge.png"
|
||||
STORED = {
|
||||
"enabled": True,
|
||||
"global": {"dynamic_duration": True},
|
||||
"feeds": {"custom_feeds": [{"name": "Verge",
|
||||
"url": "https://www.theverge.com/rss/index.xml",
|
||||
"enabled": True,
|
||||
"logo": {"path": LOGO_PATH}}]},
|
||||
}
|
||||
|
||||
|
||||
class _FormFields(HTMLParser):
|
||||
"""Collect what a browser would submit from the rendered form."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.pairs = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
a = dict(attrs)
|
||||
if tag != "input" or not a.get("name") or "disabled" in a:
|
||||
return
|
||||
if a.get("type") in ("checkbox", "radio") and "checked" not in a:
|
||||
return
|
||||
if a.get("type") in ("button", "submit", "file"):
|
||||
return
|
||||
self.pairs.append((a["name"], a.get("value", "")))
|
||||
|
||||
|
||||
def _render():
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
)
|
||||
plugin = {"id": "demo", "name": "Demo", "description": "", "enabled": True,
|
||||
"author": "me", "version": "1.0.0"}
|
||||
return env.get_template("v3/partials/plugin_config.html").render(
|
||||
plugin=plugin, schema=SCHEMA, config=json.loads(json.dumps(STORED)))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def post(tmp_path):
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
from web_interface.blueprints import api_v3 as a
|
||||
|
||||
originals = {k: getattr(a.api_v3, k, None)
|
||||
for k in ("config_manager", "schema_manager", "plugin_manager")}
|
||||
|
||||
pdir = tmp_path / "plugins" / "demo"
|
||||
pdir.mkdir(parents=True)
|
||||
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
|
||||
|
||||
store = {"demo": json.loads(json.dumps(STORED))}
|
||||
cm = MagicMock()
|
||||
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
|
||||
cm.get_raw_file_content.return_value = {}
|
||||
cm.get_config_path.return_value = str(tmp_path / "config.json")
|
||||
|
||||
def _save(cfg, **_kw):
|
||||
store.clear()
|
||||
store.update(cfg)
|
||||
return type("R", (), {"status": type("S", (), {"value": "success"})(),
|
||||
"message": None})()
|
||||
|
||||
cm.save_config_atomic.side_effect = _save
|
||||
|
||||
a.api_v3.config_manager = cm
|
||||
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
|
||||
project_root=tmp_path)
|
||||
pm = MagicMock()
|
||||
pm.plugins = {}
|
||||
pm.get_plugin.return_value = None
|
||||
a.api_v3.plugin_manager = pm
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
|
||||
client = app.test_client()
|
||||
|
||||
def _post(pairs):
|
||||
resp = client.post("/api/v3/plugins/config?plugin_id=demo",
|
||||
data=MultiDict(pairs))
|
||||
return resp, store.get("demo", {})
|
||||
|
||||
try:
|
||||
yield _post
|
||||
finally:
|
||||
for k, v in originals.items():
|
||||
setattr(a.api_v3, k, v)
|
||||
|
||||
|
||||
def test_a_scalar_stored_under_an_object_field_still_renders():
|
||||
html = _render()
|
||||
assert 'name="global.dynamic_duration.min_duration_seconds"' in html
|
||||
|
||||
|
||||
def test_a_logo_without_an_id_posts_no_id_field():
|
||||
parser = _FormFields()
|
||||
parser.feed(_render())
|
||||
names = [name for name, _ in parser.pairs]
|
||||
assert "feeds.custom_feeds.0.logo.path" in names
|
||||
assert "feeds.custom_feeds.0.logo.id" not in names
|
||||
|
||||
|
||||
def test_saving_that_form_succeeds_and_upgrades_the_legacy_value(post):
|
||||
parser = _FormFields()
|
||||
parser.feed(_render())
|
||||
resp, cfg = post(parser.pairs)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert isinstance(cfg["global"]["dynamic_duration"], dict)
|
||||
# The legacy `true` was the on switch; upgrading must not turn it off.
|
||||
assert cfg["global"]["dynamic_duration"]["enabled"] is True
|
||||
assert cfg["global"]["dynamic_duration"]["min_duration_seconds"] == 30
|
||||
assert cfg["feeds"]["custom_feeds"][0]["logo"] == {"path": LOGO_PATH}
|
||||
@@ -613,7 +613,11 @@
|
||||
{% if logo_path %}
|
||||
<img src="/{{ logo_path }}" alt="Logo" loading="lazy" decoding="async" class="w-8 h-8 object-cover rounded border" id="{{ field_id }}_logo_preview_{{ item_index }}">
|
||||
<input type="hidden" name="{{ full_key }}.{{ item_index }}.logo.path" value="{{ logo_path }}">
|
||||
<input type="hidden" name="{{ full_key }}.{{ item_index }}.logo.id" value="{{ logo_value.get('id', '') }}">
|
||||
{# Only when there is one, as custom-feeds.js does: a blank id
|
||||
is saved as null and fails the id's string type. #}
|
||||
{% if logo_value.get('id') %}
|
||||
<input type="hidden" name="{{ full_key }}.{{ item_index }}.logo.id" value="{{ logo_value.get('id') }}">
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-xs text-gray-400">No logo</span>
|
||||
{% endif %}
|
||||
@@ -1108,8 +1112,21 @@
|
||||
{% set section_id = (plugin_id ~ '-section-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
|
||||
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
|
||||
{% set description = prop.description if prop.description else '' %}
|
||||
{% set nested_value = value if value else {} %}
|
||||
|
||||
{# Only a dict can be looked into. A schema that turns a legacy scalar into
|
||||
an object (news' dynamic_duration: true -> {enabled, ...}) still finds
|
||||
the scalar in config.json until the next save, and `key in true` is a
|
||||
TypeError that takes down the whole config page. A legacy boolean was
|
||||
the on/off switch the object's `enabled` now holds, so carry it over --
|
||||
rendering {} would draw that checkbox unchecked and the next save would
|
||||
quietly turn the feature off. The save then writes the object. #}
|
||||
{% if value is mapping %}
|
||||
{% set nested_value = value %}
|
||||
{% elif value is boolean and 'enabled' in (prop.properties or {}) %}
|
||||
{% set nested_value = {'enabled': value} %}
|
||||
{% else %}
|
||||
{% set nested_value = {} %}
|
||||
{% endif %}
|
||||
|
||||
<div class="nested-section border border-gray-300 rounded-lg mb-4">
|
||||
<button type="button"
|
||||
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors rounded-t-lg"
|
||||
|
||||
Reference in New Issue
Block a user