diff --git a/README.md b/README.md index ac337c9..c81a9b8 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)). For now it has the log-in and an empty home page; the pages for plugins and settings follow (issue #238). +`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. - **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/plugin-interface.md b/docs/decisions/plugin-interface.md index fb87614..63b2f91 100644 --- a/docs/decisions/plugin-interface.md +++ b/docs/decisions/plugin-interface.md @@ -50,7 +50,7 @@ The plugin never talks to the screen. Only the scheduler does. For sample images and tests, `fetch` is skipped and the sample data goes straight to `draw`. How to write a plugin: `docs/writing-plugins.md`. -**Update 2026-10-07 (M5 part 2a, issue #238, agreed with txoof):** a plugin can mark a setting as **required** with `setting(..., required=True)`, for settings it can't work without, such as an API key. The first ones are `lat`, `lon` and `email` of `met_no` and `moon_phase`: met.no's terms of service ask for a real email address, and a user should not start these plugins without giving one. A plugin that is missing a required setting can be in the config file and switched on, but it is **not shown and not counted as broken**: the config check gives a warning naming the missing settings, `paperpi list` shows "needs email" in its "on" column, and the web interface (part 2b) shows "Needs settings" in place of the on switch. The example config no longer fills in a made-up email address. `setting` is the one standard place for a setting's own options; the input helpers of `web-interface.md` (such as `location`, which looks up latitude and longitude; part 3c) will be options of `setting` too. +**Update 2026-10-07 (M5 part 2a, issue #238, agreed with txoof):** a plugin can mark a setting as **required** with `setting(..., required=True)`, for settings it can't work without, such as an API key. The first ones are `lat`, `lon` and `email` of `met_no` and `moon_phase`: met.no's terms of service ask for a real email address, and a user should not start these plugins without giving one. A plugin that is missing a required setting can be in the config file and switched on, but it is **not shown and not counted as broken**: the config check gives a warning naming the missing settings, `paperpi list` shows "needs email" in its "on" column, and the web interface's Active Plugins page shows "Needs settings: ..." and its Switch on button can't be used until they are filled in. The example config no longer fills in a made-up email address. `setting` is the one standard place for a setting's own options; the input helpers of `web-interface.md` (such as `location`, which looks up latitude and longitude; part 3c) will be options of `setting` too. ### How a plugin is run diff --git a/docs/decisions/web-interface.md b/docs/decisions/web-interface.md index 6c9ea22..5d9e54a 100644 --- a/docs/decisions/web-interface.md +++ b/docs/decisions/web-interface.md @@ -19,6 +19,16 @@ In v2 the web interface is the main way to set up and change PaperPi: plugins, s - Pages are built on the Pi. htmx updates parts of a page, such as the screen preview and the list of warnings. - A page that needs dragging or resizing gets a small JavaScript library made for that, loaded as a plain file with no build step. The first case will be the dashboard editor (see "Later"). +### Plugin list pages + +*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") and adds the block at the end of Active Plugins. 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. +- **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 name. If the block was moved or renamed 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. + ### Plugin settings forms - 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. diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 1a86ee3..4247a1d 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -80,7 +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: `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 and `paperpi list` (and from M5 part 2b the web interface) 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). +- 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. ```python from paperpi.plugin import PluginSettings, setting diff --git a/src/paperpi/cli.py b/src/paperpi/cli.py index 678b272..1a75764 100644 --- a/src/paperpi/cli.py +++ b/src/paperpi/cli.py @@ -283,7 +283,7 @@ def _run(args: argparse.Namespace) -> int: def load() -> config.Config: loaded = config.load(args.config, state_dir=args.state_dir) if web is not None and not loaded.from_last_good: - web.use(loaded.web) + web.use(loaded) return loaded try: @@ -325,7 +325,12 @@ def load() -> config.Config: # Imported here: the web packages take a moment to load, and no other command needs them. from .web import server - web = server.start(args.config, loaded.web) + web = server.start( + args.config, + loaded.web, + reload=scheduler.reload, + text=None if loaded.from_last_good else loaded.text, + ) if web is not None: print(f"web interface on port {web.port}") try: diff --git a/src/paperpi/config.py b/src/paperpi/config.py index 24eac8c..3e55f10 100644 --- a/src/paperpi/config.py +++ b/src/paperpi/config.py @@ -313,6 +313,8 @@ class Config: from_last_good: bool = False """True when the file was wrong and the last good copy is used instead.""" web: WebSettings = field(default_factory=WebSettings) + text: str = "" + """The file text it was read from (with ``from_last_good``: the last good copy's).""" @property def errors(self) -> list[Problem]: @@ -439,7 +441,9 @@ def parse(text: str, source: str = "config") -> Config: # E.g. lists nested thousands deep, or a number with thousands of digits. problem = f"not valid TOML: {type(error).__name__}: {str(error)[:200]}" raise ConfigError([Problem("error", problem, source)]) from None - return _Checker(text, source).check(data) + config = _Checker(text, source).check(data) + config.text = text + return config def _log_problems(problems: list[Problem]) -> None: diff --git a/src/paperpi/plugin.py b/src/paperpi/plugin.py index 8d68b9c..ca9f2bf 100644 --- a/src/paperpi/plugin.py +++ b/src/paperpi/plugin.py @@ -113,7 +113,7 @@ def setting(default: Any, *, required: bool = False, **field: Any) -> Any: ``required``: the plugin can't work until the user fills it in (a place, an email 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 from M5 part 2b the web interface) say which settings it needs. + ``paperpi list`` and the web interface's Active Plugins page say which settings it needs. """ extra = field.pop("json_schema_extra", None) or {} if not isinstance(extra, dict): diff --git a/src/paperpi/web/app.py b/src/paperpi/web/app.py index f8e41d0..2505f23 100644 --- a/src/paperpi/web/app.py +++ b/src/paperpi/web/app.py @@ -7,6 +7,10 @@ ``/setup`` the first visitor sets the password (only while none is set) ``/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 ``/static/...`` the style sheet and htmx (a small JavaScript file that updates one part of a page without loading the whole page again) ================= ============================================================== @@ -23,9 +27,9 @@ import ipaddress from collections.abc import Awaitable, Callable from pathlib import Path -from urllib.parse import urlsplit +from urllib.parse import urlencode, urlsplit -from fastapi import FastAPI, Request +from fastapi import FastAPI, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates @@ -33,7 +37,9 @@ from .. import __version__ from .auth import COOKIE_SECONDS, Auth, new_cookie +from .config_file import EditError from .password import PasswordError, password_problem +from .plugins import PluginEditor, library, library_item COOKIE = "paperpi_login" _HERE = Path(__file__).parent @@ -41,15 +47,18 @@ _OPEN = ("/setup", "/login") -def create_app(auth: Auth) -> FastAPI: - """The web interface, using ``auth`` for the password and log-in.""" +def create_app(auth: Auth, editor: PluginEditor | None = None) -> FastAPI: + """The web interface, using ``auth`` for the password and log-in and ``editor`` to + change the plugins in the config file.""" + editor = editor or PluginEditor(auth.config_file) 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.filters["sentence"] = lambda text: text[:1].upper() + text[1:] - def page(request: Request, name: str, status: int = 200, **values) -> HTMLResponse: - return templates.TemplateResponse(request, name, values, status_code=status) + def page(request: Request, template: str, status: int = 200, **values) -> HTMLResponse: + return templates.TemplateResponse(request, template, values, status_code=status) def logged_in(request: Request, stored: str) -> RedirectResponse: response = RedirectResponse("/", status_code=303) @@ -137,6 +146,100 @@ async def login(request: Request): return logged_in(request, stored) return page(request, "login.html", 401, problem="Wrong password.") + def plugin_list(request: Request, status: int = 200, problem: str | None = None): + try: + found = editor.plugin_list() + except EditError as error: + rows, problems = [], [str(error)] + status = 500 if status == 200 else status + else: + rows, problems = found.rows, found.problems + done = request.query_params.get("done") + # The name in the address is only shown when it is in the list: a link can't put + # other text on the page. + name = request.query_params.get("name") + return page( + request, + "plugins.html", + status, + rows=rows, + problems=[p for p in dict.fromkeys(problems) if p != problem], + problem=problem, + done=done if done in ("saved", "added", "removed") else None, + name=name if any(row.name == name for row in rows) else None, + hand_edits=request.query_params.get("hand") == "1", + config_file=auth.config_file, + ) + + def saved(done: str, hand_edits: bool, name: str = "") -> RedirectResponse: + values = {"done": done} | ({"name": name} if name else {}) + query = urlencode(values | ({"hand": "1"} if hand_edits else {})) + return RedirectResponse(f"/plugins?{query}", status_code=303) + + @app.get("/plugins", response_class=HTMLResponse) + def plugins_page(request: Request): + return plugin_list(request) + + @app.post("/plugins/{index}/move", response_class=HTMLResponse) + def move(request: Request, index: int, name: str = Form(""), step: str = Form("")): + if step not in ("up", "down"): + return plugin_list(request, 400, "Choose up or down.") + try: + hand_edits = editor.move(index, name, -1 if step == "up" else 1) + except EditError as error: + return plugin_list(request, 409, str(error)) + return saved("saved", hand_edits) + + @app.post("/plugins/{index}/enabled", response_class=HTMLResponse) + def switch(request: Request, index: int, name: str = Form(""), on: str = Form("")): + try: + hand_edits = editor.set_enabled(index, name, on == "1") + except EditError as error: + return plugin_list(request, 409, str(error)) + return saved("saved", hand_edits) + + @app.get("/plugins/{index}/remove", response_class=HTMLResponse) + def remove_page(request: Request, index: int, name: str = ""): + try: + row = editor.row(index, name) + except EditError as error: + return plugin_list(request, 409, str(error)) + return page(request, "remove.html", row=row) + + @app.post("/plugins/{index}/remove", response_class=HTMLResponse) + def remove(request: Request, index: int, name: str = Form("")): + try: + hand_edits = editor.remove(index, name) + except EditError as error: + return plugin_list(request, 409, str(error)) + return saved("removed", hand_edits) + + @app.get("/library", response_class=HTMLResponse) + def library_page(request: Request): + return page(request, "library.html", items=library()) + + def add_page(request: Request, plugin_type: str, status: int = 200, **values): + item = library_item(plugin_type) + if item is None: + return page(request, "library.html", 404, items=library(), problem="No such plugin.") + values.setdefault("name", editor.suggested_name(item)) + return page(request, "add.html", status, item=item, **values) + + @app.get("/library/{plugin_type}", response_class=HTMLResponse) + def add_form(request: Request, plugin_type: str): + return add_page(request, plugin_type) + + @app.post("/library/{plugin_type}", response_class=HTMLResponse) + def add(request: Request, plugin_type: str, name: str = Form("")): + item = library_item(plugin_type) + if item is None: + return add_page(request, plugin_type) + try: + hand_edits = 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, name.strip()) + @app.post("/logout") def logout(): response = RedirectResponse("/login", status_code=303) diff --git a/src/paperpi/web/plugins.py b/src/paperpi/web/plugins.py new file mode 100644 index 0000000..2eab5f7 --- /dev/null +++ b/src/paperpi/web/plugins.py @@ -0,0 +1,282 @@ +"""What the Active Plugins and Plugin Library pages show, and the changes they make. + +- :class:`PluginEditor` reads the plugin list from the config file and makes one change at a + time (:mod:`paperpi.web.config_file`). After each change it asks PaperPi to load the file + again, so the change applies at once. +- A change is made to the file as it is on disk now, so hand edits that were not applied + yet stay in it and apply together with the change (agreed with txoof, M5 part 2b). The + page then says so. +- A change is refused while the file has a problem that stops PaperPi from using it (such + as a wrong ``[display]``): PaperPi would keep running on the last good copy, so the change + would not apply. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path + +from pydantic import ValidationError + +from .. import config, example, plugins +from ..plugin import Plugin, PluginEntry +from . import config_file + +#: Plugin types the Plugin Library doesn't offer: ``default`` is PaperPi's own message when +#: nothing else can be shown, and ``debugging`` is for testing PaperPi. +HIDDEN = ("default", "debugging") + + +@dataclass(frozen=True) +class Row: + """One ``[[plugin]]`` block, as the Active Plugins page shows it.""" + + index: int + """Its place in the file, counted from 0.""" + name: str + """``""`` when the block has no proper name.""" + type: str + enabled: bool + missing: tuple[str, ...] = () + """Required settings that are not set yet; the plugin can't be switched on until they are.""" + errors: tuple[str, ...] = () + """What is wrong with the block; PaperPi leaves it out until it is fixed.""" + known: bool = True + """False when the file can't be checked, so whether PaperPi uses the block is not known.""" + + @property + def status(self) -> str: + if not self.known: + return "Unknown" + if self.errors: + return "Not used: has errors" + if self.missing: + return "Needs settings: " + ", ".join(self.missing) + return "On" if self.enabled else "Off" + + +@dataclass(frozen=True) +class PluginList: + rows: list[Row] + problems: list[str] = field(default_factory=list) + """Problems with the whole file (no change can be made until they are fixed).""" + + +@dataclass(frozen=True) +class LibraryItem: + """One plugin type in the Plugin Library.""" + + type: str + description: str + plugin: Plugin | None = None + """``None`` when the plugin itself is broken (``description`` says why).""" + + @property + def required(self) -> list[tuple[str, str]]: + """The settings the user has to fill in, with their help text.""" + if self.plugin is None: + return [] + fields = self.plugin.settings.model_fields + return [(key, fields[key].description or "") for key in self.plugin.required] + + +class PluginEditor: + """Reads and changes the plugin blocks of one config file.""" + + def __init__(self, path: Path, reload: Callable[[], None] | None = None): + self.path = Path(path) + self._reload = reload + self._applied: str | None = None + """The file text PaperPi uses now (``None``: not known).""" + + def loaded(self, text: str) -> None: + """PaperPi loaded the config file ``text`` (at the start or a reload).""" + self._applied = text + + def plugin_list(self) -> PluginList: + """The plugin blocks in the file now. Raises :class:`config_file.EditError` when the + file can't be read at all.""" + path, text = config_file.read(self.path) + return _plugin_list(text, path.name) + + def change(self, edit: Callable[[str], str]) -> bool: + """Change the file with ``edit`` (old text -> new text), save it and apply it. + + Returns True when the file had hand edits that were not applied yet; they apply + too. Raises :class:`config_file.EditError` when the change can't be made. + """ + with config_file.LOCK: + path, text = config_file.read(self.path) + hand_edits = ( + self._applied is not None + and text != self._applied + and not config_file.saved_here(path, text) + ) + new = edit(text) + try: + config.parse(new, path.name) + except config.ConfigError as error: + found = "; ".join(str(p) for p in error.problems if p.level == "error") + raise config_file.EditError( + "The config file has a problem that must be fixed first, in the file " + f"itself: {found}" + ) from None + config_file.write(path, new) + if self._reload is not None: + self._reload() + return hand_edits + + def move(self, index: int, name: str, step: int) -> bool: + return self.change(lambda text: config_file.move(text, index, name, step)) + + def set_enabled(self, index: int, name: str, enabled: bool) -> bool: + def switch(text: str) -> str: + row = _row(_plugin_list(text), index, name) + if enabled and row.missing: + raise config_file.EditError( + f"{name} can't be switched on yet. Fill in these settings first: " + f"{', '.join(row.missing)}." + ) + return config_file.set_enabled(text, index, name, enabled) + + return self.change(switch) + + def remove(self, index: int, name: str) -> bool: + return self.change(lambda text: config_file.remove(text, index, name)) + + def row(self, index: int, name: str) -> Row: + """The block at ``index``, if it is still called ``name``.""" + return _row(self.plugin_list(), index, name) + + def add(self, item: LibraryItem, name: str) -> bool: + """Add a block for ``item`` called ``name`` at the end of the plugin list. A plugin + with required settings is added switched off. Raises + :class:`config_file.EditError` for a name that can't be used.""" + if item.plugin is None: + raise config_file.EditError(f"{item.type} can't be added. {item.description}") + name = name.strip() + problem = name_problem(name) + if problem: + raise config_file.EditError(problem) + values = {"enabled": False} if item.plugin.required else {} + block = example.plugin_block(item.plugin, name, values) + + def add(text: str) -> str: + if config.folder_name(name) in _used_names(text): + raise config_file.EditError( + "Another plugin already has this name (capitals and punctuation don't " + "count). Choose another name." + ) + return config_file.add(text, block) + + return self.change(add) + + def suggested_name(self, item: LibraryItem) -> str: + """A name for a new block of ``item`` that is not used yet: "Basic clock", + "Basic clock 2", ...""" + try: + used = _used_names(config_file.read(self.path)[1]) + except config_file.EditError: + used = set() + base = item.type.replace("_", " ").capitalize() + name, number = base, 1 + while config.folder_name(name) in used: + number += 1 + name = f"{base} {number}" + return name + + +def _plugin_list(text: str, source: str = "config") -> PluginList: + data = config_file.load(text) + found = config_file.blocks(text) + raw = config_file.plugin_blocks(data) + file_problems: list[str] = [] + problems: list[config.Problem] = [] + checked: dict[int, config.PluginConfig] = {} + try: + loaded = config.parse(text, source) + except config.ConfigError as error: + file_problems = [str(p) for p in error.problems if p.level == "error"] + else: + problems = loaded.problems + # By the line of its [[plugin]] line: two blocks may have the same name (then + # PaperPi uses only the first). + checked = {p.line: p for p in loaded.plugins if p.line is not None} + known = len(found) == len(raw) and not file_problems + if len(found) != len(raw): + file_problems.append(str(config_file.UnknownForm())) + rows = [] + for index, block in enumerate(raw): + block = block if isinstance(block, dict) else {} + plugin_type, enabled = block.get("type"), block.get("enabled", True) + good = errors = None + if known: + place = found[index] + good = checked.get(place.header + 1) + errors = tuple( + _without_place(p) + for p in problems + if p.level == "error" and p.line and place.start < p.line <= place.end + ) + rows.append( + Row( + index=index, + name=config_file.name_of(block), + type=plugin_type if isinstance(plugin_type, str) else "", + enabled=enabled if isinstance(enabled, bool) else True, + missing=good.missing if good is not None else (), + errors=() if good is not None or not known else (errors or ("can't be used",)), + known=known, + ) + ) + return PluginList(rows, file_problems) + + +def _row(found: PluginList, index: int, name: str) -> Row: + if not 0 <= index < len(found.rows) or found.rows[index].name != name: + raise config_file.ChangedMeanwhile() + return found.rows[index] + + +def library() -> list[LibraryItem]: + """The plugin types the Plugin Library offers, sorted by type.""" + items = [] + for plugin_type in plugins.available(): + if plugin_type in HIDDEN: + continue + try: + plugin = plugins.load(plugin_type) + except Exception as error: # noqa: BLE001 - a broken plugin is shown as broken + items.append(LibraryItem(plugin_type, f"This plugin is broken: {error}")) + else: + items.append(LibraryItem(plugin_type, plugin.description, plugin)) + return items + + +def library_item(plugin_type: str) -> LibraryItem | None: + return next((item for item in library() if item.type == plugin_type), None) + + +def name_problem(name: str) -> str | None: + """Why ``name`` can't be a plugin's name, or ``None`` when it can.""" + try: + PluginEntry.model_validate({"name": name, "type": "x"}) + except ValidationError as error: + found = error.errors()[0] + if found["type"] == "string_too_short": + return "Give the plugin a name." + if found["type"] == "string_too_long": + return f"The name can have at most {found['ctx']['max_length']} characters." + return f"The name {found['msg'].removeprefix('Value error, ')}." + return None + + +def _used_names(text: str) -> set[str]: + blocks = config_file.plugin_blocks(config_file.load(text)) + return {config.folder_name(n) for n in map(config_file.name_of, blocks) if n} + + +def _without_place(problem: config.Problem) -> str: + """The problem's message with its line number, without the file name.""" + return f"line {problem.line}: {problem.message}" if problem.line else problem.message diff --git a/src/paperpi/web/server.py b/src/paperpi/web/server.py index 20d0371..0bbd127 100644 --- a/src/paperpi/web/server.py +++ b/src/paperpi/web/server.py @@ -10,6 +10,7 @@ import logging import socket import threading +from collections.abc import Callable from pathlib import Path import uvicorn @@ -17,6 +18,7 @@ from .. import config from .app import create_app from .auth import Auth +from .plugins import PluginEditor log = logging.getLogger(__name__) @@ -27,8 +29,15 @@ class WebServer: """The web interface of one ``paperpi run``. Start it with :func:`start`.""" - def __init__(self, auth: Auth, settings: config.WebSettings, sock: socket.socket): + def __init__( + self, + auth: Auth, + settings: config.WebSettings, + sock: socket.socket, + editor: PluginEditor, + ): self.auth = auth + self.editor = editor self.settings = settings """The settings it was started with; a change of address or port needs a restart.""" self._socket = sock @@ -36,7 +45,7 @@ def __init__(self, auth: Auth, settings: config.WebSettings, sock: socket.socket """The port it listens on (useful with ``port = 0`` in tests).""" self._server = uvicorn.Server( uvicorn.Config( - create_app(auth), + create_app(auth, self.editor), log_config=None, # PaperPi's own logging stays as it is log_level="warning", access_log=False, @@ -53,10 +62,12 @@ def _serve(self) -> None: except BaseException: # noqa: BLE001 - also SystemExit, which uvicorn uses for errors log.exception("the web interface stopped because of an error") - def use(self, settings: config.WebSettings) -> None: - """Apply reloaded ``[web]`` settings: log-in changes apply at once, the rest at the - next start.""" + def use(self, loaded: config.Config) -> None: + """Apply a reloaded config: ``[web]`` log-in changes apply at once, the rest of + ``[web]`` at the next start.""" + settings = loaded.web self.auth.use(settings) + self.editor.loaded(loaded.text) changed = [ k for k in config.WEB_NEXT_START if getattr(settings, k) != getattr(self.settings, k) ] @@ -75,8 +86,18 @@ def stop(self) -> None: self._socket.close() -def start(config_file: Path, settings: config.WebSettings) -> WebServer | None: - """Start the web interface, or log why it can't start and return ``None``.""" +def start( + config_file: Path, + settings: config.WebSettings, + *, + reload: Callable[[], None] | None = None, + text: str | None = None, +) -> WebServer | None: + """Start the web interface, or log why it can't start and return ``None``. + + ``reload`` makes PaperPi load the config file again (after a change in the web + interface); ``text`` is the config file text PaperPi uses now. + """ try: sock = _listen(settings.address, settings.port) except OSError as error: @@ -87,7 +108,10 @@ def start(config_file: Path, settings: config.WebSettings) -> WebServer | None: error, ) return None - server = WebServer(Auth(config_file, settings), settings, sock) + editor = PluginEditor(config_file, reload) + if text is not None: + editor.loaded(text) + server = WebServer(Auth(config_file, settings), settings, sock, editor) server._thread.start() return server diff --git a/src/paperpi/web/static/style.css b/src/paperpi/web/static/style.css index d4d2d42..56d7036 100644 --- a/src/paperpi/web/static/style.css +++ b/src/paperpi/web/static/style.css @@ -15,3 +15,10 @@ button { padding: 0.4rem 0.9rem; font-size: 1rem; } .note { padding: 0.5rem; border-left: 4px solid #a60; background: #fff6e5; } details { margin-top: 2rem; } code { background: #eee; padding: 0 0.2rem; } +.saved { padding: 0.5rem; border-left: 4px solid #060; background: #eef8ee; } +.plugins { padding-left: 1.5rem; } +.plugins li { margin: 0.75rem 0; padding-bottom: 0.75rem; border-bottom: 1px solid #ddd; } +.plugins .type, .plugins .hint { color: #555; font-size: 0.9rem; } +.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; } diff --git a/src/paperpi/web/templates/add.html b/src/paperpi/web/templates/add.html new file mode 100644 index 0000000..e5dbfbc --- /dev/null +++ b/src/paperpi/web/templates/add.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% set logged_in = true %} +{% block title %}Add {{ item.type }} · PaperPi{% endblock %} +{% block main %} +

Add {{ item.type }}

+

{{ item.description }}

+{% if problem %}

{{ problem|sentence }}

{% endif %} +{% if item.required %} +
+

This plugin is not shown until these settings are filled in, so it is added switched off:

+
    + {% 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.

+
+{% endif %} +
+ + + Cancel +
+{% endblock %} diff --git a/src/paperpi/web/templates/home.html b/src/paperpi/web/templates/home.html index e811239..dcd1e62 100644 --- a/src/paperpi/web/templates/home.html +++ b/src/paperpi/web/templates/home.html @@ -2,7 +2,11 @@ {% set logged_in = true %} {% block main %}

PaperPi

-

PaperPi is running. The pages for plugins and settings are coming in the next versions.

+

PaperPi is running.

+ {% if not auth.login %}

Log-in is switched off (login = false in [web] of the config file): anyone on your home network can change PaperPi's settings.

diff --git a/src/paperpi/web/templates/library.html b/src/paperpi/web/templates/library.html new file mode 100644 index 0000000..2a2a007 --- /dev/null +++ b/src/paperpi/web/templates/library.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% set logged_in = true %} +{% block title %}Plugin Library · PaperPi{% endblock %} +{% block main %} +

Plugin Library

+{% if problem %}

{{ problem }}

{% endif %} +

Every plugin that comes with PaperPi. Choose one to add it at the end of +Active Plugins. The same plugin can be added more than once, with +different settings (for example the weather for two places).

+ +{% endblock %} diff --git a/src/paperpi/web/templates/plugins.html b/src/paperpi/web/templates/plugins.html new file mode 100644 index 0000000..8557afd --- /dev/null +++ b/src/paperpi/web/templates/plugins.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% set logged_in = true %} +{% block title %}Active Plugins · PaperPi{% endblock %} +{% block main %} +

