diff --git a/README.md b/README.md index d4d5a5d..6edaa07 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ uv run paperpi run --config paperpi.toml --out screen/ --state-dir state/ --heal ### The web interface -`paperpi run` also starts the web interface, on port 8080. Open it by the Pi's IP address on a phone or computer on the same home network, for example `http://192.168.1.20:8080` (your router's list of devices shows the Pi's address; on the Pi, `hostname -I` prints it). Names such as `paperpi.local` are refused for now, for safety (see [docs/decisions/web-interface.md](docs/decisions/web-interface.md)). It has two pages for plugins: **Active Plugins** (switch plugins on or off, move them up or down, remove one) and **Plugin Library** (every plugin that comes with PaperPi; add one at the end of the list). A change there is saved in the config file and applies at once. Comments in the file stay, except the comments of a plugin you remove and a comment on the same line as `enabled =`. If the config file changed since the page was opened, is written in a way the web interface can't change safely, or has a problem that stops PaperPi from using it, nothing is saved and the page says so. A plugin that needs settings (such as `met_no`, which needs a place and your email address) is added switched off; until the settings page follows (issue #238), fill those in in the config file. +`paperpi run` also starts the web interface, on port 8080. Open it by the Pi's IP address on a phone or computer on the same home network, for example `http://192.168.1.20:8080` (your router's list of devices shows the Pi's address; on the Pi, `hostname -I` prints it). Names such as `paperpi.local` are refused for now, for safety (see [docs/decisions/web-interface.md](docs/decisions/web-interface.md)). It has three pages for plugins: **Active Plugins** (switch plugins on or off, move them up or down, remove one, open its settings), **Plugin Library** (every plugin that comes with PaperPi; add one at the end of the list) and each plugin's **Settings** (every setting with its help text and default; errors are shown next to the field). A change there is saved in the config file and applies at once. Comments in the file stay, except the comments of a plugin you remove and a comment written after a setting, on the same line, when the web interface changes that setting. If the config file changed since the page was opened, is written in a way the web interface can't change safely, or has a problem that stops PaperPi from using it, nothing is saved and the page says so. A plugin that needs settings (such as `met_no`, which needs a place and your email address) is added switched off, and its Settings page opens to fill them in. - **First visit:** the first person to open it sets the web password (at least 8 characters). The browser then stays logged in for a year, until **Log out**. Log out only logs out that browser; a new password logs out every browser. - **Forgotten password:** you need access to the Pi itself (a keyboard and screen, or SSH). diff --git a/docs/decisions/web-interface.md b/docs/decisions/web-interface.md index 56699c4..100b147 100644 --- a/docs/decisions/web-interface.md +++ b/docs/decisions/web-interface.md @@ -24,8 +24,8 @@ In v2 the web interface is the main way to set up and change PaperPi: plugins, s *Added in M5 part 2b (issue #238), agreed with txoof on 2026-10-07.* - **Active Plugins** lists every `[[plugin]]` block in the config file, in file order (the order the plugins take turns), also blocks with errors. Each has: Switch on / Switch off, **Up** and **Down** buttons (no dragging, so no extra JavaScript library; works the same on a phone), and Remove, which asks first. - **Plugin Library** lists every plugin type that comes with PaperPi, with its one-line description, except `default` (PaperPi's own "nothing to show" message) and `debugging` (for testing PaperPi). Choosing one asks for a name (a free one is suggested, for example "Basic clock 2"; it may be left empty) and adds the block at the end of Active Plugins with a new `id` (see `config-format.md`, M5 part 3a-0). Active Plugins shows the name, and the `id` next to the type when it differs, so two plugins with the same name can be told apart. Sample pictures follow in part 3b. -- A plugin with required settings (see `plugin-interface.md`) is added **switched off**, and the add page lists those settings with their help text (for `met_no`: a real email address, as met.no's terms ask). Active Plugins shows "Needs settings: ..." and can't switch it on until they are filled in. -- Each change is saved at once (nothing else in the config file changes: comments stay, and the comments just above a `[[plugin]]` line move and are removed with its block; a comment on the same line as `enabled =` is lost) and PaperPi reloads the file, so it applies at once. +- A plugin with required settings (see `plugin-interface.md`) is added **switched off**, and the add page lists those settings with their help text (for `met_no`: a real email address, as met.no's terms ask). Active Plugins shows "Needs settings: ..." and can't switch it on until they are filled in. *M5 part 3a-2 (agreed with txoof on 2026-10-08):* after adding, the new plugin's settings page opens at once, and each plugin in Active Plugins has a Settings link. The settings page also has a folded "Technical information" part with the plugin's `id`, type and storage folder. +- Each change is saved at once (nothing else in the config file changes: comments stay, and the comments just above a `[[plugin]]` line move and are removed with its block; a comment on the same line as a setting that changes, such as `enabled =`, is lost) and PaperPi reloads the file, so it applies at once. - **Hand edits not applied yet:** a change from the web interface is made to the file as it is on disk, so hand edits stay and apply together with it. The page then says "Your hand edits to the config file were applied too." (The other choice, refusing to save until the hand edits are applied, needs an extra step every time.) - A change names its block by place and `id`. If the block was moved or its `id` changed by hand since the page was shown, nothing is changed and the page says the file changed meanwhile. A file the web interface can't change safely (for example a `[[plugin]]` line written in an unusual way) is never saved wrongly: every change is read back and compared before saving. A change is also refused while the file has a problem that stops PaperPi from using it (PaperPi then runs on the last good copy, so the change would not apply), and when the file would become larger than PaperPi reads. @@ -33,7 +33,7 @@ In v2 the web interface is the main way to set up and change PaperPi: plugins, s - One page handles every plugin. It reads the plugin's settings description (see `plugin-interface.md`) and builds the form from it: number field, dropdown, password field for API keys, and so on. - On Save, the values are checked with the same description. Errors are shown next to the field. A saved change applies at once (see `live-config-reload.md`). -- A setting that needs more than a plain field can name an input type from a small list in the web interface, e.g. `location` (lat/lon lookup), `server_search` (find music servers), `layout_picker`. New input types are added when a plugin needs one. +- A setting that needs more than a plain field can name an input type from a small list in the web interface, e.g. `location` (lat/lon lookup), `server_search` (find music servers), `layout_picker`. New input types are added when a plugin needs one. *Built in M5 part 3a-2:* a plugin names one with `paperpi.plugin.setting(..., helper="location")`; the web interface's list (`paperpi.web.helpers`) holds a function per name that adds its part under the field. A name it doesn't know adds nothing, so the plain field still works. The first helper is `location` (part 3c). - Plugins added later show up without changes to the web code. - *Agreed with txoof on 2026-10-08 (M5 part 3a):* the page shows the plugin's `name` first, then its own settings, then display time, refresh, layout and level; the other shared settings (time limit, alert and storage settings) are folded under "More settings", which opens by itself when one of them has an error or a value that is not the default. Each field shows its value, or the default when the file doesn't set it. - How a save works (M5 part 3a-1): only settings that changed are written. An empty field means "the default" (an empty check box: off; an empty list: nothing picked); a value changed to the default is taken out of the file and becomes the comment `# key = default` again (a value left as it is stays, also one written in the file that equals the default), so the help text above it still fits. A new setting takes the place of its `# key = ...` comment line when there is one. If any value has an error, nothing is saved; each error is shown next to its field, with what was typed (never a secret). A secret (such as an API key) is never shown on the page; leaving its field empty keeps the saved one. A setting the form can't show (a group of settings, a list of numbers) is shown as it is in the file, to be changed there. A setting written over several lines in the file is never changed by the page. diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 486194b..37a395c 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -80,8 +80,8 @@ Each setting is a field of the plugin's `Settings` class, with a type, a default - Every setting needs a default. - Don't use the names of the shared settings, which every `[[plugin]]` block already has: `id`, `name`, `type`, `enabled`, `level`, `display_time`, `refresh`, `time_limit`, `layout`, `alert_reminder`, `alert_max_time`. - Use `pydantic.SecretStr` as the type for API keys and passwords. PaperPi then never shows their values in error messages or logs. -- A setting the user must fill in before the plugin can work (a place, an email address, an API key) is made with `paperpi.plugin.setting(..., required=True)` instead of `Field`. It takes the same arguments as `Field`. Its default must be "not set": `None`, empty text or an empty `SecretStr` (text of only spaces also counts as not set). Until every required setting is filled in, the plugin is not shown and not counted as broken; the config check, `paperpi list` and the web interface's Active Plugins page say which settings are missing. The example block marks them "(required)". `setting` is also where later options for a single setting are added, such as a web interface helper that looks up latitude and longitude (M5 part 3c). -- The web interface shows the plugin's `description` in its Plugin Library, and the help text of each required setting on the page that adds the plugin. Write both for someone at home who is not a programmer. +- A setting the user must fill in before the plugin can work (a place, an email address, an API key) is made with `paperpi.plugin.setting(..., required=True)` instead of `Field`. It takes the same arguments as `Field`. Its default must be "not set": `None`, empty text or an empty `SecretStr` (text of only spaces also counts as not set). Until every required setting is filled in, the plugin is not shown and not counted as broken; the config check, `paperpi list` and the web interface's Active Plugins page say which settings are missing. The example block marks them "(required)". `setting` is also where other options for a single setting go: `helper="location"` names a web interface helper shown under the setting's field (the first one, a latitude and longitude lookup, comes in M5 part 3c; see `paperpi.web.helpers`). +- The web interface shows the plugin's `description` in its Plugin Library, and the help text (`description`) of every setting on the plugin's Settings page, next to its field. Write both for someone at home who is not a programmer. The settings page is built from the settings description: a `Literal` becomes a choice list, `bool` a check box, `int`/`float` a number field (with its `ge`, `gt`, `le` and `lt` limits), `str` a text field (with `max_length`), `SecretStr` a password field, and a tuple or list of a `Literal` a list to tick. A setting of another kind is shown as it is in the file, to be changed there. ```python from paperpi.plugin import PluginSettings, setting diff --git a/src/paperpi/plugin.py b/src/paperpi/plugin.py index a4852df..364fc80 100644 --- a/src/paperpi/plugin.py +++ b/src/paperpi/plugin.py @@ -102,11 +102,12 @@ class Settings(PluginSettings): _OPTIONS = "paperpi" -def setting(default: Any, *, required: bool = False, **field: Any) -> Any: +def setting( + default: Any, *, required: bool = False, helper: str | None = None, **field: Any +) -> Any: """A plugin setting: pydantic's ``Field`` (with the same ``description``, ``ge``, ``max_length``, ...) plus what PaperPi needs to know about it. This is the one place a - setting gets PaperPi's own options; later ones (such as a web interface helper that - looks up a place's latitude and longitude, M5 part 3c) are added here too:: + setting gets PaperPi's own options:: lat: float | None = setting(None, required=True, description="Latitude") @@ -114,20 +115,39 @@ def setting(default: Any, *, required: bool = False, **field: Any) -> Any: address, an API key). Its default must be "not set" (see :func:`is_set`). A plugin with a required setting that is not set is not shown; the config check and ``paperpi list`` and the web interface's Active Plugins page say which settings it needs. + + ``helper``: the name of a web interface helper for this setting, shown under its field + on the settings page, for example ``"location"`` (look up a place's latitude and + longitude, M5 part 3c). A name the web interface doesn't know is left out, so the + plain field still works. See ``paperpi.web.helpers``. """ extra = field.pop("json_schema_extra", None) or {} if not isinstance(extra, dict): raise TypeError("setting() takes json_schema_extra only as a dictionary") - if required: - extra = extra | {_OPTIONS: {"required": True}} + options = {"required": True} if required else {} + if helper is not None: + options["helper"] = helper + if options: + extra = extra | {_OPTIONS: options} return Field(default, json_schema_extra=extra or None, **field) -def is_required(info: FieldInfo) -> bool: - """True for a setting made with ``setting(required=True)``.""" +def _options(info: FieldInfo) -> dict[str, Any]: + """PaperPi's own options of a setting made with :func:`setting`.""" extra = info.json_schema_extra options = extra.get(_OPTIONS) if isinstance(extra, dict) else None - return isinstance(options, dict) and bool(options.get("required")) + return options if isinstance(options, dict) else {} + + +def is_required(info: FieldInfo) -> bool: + """True for a setting made with ``setting(required=True)``.""" + return bool(_options(info).get("required")) + + +def helper_of(info: FieldInfo) -> str | None: + """The web interface helper a setting names (``setting(helper=...)``), or ``None``.""" + helper = _options(info).get("helper") + return helper if isinstance(helper, str) else None def is_set(value: Any) -> bool: diff --git a/src/paperpi/web/app.py b/src/paperpi/web/app.py index d0838cf..17852b0 100644 --- a/src/paperpi/web/app.py +++ b/src/paperpi/web/app.py @@ -8,9 +8,11 @@ ``/login`` log in; also says how to reset a forgotten password ``/logout`` log out (a button on every page) ``/plugins`` Active Plugins: the plugins in the config file; switch on or off, move - up or down, remove (``/plugins//...``, n = place in the file, - counted from 0) -``/library`` Plugin Library: every plugin type; ``/library/`` adds one + up or down, remove, settings (``/plugins//...?id=``, n = place + in the file, counted from 0, and the plugin's id to check it is + still there) +``/library`` Plugin Library: every plugin type; ``/library/`` adds one and + opens its settings ``/static/...`` the style sheet and htmx (a small JavaScript file that updates one part of a page without loading the whole page again) ================= ============================================================== @@ -37,10 +39,13 @@ from starlette.concurrency import run_in_threadpool from .. import __version__ +from ..config import folder_name from .auth import COOKIE_SECONDS, Auth, new_cookie from .config_file import EditError +from .forms import Read, fields +from .helpers import helper_html from .password import PasswordError, password_problem -from .plugins import PluginEditor, library, library_item +from .plugins import FormErrors, PluginEditor, library, library_item COOKIE = "paperpi_login" _HERE = Path(__file__).parent @@ -58,7 +63,8 @@ def create_app(auth: Auth, editor: PluginEditor | None = None) -> FastAPI: app = FastAPI(title="PaperPi", docs_url=None, redoc_url=None, openapi_url=None) app.mount("/static", StaticFiles(directory=_HERE / "static"), name="static") templates = Jinja2Templates(directory=_HERE / "templates") - templates.env.globals.update(version=__version__, auth=auth) + templates.env.globals.update(version=__version__, auth=auth, helper_html=helper_html) + templates.env.filters["number"] = _number templates.env.filters["sentence"] = lambda text: text[:1].upper() + text[1:] def page(request: Request, template: str, status: int = 200, **values) -> HTMLResponse: @@ -173,7 +179,6 @@ def plugin_list(request: Request, status: int = 200, problem: str | None = None) done=done if done in ("saved", "added", "removed") else None, name=name, hand_edits=request.query_params.get("hand") == "1", - config_file=auth.config_file, ) def saved(done: str, hand_edits: bool, plugin_id: str = "") -> RedirectResponse: @@ -219,6 +224,61 @@ def remove(request: Request, index: int, plugin_id: _FormId = ""): return plugin_list(request, 409, str(error)) return saved("removed", hand_edits) + def settings_page( + request: Request, + index: int, + plugin_id: str, + status: int = 200, + found: Read | None = None, + ) -> HTMLResponse: + try: + plugin, block = editor.settings(index, plugin_id) + row = editor.row(index, plugin_id) + except EditError as error: + return plugin_list(request, 409, str(error)) + errors = found.errors if found else {} + shown = fields(plugin, block, found.sent if found else None, errors) + groups = {g: [f for f in shown if f.group == g] for g in ("name", "own", "common", "more")} + # Errors that belong to no field on the page (a broken id, ...). + other = [m for k, m in errors.items() if k not in {f.key for f in shown}] + done = request.query_params.get("done") + return page( + request, + "settings.html", + status, + row=row, + plugin=plugin, + groups=groups, + more_open=any(f.error or f.changed for f in groups["more"]), + folder=folder_name(row.id), + done=done if done in ("saved", "added") and not found else None, + hand_edits=request.query_params.get("hand") == "1", + problem="; ".join(other) if other else FormErrors.MESSAGE if found else None, + ) + + @app.get("/plugins/{index}/settings", response_class=HTMLResponse) + def settings_form(request: Request, index: int, plugin_id: _QueryId = ""): + return settings_page(request, index, plugin_id) + + @app.post("/plugins/{index}/settings", response_class=HTMLResponse) + async def save_settings(request: Request, index: int): + form = await request.form() + plugin_id = str(form.get("id", "")) + sent = {k: [str(v) for v in form.getlist(k)] for k in form if k != "id"} + try: + hand_edits = await run_in_threadpool(editor.save_settings, index, plugin_id, sent) + except FormErrors as error: + return await run_in_threadpool( + settings_page, request, index, plugin_id, 400, error.found + ) + except EditError as error: + return await run_in_threadpool(plugin_list, request, 409, str(error)) + return settings_saved(index, plugin_id, "saved", hand_edits) + + def settings_saved(index: int, plugin_id: str, done: str, hand_edits: bool): + values = {"id": plugin_id, "done": done} | ({"hand": "1"} if hand_edits else {}) + return RedirectResponse(f"/plugins/{index}/settings?{urlencode(values)}", 303) + @app.get("/library", response_class=HTMLResponse) def library_page(request: Request): return page(request, "library.html", items=library()) @@ -243,7 +303,15 @@ def add(request: Request, plugin_type: str, name: str = Form("")): hand_edits, plugin_id = editor.add(item, name) except EditError as error: return add_page(request, plugin_type, 400, name=name, problem=str(error)) - return saved("added", hand_edits, plugin_id) + # On to its settings page, to fill in what it needs (agreed with txoof, M5 part 3a). + try: + rows = editor.plugin_list().rows + except EditError: # the file can't be read a moment later: show what can be shown + rows = [] + index = next((r.index for r in rows if r.id == plugin_id), None) + if index is None: # moved away or removed by hand meanwhile + return saved("added", hand_edits, plugin_id) + return settings_saved(index, plugin_id, "added", hand_edits) @app.post("/logout") def logout(): @@ -254,6 +322,11 @@ def logout(): return app +def _number(value: float) -> str: + """A number as the pages show it: ``604800``, not ``604800.0``.""" + return str(int(value)) if float(value).is_integer() else str(value) + + def _known_host(host: str) -> bool: """True for an IP address or ``localhost``, with or without a port (see the top).""" if host.startswith("["): # an IPv6 address: [::1]:8080 diff --git a/src/paperpi/web/forms.py b/src/paperpi/web/forms.py index 85df4db..b9d6732 100644 --- a/src/paperpi/web/forms.py +++ b/src/paperpi/web/forms.py @@ -27,11 +27,11 @@ from dataclasses import dataclass, field from typing import Any, Literal -from pydantic import BaseModel, SecretStr, ValidationError +from pydantic import BaseModel, SecretBytes, SecretStr, ValidationError from pydantic.fields import FieldInfo from .. import example -from ..plugin import Plugin, PluginEntry, is_required +from ..plugin import Plugin, PluginEntry, helper_of, is_required #: The shared settings shown right after the plugin's own ones; the others are folded away #: under "More settings" (agreed with txoof, M5 part 3a). @@ -73,6 +73,10 @@ class FormField: """``number``: whole numbers only.""" max_length: int | None = None error: str = "" + helper: str | None = None + """The web interface helper shown under the field (see :mod:`paperpi.web.helpers`).""" + changed: bool = False + """The config file sets a value that is not the default.""" @dataclass(frozen=True) @@ -119,6 +123,8 @@ def fields( texts = [_text(v) for v in value] if isinstance(value, list | tuple) else [_text(value)] if kind == "file": texts = [example.toml_value(value) if value is not None else ""] + if _holds_secret(info.annotation) and texts[0]: + texts = ["(set; it holds a secret, so it is not shown)"] if sent is not None and key in sent and kind not in ("secret", "file"): # Without the empty value the page sends for a check box or list. texts = [t for t in sent[key] if t] or [""] @@ -136,14 +142,16 @@ def fields( selected=tuple(texts) if kind == "choices" else (), choices=tuple(_text(c) for c in allowed), values=tuple(allowed), - default="" if kind == "secret" else _text(shown[key]), + default=_default_text(kind, shown[key], info), required=is_required(info), is_set=kind == "secret" and bool(_text(value)), + changed=example.plain(value) != example.plain(shown[key]), minimum=_limit(info, "ge", "gt"), maximum=_limit(info, "le", "lt"), whole=_plain_type(info.annotation) is int, max_length=_limit(info, "max_length"), error=errors.get(key, ""), + helper=helper_of(info), ) ) order: list[Group] = ["name", "own", "common", "more"] @@ -249,6 +257,28 @@ def _limit(info: FieldInfo, *names: str) -> Any: return None +def _default_text(kind: Kind, default: Any, info: FieldInfo) -> str: + """The default as the page shows it next to the field (``""``: none).""" + if kind == "secret" or default is None: + return "" + if kind == "check": + return "on" if default else "off" + if kind == "choices": + return ", ".join(_text(v) for v in default) or "none" + if kind == "file": + return "" if _holds_secret(info.annotation) else example.toml_value(default) + return _text(default) + + +def _holds_secret(annotation: Any) -> bool: + """True when a setting of this type is, or holds, a secret (``SecretStr``).""" + if annotation in (SecretStr, SecretBytes): + return True + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + return any(_holds_secret(f.annotation) for f in annotation.model_fields.values()) + return any(_holds_secret(a) for a in typing.get_args(annotation)) + + def _text(value: Any) -> str: """``value`` as the form shows it.""" if value is None: diff --git a/src/paperpi/web/helpers.py b/src/paperpi/web/helpers.py new file mode 100644 index 0000000..0c2c7c6 --- /dev/null +++ b/src/paperpi/web/helpers.py @@ -0,0 +1,31 @@ +"""Setting helpers: an extra part of the settings page, under one setting's field, for a +setting that needs more than a plain field (``docs/decisions/web-interface.md``). + +A plugin names a helper with ``paperpi.plugin.setting(..., helper="location")``. A helper +here is a function that gets the field (:class:`paperpi.web.forms.FormField`) and returns +the HTML to show under it. A name that is not in :data:`HELPERS` (yet) shows nothing, so +the plain field still works. The first helper, ``location`` (look up a place's latitude +and longitude), comes in M5 part 3c; new ones are added when a plugin needs one. + +The page shows a helper's HTML as it is, and the field's texts come from the config file +or the form. So build the HTML with ``Markup("