Active Plugins

+{% if done %} +

+ {%- if done == "added" %}{% if name %}Added {{ name }}.{% else %}Plugin added.{% endif %} + {%- elif done == "removed" %}Plugin removed. + {%- else %}Saved.{% endif %} PaperPi applies the change now. + {%- if hand_edits %} Your hand edits to the config file were applied too.{% endif %}

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

{{ problem|sentence }}

{% endif %} +{% 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 %} +
  1. +
    {{ row.name or "(no name)" }} {{ row.type }}
    +
    {{ row.status }}
    + {% for error in row.errors %}
    {{ error }}
    {% endfor %} +
    +
    + + {% if row.enabled %} + + {% else %} + + + {% endif %} +
    +
    + + + +
    + Remove +
    +
  2. +{% endfor %} +
+{% endblock %} diff --git a/src/paperpi/web/templates/remove.html b/src/paperpi/web/templates/remove.html new file mode 100644 index 0000000..31d0e04 --- /dev/null +++ b/src/paperpi/web/templates/remove.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% set logged_in = true %} +{% block title %}Remove {{ row.name }} · PaperPi{% endblock %} +{% block main %} +

Remove {{ row.name or "(no name)" }}?

+

This removes the {{ row.type }} plugin {{ row.name or "(no name)" }} +and all its settings from the config file. To only stop showing it, switch it off instead.