{}

").format(...)`` or a template, +never with an f-string: those escape the texts (turn ``<`` into ``<`` and so on), so +typed text can't become part of the page. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from markupsafe import Markup + +from .forms import FormField + +#: The helpers the settings page knows, by name. +HELPERS: dict[str, Callable[[FormField], Markup]] = {} + + +def helper_html(field: FormField) -> Markup: + """The HTML of the helper ``field`` names, or nothing.""" + helper = HELPERS.get(field.helper) if field.helper else None + return helper(field) if helper is not None else Markup("") diff --git a/src/paperpi/web/plugins.py b/src/paperpi/web/plugins.py index 07009fa..849c24a 100644 --- a/src/paperpi/web/plugins.py +++ b/src/paperpi/web/plugins.py @@ -33,8 +33,10 @@ class FormErrors(config_file.EditError): """A settings form with values that can't be used; nothing was saved.""" + MESSAGE = "Nothing was saved: some settings can't be used; see the messages next to them." + def __init__(self, found: forms.Read): - super().__init__("Some settings can't be used; see the messages next to them.") + super().__init__(self.MESSAGE) self.found = found diff --git a/src/paperpi/web/static/style.css b/src/paperpi/web/static/style.css index 56d7036..2f8450c 100644 --- a/src/paperpi/web/static/style.css +++ b/src/paperpi/web/static/style.css @@ -22,3 +22,14 @@ code { background: #eee; padding: 0 0.2rem; } .buttons { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-top: 0.4rem; } .buttons form { margin: 0; display: flex; gap: 0.5rem; } .library li { margin: 0.5rem 0; } +.settings h2 { font-size: 1.1rem; margin-top: 1.5rem; } +.settings .field { margin: 0.9rem 0; } +.settings .field > label { display: block; margin: 0 0 0.25rem; } +.settings select { font-size: 1rem; padding: 0.3rem; } +.settings label.check { display: flex; gap: 0.4rem; align-items: center; margin: 0.25rem 0; } +.settings label.check input { width: auto; display: inline; } +.settings .wrong input, .settings .wrong select { border: 2px solid #a00; } +.settings .problem { margin: 0.25rem 0 0; } +.hint, .type, .required { color: #555; font-size: 0.9rem; } +.technical dt { font-weight: bold; margin-top: 0.5rem; } +.technical dd { margin-left: 0; } diff --git a/src/paperpi/web/templates/add.html b/src/paperpi/web/templates/add.html index e93adfd..d6d45d3 100644 --- a/src/paperpi/web/templates/add.html +++ b/src/paperpi/web/templates/add.html @@ -11,8 +11,8 @@

Add {{ item.type }}

    {% for key, help in item.required %}
  • {{ key }}: {{ help }}
  • {% endfor %}
-

Until the settings page is ready (a later version), type them into the config file on - the Pi ({{ auth.config_file }}), then switch the plugin on in Active Plugins.

+

Its settings page opens next: fill them in there, then switch the plugin on in Active + Plugins.

{% endif %}
diff --git a/src/paperpi/web/templates/plugins.html b/src/paperpi/web/templates/plugins.html index 240abb3..ab4449f 100644 --- a/src/paperpi/web/templates/plugins.html +++ b/src/paperpi/web/templates/plugins.html @@ -14,11 +14,6 @@

Active Plugins

{% for found in problems %}

{{ found|sentence }}

{% endfor %}

The plugins that are switched on take turns on the screen, in this order. Add a plugin from the Plugin Library.

-{% if rows | selectattr("missing") | list %} -

Some plugins need settings before they are shown. Until the settings page is -ready (a later version), type them into the config file on the Pi: -{{ config_file }}.

-{% endif %} {% if not rows and not problems %}

No plugins yet.

{% endif %}
    {% for row in rows %} @@ -41,6 +36,7 @@

    Active Plugins

    + {% if row.id %}Settings{% endif %} Remove diff --git a/src/paperpi/web/templates/settings.html b/src/paperpi/web/templates/settings.html new file mode 100644 index 0000000..cf8b1b4 --- /dev/null +++ b/src/paperpi/web/templates/settings.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} +{% set logged_in = true %} +{% block title %}Settings of {{ row.name }} ยท PaperPi{% endblock %} + +{% macro field(f) %} +
    + {% if f.kind == "check" %} + {# A browser sends nothing for a box that is off, so "false" goes first. #} + + + {% else %} + + {% if f.kind == "choice" %} + + {% elif f.kind == "choices" %} + {# A browser sends nothing for a list with nothing picked, so an empty value goes first. #} + +
    + {% for choice in f.choices %} + + {% endfor %} +
    + {% elif f.kind == "number" %} + {# A phone's number keys often have no minus, so only for numbers that can't be below 0. #} + = 0 %} + inputmode="{{ 'numeric' if f.whole else 'decimal' }}"{% endif %}> + {% elif f.kind == "text" %} + + {% elif f.kind == "secret" %} + + {% else %} +

    {{ f.value or "(not set)" }} + Change this one in the config file.

    + {% endif %} + {% if f.kind not in ("secret", "file") %} +
    + {%- if f.kind == "number" and (f.minimum is not none or f.maximum is not none) %} + {%- if f.minimum is not none %}From {{ f.minimum|number }}{% endif %} + {%- if f.maximum is not none %} up to {{ f.maximum|number }}{% endif %}. {% endif %} + {%- if f.default %}Default: {{ f.default }}.{% elif f.kind != "choices" %}Empty: not set.{% endif -%} +
    + {% endif %} + {% endif %} + {% if f.error %}

    {{ f.error|sentence }}

    {% endif %} + {{ helper_html(f) }} +
    +{% endmacro %} + +{% block main %} +

    {{ row.name }}

    +

    {{ row.type }}: {{ plugin.description }}

    +{% if done %} +

    + {%- if done == "added" %}Added. Fill in its settings here, then Save. + {%- else %}Saved. PaperPi applies the change now.{% endif %} + {%- if hand_edits %} Your hand edits to the config file were applied too.{% endif %}

    +{% endif %} +{% if problem %}

    {{ problem|sentence }}

    {% endif %} +{% if row.missing %} +

    Not shown until these settings are filled in: {{ row.missing|join(", ") }}. +{%- if not row.enabled %} Then switch it on in Active Plugins.{% endif %}

    +{% endif %} +{% for found in row.errors %}

    {{ found }}

    {% endfor %} + +
    + + {% for f in groups.name %}{{ field(f) }}{% endfor %} + {% if groups.own %} +

    Settings of this plugin

    + {% for f in groups.own %}{{ field(f) }}{% endfor %} + {% endif %} +

    When and how it is shown

    + {% for f in groups.common %}{{ field(f) }}{% endfor %} + + More settings + {% for f in groups.more %}{{ field(f) }}{% endfor %} + + + + +
    + Technical information +
    +
    ID
    {{ row.id }} (fixed; it names the storage folder)
    +
    Type
    {{ row.type }}
    +
    Storage folder
    plugins/{{ folder }}/ in PaperPi's state folder
    +
    Config file
    {{ auth.config_file }}, plugin block {{ row.index + 1 }}
    +
    +
    +{% endblock %} diff --git a/tests/test_web_plugins.py b/tests/test_web_plugins.py index ae6cae1..6e15d1a 100644 --- a/tests/test_web_plugins.py +++ b/tests/test_web_plugins.py @@ -159,8 +159,9 @@ def test_adding_a_plugin(client, cfg, reloads): added = blocks(cfg)[-1] assert re.fullmatch(r"word_clock-[0-9a-f]{8}", added["id"]) assert added == {"id": added["id"], "name": "Words", "type": "word_clock"} - assert response.headers["location"] == f"/plugins?done=added&id={added['id']}" - assert "Added Words." in client.get(response.headers["location"]).text + assert response.headers["location"] == f"/plugins/3/settings?id={added['id']}&done=added" + page = client.get(response.headers["location"]).text + assert "

    Words

    " in page and "Added. Fill in its settings here" in page assert reloads == [1] # The next one gets another name. client.post("/library/word_clock", data={"name": "Word clock"}) diff --git a/tests/test_web_settings.py b/tests/test_web_settings.py new file mode 100644 index 0000000..cade852 --- /dev/null +++ b/tests/test_web_settings.py @@ -0,0 +1,253 @@ +import tomllib +from typing import Literal + +import pytest +from fastapi.testclient import TestClient +from markupsafe import Markup +from pydantic import Field, SecretStr + +from paperpi import config +from paperpi.plugin import Plugin, PluginSettings, helper_of, is_required, ready, setting +from paperpi.web import auth, forms, helpers +from paperpi.web import plugins as web_plugins +from paperpi.web.app import create_app +from paperpi.web.plugins import PluginEditor + +CONFIG = """\ +config_version = 1 +[display] +type = "virtual" + +[[plugin]] +id = "Clock" +type = "basic_clock" + +[[plugin]] +id = "Weather" +name = "Weather Berlin" +type = "met_no" +lat = 52.52 +lon = 13.40 +enabled = false + +[[plugin]] +id = "Comic" +type = "xkcd_comic" +time_limit = 30 +""" + +# The test client opens the pages as a phone on the home network would: by IP address. +PI = "http://192.168.1.20:8080" + + +@pytest.fixture +def cfg(tmp_path): + path = tmp_path / "paperpi.toml" + path.write_text(CONFIG) + return path + + +@pytest.fixture +def reloads(): + return [] + + +@pytest.fixture +def client(cfg, reloads): + editor = PluginEditor(cfg, lambda: reloads.append(1)) + editor.loaded(CONFIG) + paperpi_auth = auth.Auth(cfg, config.WebSettings(login=False)) + return TestClient(create_app(paperpi_auth, editor), follow_redirects=False, base_url=PI) + + +def blocks(cfg): + return tomllib.loads(cfg.read_text())["plugin"] + + +def test_active_plugins_link_to_each_settings_page(client): + page = client.get("/plugins").text + assert 'Settings' in page + assert "type them into the config file" not in page + + +def test_the_page_shows_the_settings_in_their_groups(client): + page = client.get("/plugins/1/settings", params={"id": "Weather"}).text + assert "

    Weather Berlin

    " in page + assert page.index('name="name"') < page.index("Settings of this plugin") + assert page.index('name="lat"') < page.index("When and how it is shown") + assert page.index('name="display_time"') < page.index("More settings") + assert page.index("More settings") < page.index('name="storage_mb"') + assert 'value="52.52"' in page and "(required)" in page + assert "Not shown until these settings are filled in: email." in page + assert "
    \n More settings" in page # folded: all defaults + assert "Weather (fixed; it names the storage folder)" in page + assert "plugins/weather/" in page + + +def test_more_settings_open_by_themselves_when_one_is_not_the_default(client): + page = client.get("/plugins/2/settings", params={"id": "Comic"}).text + assert "
    \n More settings" in page + + +def test_saving_changes_the_file_and_applies_at_once(client, cfg, reloads): + form = {"id": "Weather", "email": "me@example.org", "lon": "13.5", "display_time": "120"} + response = client.post("/plugins/1/settings", data=form) + assert response.status_code == 303 + assert response.headers["location"] == "/plugins/1/settings?id=Weather&done=saved" + assert blocks(cfg)[1] == { + "id": "Weather", + "name": "Weather Berlin", + "type": "met_no", + "lat": 52.52, + "lon": 13.5, + "enabled": False, + "email": "me@example.org", + } + assert reloads == [1] + page = client.get(response.headers["location"]).text + assert "Saved. PaperPi applies the change now." in page + assert "Not shown until" not in page # email is set now + + +def test_values_that_cant_be_used_save_nothing_and_are_shown_again(client, cfg, reloads): + response = client.post( + "/plugins/1/settings", data={"id": "Weather", "lat": "91", "temperature": "F"} + ) + assert response.status_code == 400 + page = response.text + assert "Nothing was saved" in page + assert 'value="91"' in page and "Input should be less than or equal to 90" in page + assert '' in page + assert "level" in page and "Input should be" in page # the config check's message + + +def test_a_block_of_an_unknown_type_or_place_has_no_settings_page(client, cfg): + cfg.write_text(CONFIG.replace('type = "basic_clock"', 'type = "nothing"')) + response = client.get("/plugins/0/settings", params={"id": "Clock"}) + assert response.status_code == 409 and "its plugin type" in response.text + for index in (-1, 9): + assert client.get(f"/plugins/{index}/settings", params={"id": "Clock"}).status_code == 409 + + +def test_the_page_says_when_hand_edits_were_applied_too(client): + page = client.get("/plugins/0/settings", params={"id": "Clock", "done": "saved", "hand": "1"}) + assert "Your hand edits to the config file were applied too." in page.text