+
+ + + Cancel +
+{% endblock %} diff --git a/tests/test_cli.py b/tests/test_cli.py index 5ca0db1..7f8e827 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -538,10 +538,19 @@ def test_run_starts_the_web_interface_unless_asked_not_to( from paperpi.web import server calls = [] - monkeypatch.setattr(server, "start", lambda *a: calls.append(a)) + monkeypatch.setattr(server, "start", lambda *a, **kw: calls.append((a, kw))) + reloads = [] + monkeypatch.setattr(cli.Scheduler, "reload", lambda self: reloads.append(1)) monkeypatch.setattr(cli.Scheduler, "run", lambda self: None) cfg = tmp_path / "paperpi.toml" - cfg.write_text(f'config_version = 1\n[display]\ntype = "virtual"\n{web}') + text = f'config_version = 1\n[display]\ntype = "virtual"\n{web}' + cfg.write_text(text) run = ["run", *args, "--config", str(cfg), "--state-dir", str(tmp_path)] assert main([*run, "--health-file", str(tmp_path / "health")]) == 0 assert bool(calls) == started + if started: + # A change in the web interface reloads PaperPi; the web knows the text in use. + ((_, kw),) = calls + assert kw["text"] == text + kw["reload"]() + assert reloads == [1] diff --git a/tests/test_config.py b/tests/test_config.py index 3d0a56d..c1b72b1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -414,7 +414,7 @@ def files(tmp_path): def test_good_load_saves_last_good_copy(files): path, state, last_good = files - load(path, state_dir=state) + assert load(path, state_dir=state).text == GOOD assert last_good.read_text() == GOOD assert last_good.stat().st_mode & 0o777 == 0o600 # it may hold passwords assert list(state.iterdir()) == [last_good] # no temporary files left @@ -444,6 +444,7 @@ def test_broken_file_uses_last_good_copy(files): path.write_text("config_version = 1\n[display\n") cfg = load(path, state_dir=state) assert cfg.from_last_good + assert cfg.text == GOOD # the text in use: the last good copy's assert [p.entry.name for p in cfg.plugins] == ["Clock"] assert problems(cfg) == [ "paperpi.toml line 2: not valid TOML: Expected ']' at the end of a table declaration " diff --git a/tests/test_web.py b/tests/test_web.py index dd8566a..9efd2be 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -1,6 +1,7 @@ import logging import socket import urllib.request +from dataclasses import replace import pytest from fastapi.testclient import TestClient @@ -202,7 +203,8 @@ def test_server_runs_in_a_thread(cfg, caplog): assert page.url.endswith("/setup") # A reload with a new port: it applies at the next start. with caplog.at_level(logging.WARNING): - web.use(config.WebSettings(port=9000, login=False)) + loaded = config.parse(cfg.read_text()) + web.use(replace(loaded, web=config.WebSettings(port=9000, login=False))) assert "[web] address, port: changes apply at the next start" in caplog.text assert not web.auth.login # log-in changes apply at once finally: diff --git a/tests/test_web_plugins.py b/tests/test_web_plugins.py new file mode 100644 index 0000000..6c3fee9 --- /dev/null +++ b/tests/test_web_plugins.py @@ -0,0 +1,336 @@ +import tomllib +import urllib.request + +import pytest +from fastapi.testclient import TestClient + +from paperpi import config, plugins +from paperpi.plugin import PluginDefinitionError +from paperpi.web import auth +from paperpi.web import password as web_password +from paperpi.web.app import create_app +from paperpi.web.plugins import PluginEditor + +CONFIG = """\ +config_version = 1 +[display] +type = "virtual" + +# the clock in the kitchen +[[plugin]] +name = "Clock" +type = "basic_clock" + +[[plugin]] +name = "Weather" +type = "met_no" +lat = 52.52 +lon = 13.40 +enabled = false + +[[plugin]] +name = "Broken" +type = "basic_clock" +refresh = 1 +""" + +# 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 editor(cfg, reloads): + found = PluginEditor(cfg, lambda: reloads.append(1)) + found.loaded(CONFIG) + return found + + +@pytest.fixture +def client(cfg, editor): + 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()).get("plugin", []) + + +def names(cfg): + return [b["name"] for b in blocks(cfg)] + + +def test_active_plugins_shows_every_block_with_its_state(client): + page = client.get("/plugins") + assert page.status_code == 200 + text = page.text + assert text.index("Clock") < text.index("Weather") < text.index("Broken") + assert "Needs settings: email" in text + assert "Not used: has errors" in text and "refresh" in text + assert text.count("Switch off") == 2 # Clock and Broken; Weather is off + # Weather can't be switched on until its email is filled in. + assert '' in text + assert 'value="up" disabled' in text and 'value="down" disabled' in text + + +def test_moving_a_plugin_saves_and_applies_at_once(client, cfg, reloads): + response = client.post("/plugins/0/move", data={"name": "Clock", "step": "down"}) + assert response.status_code == 303 + assert response.headers["location"].startswith("/plugins?done=saved") + assert names(cfg) == ["Weather", "Clock", "Broken"] + assert '# the clock in the kitchen\n[[plugin]]\nname = "Clock"' in cfg.read_text() + assert reloads == [1] + page = client.get(response.headers["location"]) + assert "Saved. PaperPi applies the change now." in page.text + assert "hand edits" not in page.text + client.post("/plugins/1/move", data={"name": "Clock", "step": "up"}) + assert names(cfg) == ["Clock", "Weather", "Broken"] + + +def test_switching_off_and_on(client, cfg): + client.post("/plugins/0/enabled", data={"name": "Clock", "on": "0"}) + assert blocks(cfg)[0]["enabled"] is False + client.post("/plugins/0/enabled", data={"name": "Clock", "on": "1"}) + assert "enabled" not in blocks(cfg)[0] + + +def test_a_plugin_with_missing_settings_cant_be_switched_on(client, cfg, reloads): + response = client.post("/plugins/1/enabled", data={"name": "Weather", "on": "1"}) + assert response.status_code == 409 + assert "Fill in these settings first: email." in response.text + assert cfg.read_text() == CONFIG and not reloads + + +def test_removing_asks_first(client, cfg): + page = client.get("/plugins/0/remove", params={"name": "Clock"}) + assert "Remove Clock?" in page.text and cfg.read_text() == CONFIG + response = client.post("/plugins/0/remove", data={"name": "Clock"}) + assert response.headers["location"] == "/plugins?done=removed" + assert names(cfg) == ["Weather", "Broken"] + assert "kitchen" not in cfg.read_text() + assert "Plugin removed." in client.get(response.headers["location"]).text + + +def test_a_list_changed_by_hand_meanwhile_is_not_changed(client, cfg, reloads): + # The page showed Clock first, but it was removed by hand since then. + cfg.write_text(CONFIG.replace('name = "Clock"', 'name = "Kitchen"')) + response = client.post("/plugins/0/remove", data={"name": "Clock"}) + assert response.status_code == 409 and "changed after this page was opened" in response.text + assert names(cfg) == ["Kitchen", "Weather", "Broken"] and not reloads + + +def test_hand_edits_are_kept_and_the_page_says_so(client, cfg, editor): + cfg.write_text(CONFIG + '\n[[plugin]]\nname = "Words"\ntype = "word_clock"\n') + response = client.post("/plugins/0/move", data={"name": "Clock", "step": "down"}) + assert "hand=1" in response.headers["location"] + assert names(cfg) == ["Weather", "Clock", "Broken", "Words"] + page = client.get(response.headers["location"]) + assert "Your hand edits to the config file were applied too." in page.text + # The next change, before PaperPi reloaded: the file holds only the web's own change. + response = client.post("/plugins/1/move", data={"name": "Clock", "step": "up"}) + assert "hand=1" not in response.headers["location"] + + +def test_the_library_lists_plugins_to_add(client): + page = client.get("/library").text + assert "basic_clock" in page and "met_no" in page + assert "debugging" not in page and "default" not in page + assert "/library/basic_clock" in page + + +def test_adding_a_plugin(client, cfg, reloads): + form = client.get("/library/word_clock").text + assert 'value="Word clock"' in form # a name that is not used yet + response = client.post("/library/word_clock", data={"name": " Words "}) + assert response.headers["location"] == "/plugins?done=added&name=Words" + assert blocks(cfg)[-1] == {"name": "Words", "type": "word_clock"} + assert reloads == [1] + # The next one gets another name. + client.post("/library/word_clock", data={"name": "Word clock"}) + assert 'value="Word clock 2"' in client.get("/library/word_clock").text + + +def test_a_plugin_with_required_settings_is_added_switched_off(client, cfg): + form = client.get("/library/met_no").text + assert "real email address" in form and "added switched off" in form + client.post("/library/met_no", data={"name": "Weather Rio"}) + assert blocks(cfg)[-1] == {"name": "Weather Rio", "type": "met_no", "enabled": False} + assert config.parse(cfg.read_text()).plugin("Weather Rio").missing == ("lat", "lon", "email") + + +@pytest.mark.parametrize( + ("name", "problem"), + [ + ("", "Give the plugin a name."), + ("x" * 101, "at most 100 characters"), + ("a\tb", "must not hold control characters"), + ("clock!", "Another plugin already has this name"), + ], +) +def test_names_that_cant_be_used(client, cfg, name, problem): + response = client.post("/library/word_clock", data={"name": name}) + assert response.status_code == 400 and problem in response.text + assert cfg.read_text() == CONFIG + + +def test_unknown_plugin_types(client): + assert client.get("/library/nothing").status_code == 404 + assert client.post("/library/debugging", data={"name": "x"}).status_code == 404 + + +def test_a_broken_file_is_shown_not_changed(client, cfg): + cfg.write_text("[[plugin]\n") + page = client.get("/plugins") + assert page.status_code == 500 and "The config file is not valid TOML" in page.text + assert client.post("/plugins/0/move", data={"name": "", "step": "up"}).status_code == 409 + assert cfg.read_text() == "[[plugin]\n" + + +def test_the_plugin_pages_need_a_log_in(cfg): + paperpi_auth = auth.Auth(cfg, config.WebSettings()) + client = TestClient(create_app(paperpi_auth), follow_redirects=False, base_url=PI) + for path in ["/plugins", "/library", "/library/basic_clock", "/plugins/0/remove"]: + assert client.get(path).headers["location"] == "/setup" + assert client.post("/plugins/0/remove", data={"name": "Clock"}).status_code == 303 + assert cfg.read_text() == CONFIG + + +def test_a_change_in_the_running_web_interface_reloads_paperpi(cfg, reloads): + from paperpi.web import server + + # Port 0: any free port (not allowed in the config file, so made without its checks). + settings = config.WebSettings.model_construct( + **(config.WebSettings().model_dump() | {"address": "127.0.0.1", "port": 0, "login": False}) + ) + web = server.start(cfg, settings, reload=lambda: reloads.append(1), text=CONFIG) + assert web is not None + try: + request = urllib.request.Request( + f"http://127.0.0.1:{web.port}/plugins/0/enabled", + data=b"name=Clock&on=0", + headers={"Origin": f"http://127.0.0.1:{web.port}"}, + ) + with urllib.request.urlopen(request, timeout=30) as page: + assert "/plugins?done=saved" in page.url + finally: + web.stop() + assert reloads == [1] and blocks(cfg)[0]["enabled"] is False + + +def test_a_reload_tells_the_web_interface_which_text_is_in_use(cfg, reloads): + from paperpi.web import server + + settings = config.WebSettings.model_construct( + **(config.WebSettings().model_dump() | {"address": "127.0.0.1", "port": 0}) + ) + web = server.start(cfg, settings) + try: + edited = CONFIG + "# a hand edit\n" + cfg.write_text(edited) + web.use(config.parse(edited)) # PaperPi reloaded the hand edit + assert not web.editor.move(0, "Clock", 1) + finally: + web.stop() + + +def test_which_changes_count_as_hand_edits(cfg, editor): + # Applied by a reload: not a hand edit any more. + edited = CONFIG + "# a hand edit\n" + cfg.write_text(edited) + editor.loaded(edited) + assert not editor.move(0, "Clock", 1) + # After the web's own change was applied, a new hand edit is one. + editor.loaded(cfg.read_text()) + cfg.write_text(cfg.read_text() + "# another one\n") + assert editor.move(1, "Clock", -1) + # A password saved by the web interface is not a hand edit. + editor.loaded(cfg.read_text()) + web_password.save_password_hash(cfg, "scrypt:16:1:1:c2FsdA:" + "A" * 43) + assert not editor.move(0, "Clock", 1) + # An editor that doesn't know what PaperPi uses never says so. + cfg.write_text(cfg.read_text() + "# and one more\n") + assert not PluginEditor(cfg).move(1, "Clock", -1) + + +def test_a_broken_plugin_is_shown_but_cant_be_added(client, cfg, monkeypatch): + real = plugins.load + + def load(plugin_type, *args): + if plugin_type == "word_clock": + raise PluginDefinitionError("plugin 'word_clock': needs at least one layout") + return real(plugin_type, *args) + + monkeypatch.setattr(plugins, "load", load) + assert "This plugin is broken: plugin 'word_clock'" in client.get("/library").text + assert client.get("/library/word_clock").status_code == 200 + response = client.post("/library/word_clock", data={"name": "Words"}) + assert response.status_code == 400 and "can't be added" in response.text + assert cfg.read_text() == CONFIG + + +def test_a_repeated_name_shows_the_second_block_as_not_used(client, cfg): + cfg.write_text(CONFIG + '\n[[plugin]]\nname = "Clock"\ntype = "word_clock"\n') + page = client.get("/plugins").text + assert page.count("Not used: has errors") == 2 # Broken and the second Clock + assert "is already used by the plugin block" in page + + +def test_a_change_is_refused_while_paperpi_cant_use_the_file(client, cfg, reloads): + broken = CONFIG.replace('type = "virtual"', 'type = "nothing"') + cfg.write_text(broken) + page = client.get("/plugins") + assert "unknown screen type" in page.text and "Unknown" in page.text + response = client.post("/plugins/0/move", data={"name": "Clock", "step": "down"}) + assert response.status_code == 409 and "must be fixed first" in response.text + assert cfg.read_text() == broken and not reloads + + +def test_every_change_needs_a_log_in_and_a_form_from_paperpi(cfg): + forms = { + "/plugins/0/move": {"name": "Clock", "step": "down"}, + "/plugins/0/enabled": {"name": "Clock", "on": "0"}, + "/plugins/0/remove": {"name": "Clock"}, + "/library/word_clock": {"name": "Words"}, + } + stored = web_password.hash_password("correct horse") + paperpi_auth = auth.Auth(cfg, config.WebSettings(password_hash=stored)) + client = TestClient(create_app(paperpi_auth), follow_redirects=False, base_url=PI) + for path, form in forms.items(): + assert client.post(path, data=form).headers["location"] == "/login" + open_client = TestClient( + create_app(auth.Auth(cfg, config.WebSettings(login=False))), + follow_redirects=False, + base_url=PI, + ) + for path, form in forms.items(): + other_site = {"origin": "http://evil.example", "sec-fetch-site": "cross-site"} + assert open_client.post(path, data=form, headers=other_site).status_code == 403 + assert cfg.read_text() == CONFIG + + +def test_odd_requests(client, cfg): + response = client.post("/plugins/0/move", data={"name": "Clock", "step": "sideways"}) + assert response.status_code == 400 and cfg.read_text() == CONFIG + assert client.get("/plugins/0/remove", params={"name": "Kitchen"}).status_code == 409 + # Text in the address is only shown when it names a plugin in the list. + page = client.get("/plugins", params={"done": "added", "name": "Your password was reset"}) + assert "Your password was reset" not in page.text and "Plugin added." in page.text + page = client.get("/plugins", params={"done": "added", "name": "Clock"}) + assert "Added Clock." in page.text + + +def test_the_file_keeps_its_permissions(client, cfg): + cfg.chmod(0o640) + client.post("/plugins/0/move", data={"name": "Clock", "step": "down"}) + assert cfg.stat().st_mode & 0o777 == 0o640