diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml new file mode 100644 index 000000000..3983fbb96 --- /dev/null +++ b/.github/workflows/django-tests.yml @@ -0,0 +1,72 @@ +name: "Django tests" + +on: + pull_request: + paths: + - "docker/web/**" + - ".github/workflows/django-tests.yml" + push: + branches: + - "main" + - "devel" + paths: + - "docker/web/**" + - ".github/workflows/django-tests.yml" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" # Matches the python:3.12 base image in docker/Dockerfile + cache: pip + cache-dependency-path: docker/web/requirements.txt + + - name: Install dependencies + run: pip install -r docker/web/requirements.txt unittest-xml-reporting + + - name: Run tests + run: docker/web/run_tests.sh -v 2 + env: + NSPM_TEST_JUNIT_DIR: ${{ github.workspace }}/test-results + + # annotate_only writes the job summary and failure annotations without creating a + # check run, which needs checks: write and so fails on pull requests from forks. + - name: Report test results + if: ${{ !cancelled() }} + uses: mikepenz/action-junit-report@v6 + with: + report_paths: test-results/*.xml + annotate_only: true + detailed_summary: true + include_passed: true + require_tests: true + + # test_openapi.py checks openapi.yaml against the DRF views; this checks the UI's generated + # TypeScript types against openapi.yaml. Together they catch a contract change committed + # without running docker/web/generate_api.sh. + api-types: + runs-on: ubuntu-latest + defaults: + run: + working-directory: docker/web/nspanelmanager/web/react-components + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" # Matches the node:20 image in docker/Dockerfile + + - name: Regenerate TypeScript types from openapi.yaml + run: npm run generate:api-types + + - name: Check the committed types are up to date + run: | + if [ -n "$(git status --porcelain -- src/api/schema.d.ts)" ]; then + git diff -- src/api/schema.d.ts + echo "::error file=docker/web/nspanelmanager/web/react-components/src/api/schema.d.ts::src/api/schema.d.ts is out of date with docker/web/openapi.yaml. Run docker/web/generate_api.sh and commit the result." + exit 1 + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..673cec56a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing + +## Changing the REST API + +The React UI and Django share a typed contract for the `/rest/*` endpoints that have been ported to Django REST Framework (currently `/rest/rooms` and `/rest/rooms/`; the rest are still plain Django views and aren't covered yet). The chain is: + +``` +web/serializers.py + DRF views in web/rest.py + │ drf-spectacular + ▼ +docker/web/openapi.yaml (committed) + │ openapi-typescript + ▼ +react-components/src/api/schema.d.ts (committed) + │ openapi-fetch + ▼ +api.GET / api.PUT / … in react-components/src/api/client.ts +``` + +Rename or retype a serializer field and, once the files are regenerated, every UI caller that uses the old name stops compiling. + +### After changing a serializer or a DRF view + +1. Run `docker/web/generate_api.sh`. It needs the Django requirements (see `docker/web/run_tests.sh`) and Node/npm. It rewrites `openapi.yaml` and `schema.d.ts`. +2. Fix whatever `npx tsc -b` in `react-components/` now reports. +3. Commit both generated files with your change. + +CI fails if you skip step 1: + +- `web/tests/test_openapi.py` (Django tests) fails if `openapi.yaml` doesn't match the views, or if drf-spectacular warns about a view it can't describe, which would otherwise come out as a loose type. +- The `api-types` job in `.github/workflows/django-tests.yml` fails if `schema.d.ts` doesn't match `openapi.yaml`. + +### Conventions for DRF views + +- Field names on the wire are snake_case. If a model field is named differently, map it in the serializer (`display_order = serializers.IntegerField(source="displayOrder")`), not in the view. +- Request serializers include `WarnOnUnknownFieldsMixin`. In production it logs a warning for fields the serializer doesn't know and otherwise ignores them, so a UI and server from different versions still work together. Under the test settings (`REST_REJECT_UNKNOWN_FIELDS = True`) it rejects them with a 400, so a test whose request body has drifted from the serializer fails. +- Responses keep the `{"status": "ok", ...}` envelope. Errors are always `ErrorResponseSerializer` (`{"status": "error", "message": ..., "errors"?: {field: [msg]}}`), produced by `web.rest.exception_handler`. List each error status a view can return in `@extend_schema(responses=...)` so it shows up in the UI's types. + +### Calling the API from the UI + +Use `api` from `src/api/client.ts` rather than `fetch()`. Pass request bodies as typed values (e.g. form data from `useForm()`): openapi-fetch infers `body` generically, so an inline object literal isn't checked for extra keys. In production the server only logs a warning for unknown fields, so a typo there fails quietly. diff --git a/docker/web/generate_api.sh b/docker/web/generate_api.sh new file mode 100755 index 000000000..cf80b8fce --- /dev/null +++ b/docker/web/generate_api.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Regenerate the REST API contract after changing a serializer or a DRF view: +# 1. docker/web/openapi.yaml, from the DRF views (drf-spectacular). web/tests/test_openapi.py fails if this is stale. +# 2. The UI's TypeScript types, src/api/schema.d.ts, from openapi.yaml (openapi-typescript). +# Needs the Django requirements (see run_tests.sh) and Node/npm. Commit both files. +# See "Changing the REST API" in CONTRIBUTING.md. CI fails if either file is stale. +set -e +cd "$(dirname "$0")" + +NSPM_DATA_DIR="$(mktemp -d)" python nspanelmanager/manage.py spectacular --validate --fail-on-warn --file openapi.yaml + +# generate:api-types runs openapi-typescript with npx rather than as a devDependency: it declares a +# typescript ^5 peer dependency and the UI is on TypeScript 6. CI runs the same script. +cd nspanelmanager/web/react-components +npm run generate:api-types diff --git a/docker/web/nspanelmanager/nspanelmanager/settings.py b/docker/web/nspanelmanager/nspanelmanager/settings.py index 2044f3ec3..9c0bf350e 100755 --- a/docker/web/nspanelmanager/nspanelmanager/settings.py +++ b/docker/web/nspanelmanager/nspanelmanager/settings.py @@ -28,9 +28,12 @@ # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ +# Persistent data (secret key, database). Overridable so that tests can run outside the container. +DATA_DIR = environment("NSPM_DATA_DIR", default="/data") + # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = "" -SECRET_KEY_PATH = "/data/secret.key" +SECRET_KEY_PATH = os.path.join(DATA_DIR, "secret.key") if os.path.exists(SECRET_KEY_PATH): with open(SECRET_KEY_PATH) as f: SECRET_KEY = f.read() @@ -59,6 +62,8 @@ #'django.contrib.staticfiles', Disabled and make use of django_components.safer_staticfiles instead. It will not serve out .py or .html files "django_components.safer_staticfiles", "django_components", + "rest_framework", + "drf_spectacular", ] STATICFILES_DIRS = [os.path.join(BASE_DIR, "web/components"), os.path.join(BASE_DIR, "web/react-components/dist")] @@ -107,7 +112,7 @@ # Database # https://docs.djangoproject.com/en/4.1/ref/settings/#databases -DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "/data/nspanelmanager_db.sqlite3", "timeout": 20}} +DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": os.path.join(DATA_DIR, "nspanelmanager_db.sqlite3"), "timeout": 20}} LOGGING = { "version": 1, @@ -166,3 +171,29 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" X_FRAME_OPTIONS = "SAMEORIGIN" + +# Django REST Framework, used by the /rest/* views ported from plain Django views. +REST_FRAMEWORK = { + # Like the plain views it replaces, the API has no login and no CSRF check (DRF only checks CSRF in SessionAuthentication). + "DEFAULT_AUTHENTICATION_CLASSES": [], + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.AllowAny"], + "UNAUTHENTICATED_USER": None, + "DEFAULT_PARSER_CLASSES": ["rest_framework.parsers.JSONParser"], + "DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"], + "EXCEPTION_HANDLER": "web.rest.exception_handler", + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", +} + +# Fields a DRF request serializer doesn't know are logged and ignored, so UIs and servers from different +# versions stay compatible. test_settings.py turns this on to reject them with a 400 (see web/serializers.py). +REST_REJECT_UNKNOWN_FIELDS = False + +SPECTACULAR_SETTINGS = { + "TITLE": "NSPanelManager REST API", + "DESCRIPTION": "The /rest/* API used by the web UI. Generated by drf-spectacular; see docker/web/generate_api.sh.", + "VERSION": "1", + "SERVE_INCLUDE_SCHEMA": False, + # Separate request/response schemas, so read-only fields like `id` aren't required in request bodies. + "COMPONENT_SPLIT_REQUEST": True, + "ENUM_NAME_OVERRIDES": {"OkStatusEnum": ["ok"], "ErrorStatusEnum": ["error"]}, +} diff --git a/docker/web/nspanelmanager/nspanelmanager/test_settings.py b/docker/web/nspanelmanager/nspanelmanager/test_settings.py new file mode 100644 index 000000000..e0abf439b --- /dev/null +++ b/docker/web/nspanelmanager/nspanelmanager/test_settings.py @@ -0,0 +1,34 @@ +""" +Settings used when running the Django test suite (see docker/web/run_tests.sh). + +Identical to the production settings except that persistent data (the secret key) +is written to a throw-away directory instead of /data. The Django test runner +automatically uses an in-memory SQLite database, so the real database is never touched. +""" + +import os +import tempfile + +os.environ.setdefault("NSPM_DATA_DIR", tempfile.mkdtemp(prefix="nspm-test-")) + +from .settings import * + +MEDIA_ROOT = os.path.join(DATA_DIR, "media") + +# A test request body with a field the serializer doesn't know has drifted from the API: fail it with a 400 +# instead of only logging a warning as production does. +REST_REJECT_UNKNOWN_FIELDS = True + +# Hashing speed is irrelevant for tests. +PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] + +# Many views log full tracebacks when rejecting bad input, which the tests do on purpose. +# Set NSPM_TEST_LOG_LEVEL=INFO (or DEBUG) to see them when debugging a failing test. +LOGGING["root"]["level"] = os.environ.get("NSPM_TEST_LOG_LEVEL", "CRITICAL") +LOGGING["loggers"] = {"django.request": {"level": os.environ.get("NSPM_TEST_LOG_LEVEL", "CRITICAL")}} + +# CI sets NSPM_TEST_JUNIT_DIR to also write JUnit XML results (needs unittest-xml-reporting). +if os.environ.get("NSPM_TEST_JUNIT_DIR"): + TEST_RUNNER = "xmlrunner.extra.djangotestrunner.XMLTestRunner" + TEST_OUTPUT_DIR = os.environ["NSPM_TEST_JUNIT_DIR"] + TEST_OUTPUT_FILE_NAME = "django-tests.xml" diff --git a/docker/web/nspanelmanager/web/api.py b/docker/web/nspanelmanager/web/api.py index a78d1c933..7166eea23 100644 --- a/docker/web/nspanelmanager/web/api.py +++ b/docker/web/nspanelmanager/web/api.py @@ -231,13 +231,13 @@ def get_nspanel_config(request): base["button2_mqtt_topic"] = get_nspanel_setting_with_default(nspanel.id, "button2_mqtt_topic", "") base["button2_mqtt_payload"] = get_nspanel_setting_with_default(nspanel.id, "button2_mqtt_payload", "") - if nspanel.button1_detached_mode_light: - base["button1_detached_light"] = nspanel.button1_detached_mode_light.id + if nspanel.button1_detached_mode_entity: + base["button1_detached_light"] = nspanel.button1_detached_mode_entity.id else: - base["button1_detached_mode_light"] = -1 + base["button1_detached_light"] = -1 - if nspanel.button2_detached_mode_light: - base["button2_detached_light"] = nspanel.button2_detached_mode_light.id + if nspanel.button2_detached_mode_entity: + base["button2_detached_light"] = nspanel.button2_detached_mode_entity.id else: base["button2_detached_light"] = -1 base["rooms"] = [] diff --git a/docker/web/nspanelmanager/web/apps.py b/docker/web/nspanelmanager/web/apps.py index fa01240b3..6816c0bbc 100755 --- a/docker/web/nspanelmanager/web/apps.py +++ b/docker/web/nspanelmanager/web/apps.py @@ -74,28 +74,34 @@ def send_mqttmanager_reload_command(): def create_entity_pages_for_all_rooms(): - from .models import Room, RoomEntitiesPage + from .models import Room for room in Room.objects.all(): - # Check if an existing scenes page exists, if it does not, create one - existing_pages = RoomEntitiesPage.objects.filter(room=room, is_scenes_page=True) - if not existing_pages.exists(): - page = RoomEntitiesPage() - page.room = room - page.display_order = 0 - page.page_type = 4 - page.is_scenes_page = True - page.save() - - # Check if an existing entities page exists, if it doesn not, create one. - existing_pages = RoomEntitiesPage.objects.filter(room=room, is_scenes_page=False) - if not existing_pages.exists(): - page = RoomEntitiesPage() - page.room = room - page.display_order = 0 - page.page_type = 4 - page.is_scenes_page = False - page.save() + create_entity_pages_for_room(room) + + +def create_entity_pages_for_room(room): + from .models import RoomEntitiesPage + + # Check if an existing scenes page exists, if it does not, create one + existing_pages = RoomEntitiesPage.objects.filter(room=room, is_scenes_page=True) + if not existing_pages.exists(): + page = RoomEntitiesPage() + page.room = room + page.display_order = 0 + page.page_type = 4 + page.is_scenes_page = True + page.save() + + # Check if an existing entities page exists, if it doesn not, create one. + existing_pages = RoomEntitiesPage.objects.filter(room=room, is_scenes_page=False) + if not existing_pages.exists(): + page = RoomEntitiesPage() + page.room = room + page.display_order = 0 + page.page_type = 4 + page.is_scenes_page = False + page.save() def create_global_scenes_page(): diff --git a/docker/web/nspanelmanager/web/htmx.py b/docker/web/nspanelmanager/web/htmx.py index 73d194f65..541d35c8e 100644 --- a/docker/web/nspanelmanager/web/htmx.py +++ b/docker/web/nspanelmanager/web/htmx.py @@ -105,6 +105,7 @@ def nspanel_delete(request, nspanel_id): if request.method == "DELETE": nspanel = NSPanel.objects.get(id=nspanel_id) nspanel.delete() + send_mqttmanager_reload_command() response = HttpResponse("", status=200) response["HX-Redirect"] = "/" return response @@ -267,6 +268,7 @@ def relay_group_save(request): rg = RelayGroup() rg.friendly_name = request.POST["name"] rg.save() + send_mqttmanager_reload_command() response = HttpResponse() response["HX-Refresh"] = "true" @@ -278,6 +280,7 @@ def relay_group_delete(request, relay_group_id): if request.method == "DELETE": rg = RelayGroup.objects.get(id=relay_group_id) rg.delete() + send_mqttmanager_reload_command() response = HttpResponse() response["HX-Refresh"] = "true" @@ -538,32 +541,6 @@ def partial_remove_entity_from_page_slot(request, page_id, slot_id): ) -def partial_edit_entities_page(request, page_id): - data = { - "page_id": page_id, - } - return render(request, "partial/edit_entities_page.html", data) - - -def partial_save_edit_entities_page(request, page_id, page_type): - page = RoomEntitiesPage.objects.get(id=page_id) - page.page_type = page_type - page.save() - - room_id = 0 - if page.room: - room_id = page.room.id - send_mqttmanager_reload_command() - entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get( - request=request, - view="edit_room", - room_id=room_id, - is_scenes_pages=page.is_scenes_page, - is_global_scenes_page=(page.room is None), - ) - - def get_entity_in_page_slot(page_id, slot_id): page = RoomEntitiesPage.objects.get(id=page_id) entities = Entity.objects.filter(entities_page=page, room_view_position=slot_id) diff --git a/docker/web/nspanelmanager/web/react-components/package-lock.json b/docker/web/nspanelmanager/web/react-components/package-lock.json index 7676fd61d..4f846f4a8 100644 --- a/docker/web/nspanelmanager/web/react-components/package-lock.json +++ b/docker/web/nspanelmanager/web/react-components/package-lock.json @@ -17,6 +17,7 @@ "bun": "^1.3.13", "esbuild": "^0.28.0", "motion": "^12.40.0", + "openapi-fetch": "0.17.0", "react": "^19.2.5", "react-dom": "^19.2.5", "react-hook-form": "^7.77.0", @@ -3549,6 +3550,21 @@ "node": ">=0.10.0" } }, + "node_modules/openapi-fetch": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", + "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.1.0" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", + "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/docker/web/nspanelmanager/web/react-components/package.json b/docker/web/nspanelmanager/web/react-components/package.json index dc769aa39..4fbb7e94f 100644 --- a/docker/web/nspanelmanager/web/react-components/package.json +++ b/docker/web/nspanelmanager/web/react-components/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "generate:api-types": "npx --yes openapi-typescript@7.13.0 ../../../openapi.yaml --output src/api/schema.d.ts" }, "dependencies": { "@dnd-kit/helpers": "^0.4.0", @@ -19,6 +20,7 @@ "bun": "^1.3.13", "esbuild": "^0.28.0", "motion": "^12.40.0", + "openapi-fetch": "0.17.0", "react": "^19.2.5", "react-dom": "^19.2.5", "react-hook-form": "^7.77.0", diff --git a/docker/web/nspanelmanager/web/react-components/src/api/client.ts b/docker/web/nspanelmanager/web/react-components/src/api/client.ts new file mode 100644 index 000000000..17457c438 --- /dev/null +++ b/docker/web/nspanelmanager/web/react-components/src/api/client.ts @@ -0,0 +1,13 @@ +import createClient from "openapi-fetch"; +import type { components, paths } from "./schema"; + +// Typed client for the /rest/* endpoints in docker/web/openapi.yaml. URLs, methods, request +// bodies and responses are checked against the Django serializers at compile time. +// schema.d.ts is generated: run docker/web/generate_api.sh after changing the API. +// openapi-fetch infers `body` generically, so an inline object literal isn't checked for extra +// keys. Pass a typed value instead (e.g. form data from useForm); the server +// only logs a warning for unknown fields, so a typo there fails quietly. +export const api = createClient({ credentials: "same-origin" }); + +export type Room = components["schemas"]["Room"]; +export type RoomUpdateRequest = components["schemas"]["RoomUpdateRequest"]; diff --git a/docker/web/nspanelmanager/web/react-components/src/api/schema.d.ts b/docker/web/nspanelmanager/web/react-components/src/api/schema.d.ts new file mode 100644 index 000000000..683cab956 --- /dev/null +++ b/docker/web/nspanelmanager/web/react-components/src/api/schema.d.ts @@ -0,0 +1,225 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/rest/rooms": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["rooms_retrieve"]; + put: operations["rooms_update"]; + post: operations["rooms_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/rest/rooms/{room_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["rooms_destroy"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + ErrorResponse: { + status: components["schemas"]["ErrorStatusEnum"]; + message: string; + /** @description Validation errors by field name. */ + errors?: { + [key: string]: string[]; + }; + }; + /** + * @description * `error` - error + * @enum {string} + */ + ErrorStatusEnum: "error"; + /** + * @description * `ok` - ok + * @enum {string} + */ + OkStatusEnum: "ok"; + Room: { + readonly id: number; + friendly_name: string; + display_order: number; + room_temp_provider: string; + room_temp_sensor: string; + }; + RoomCreateRequest: { + name: string; + }; + RoomIdResponse: { + status: components["schemas"]["OkStatusEnum"]; + room_id: number; + }; + RoomListResponse: { + status: components["schemas"]["OkStatusEnum"]; + rooms: components["schemas"]["Room"][]; + }; + /** @description PUT /rest/rooms: the room's id, plus only the fields to change. */ + RoomUpdateRequest: { + id: number; + friendly_name?: string; + display_order?: number; + room_temp_provider?: string; + room_temp_sensor?: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + rooms_retrieve: { + parameters: { + query?: { + /** @description Only return this room. */ + id?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomListResponse"]; + }; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + rooms_update: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RoomUpdateRequest"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomIdResponse"]; + }; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + rooms_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RoomCreateRequest"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomIdResponse"]; + }; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + rooms_destroy: { + parameters: { + query?: never; + header?: never; + path: { + room_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomIdResponse"]; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; +} diff --git a/docker/web/nspanelmanager/web/react-components/src/components/RoomPage/RoomPage.tsx b/docker/web/nspanelmanager/web/react-components/src/components/RoomPage/RoomPage.tsx index a260368fb..6aac1c78f 100644 --- a/docker/web/nspanelmanager/web/react-components/src/components/RoomPage/RoomPage.tsx +++ b/docker/web/nspanelmanager/web/react-components/src/components/RoomPage/RoomPage.tsx @@ -10,6 +10,7 @@ import Select, { type OptionProps } from "react-select"; import { type IOptionType } from "../../stores/AvailableEntitiesStore"; import { useAvailableEntitiesStore } from "../../stores/AvailableEntitiesStore"; import { Notify } from "../NSPanelToastContainer"; +import { api, type RoomUpdateRequest } from "../../api/client"; export default function RoomPage({ room_id }: { room_id: number }) { const { status: stompStatus } = useStompStore(); @@ -20,7 +21,7 @@ export default function RoomPage({ room_id }: { room_id: number }) { const room = rooms[room_id]; const [hasLoadedValues, setHasLoadedValues] = useState(false); - const { control, register, getValues, setValue, reset, handleSubmit } = useForm({ + const { control, register, getValues, setValue, reset, handleSubmit } = useForm({ defaultValues: { id: room?.id, friendly_name: room?.friendly_name, @@ -31,44 +32,16 @@ export default function RoomPage({ room_id }: { room_id: number }) { void useWatch({ control, name: "room_temp_sensor" }); const current_friendly_name = useWatch({ control, name: "friendly_name" }); - function getCookie(name: string) { - let cookieValue = ""; - if (document.cookie && document.cookie !== "") { - const cookies = document.cookie.split(";"); - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim(); - if (cookie.substring(0, name.length + 1) === name + "=") { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; + const onSubmit = (data: RoomUpdateRequest) => { + api + .PUT("/rest/rooms", { body: data }) + .then(({ error, response }) => { + if (error) { + return Promise.reject(error.message || response.status); } - } - } - return cookieValue; - } - - const onSubmit = (data: any) => { - // PUT request using fetch with error handling - fetch("/rest/rooms", { - credentials: "same-origin", - method: "PUT", - mode: "same-origin", - headers: { "Content-Type": "application/json", "X-CSRFToken": getCookie("csrftoken") }, - body: JSON.stringify(data), - }) - .then(async (response) => { - const data = await response.json(); - - // check for error response - if (!response.ok) { - // get error message from body or default to response status - const error = (data && data.message) || response.status; - return Promise.reject(error); - } - Notify({ message: `Saved room setting successfully.`, level: "success", duration: 5000 }); }) .catch((error) => { - // setErrorMessage(error); Notify({ message: `Error saving entity. Error: ${error}`, level: "error", duration: 10000 }); }); }; diff --git a/docker/web/nspanelmanager/web/react-components/src/stores/EntityStore.ts b/docker/web/nspanelmanager/web/react-components/src/stores/EntityStore.ts index 62b7aba9b..a7fb128a6 100644 --- a/docker/web/nspanelmanager/web/react-components/src/stores/EntityStore.ts +++ b/docker/web/nspanelmanager/web/react-components/src/stores/EntityStore.ts @@ -1,14 +1,9 @@ import { create } from "zustand"; import { useStompStore } from "./StompStore"; import { Notify } from "../components/NSPanelToastContainer"; +import { api, type Room } from "../api/client"; -export interface IRoomData { - id: number; - friendly_name: string; - display_order: number; - room_temp_provider: string; - room_temp_sensor: string; -} +export type IRoomData = Room; interface INSPanelInfoData { id: number; @@ -98,9 +93,12 @@ export const useEntityStatesStore = create((set) => ({ }); console.debug("Loading rooms..."); - fetch("/rest/rooms") - .then((response) => response.json()) - .then((data) => { + api + .GET("/rest/rooms") + .then(({ data, error }) => { + if (error) { + return Promise.reject(error.message); + } console.debug("Got rooms data."); for (const room of data.rooms) { set({ rooms: { ...useEntityStatesStore.getState().rooms, [room.id]: room } }); diff --git a/docker/web/nspanelmanager/web/react-components/src/stores/RoomsStore.ts b/docker/web/nspanelmanager/web/react-components/src/stores/RoomsStore.ts index a6f3aae05..3ad521ee0 100644 --- a/docker/web/nspanelmanager/web/react-components/src/stores/RoomsStore.ts +++ b/docker/web/nspanelmanager/web/react-components/src/stores/RoomsStore.ts @@ -1,48 +1,20 @@ import { create } from "zustand"; - -interface IRoomSettings { - id: number; - friendly_name: string; - display_order: number; - room_temp_provider: string; - room_temp_sensor: string; -} +import { api, type Room } from "../api/client"; interface ISettingsStore { status: "none" | "loading" | "loaded"; - rooms: IRoomSettings[] | null; + rooms: Room[] | null; fetchData: () => Promise; } -function getCookie(name: string) { - let cookieValue = ""; - if (document.cookie && document.cookie !== "") { - const cookies = document.cookie.split(";"); - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim(); - if (cookie.substring(0, name.length + 1) === name + "=") { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } - } - return cookieValue; -} - export const useRoomsStore = create((set) => ({ status: "none", rooms: null, fetchData: async () => { set({ status: "loading" }); - fetch(`/rest/rooms`, { - credentials: "same-origin", - method: "GET", - mode: "same-origin", - headers: { "Content-Type": "application/json", "X-CSRFToken": getCookie("csrftoken") }, - }) - .then((response) => response.json()) - .then((data) => { - set((state) => ({ ...state, status: "loaded", rooms: data.rooms })); - }); + const { data } = await api.GET("/rest/rooms"); + if (data) { + set((state) => ({ ...state, status: "loaded", rooms: data.rooms })); + } }, })); diff --git a/docker/web/nspanelmanager/web/rest.py b/docker/web/nspanelmanager/web/rest.py index ad7563087..a4cd09cdf 100644 --- a/docker/web/nspanelmanager/web/rest.py +++ b/docker/web/nspanelmanager/web/rest.py @@ -7,8 +7,15 @@ import requests from django.core.files.storage import FileSystemStorage +from django.db import transaction from django.http import HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404 from django.views.decorators.csrf import csrf_exempt +from drf_spectacular.utils import extend_schema +from rest_framework import serializers as drf_serializers +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.views import exception_handler as drf_exception_handler import web.home_assistant_api import web.openhab_api @@ -18,8 +25,17 @@ set_setting_value, ) -from .apps import send_mqttmanager_reload_command +from .apps import create_entity_pages_for_room, send_mqttmanager_reload_command from .models import Entity, LightState, NSPanel, RelayGroup, Room, RoomEntitiesPage, Scene, Settings +from .serializers import ( + ErrorResponseSerializer, + RoomCreateSerializer, + RoomIdResponseSerializer, + RoomListQuerySerializer, + RoomListResponseSerializer, + RoomSerializer, + RoomUpdateSerializer, +) ######################## # Get entities section # @@ -103,7 +119,7 @@ def test_openhab(request): def mqttmanager_get_setting(request, setting_key): - if setting_key in banned_setting_keys: + if setting_key.upper() in banned_setting_keys: return JsonResponse({"status": "error"}, status=403) # Return error forbidden try: @@ -195,18 +211,6 @@ def relay_groups(request): #################### -@csrf_exempt -def rooms(request): - if request.method == "GET": - return rooms_get(request) - elif request.method == "PUT": - return room_put(request) - elif request.method == "POST": - return room_create(request) - else: - return JsonResponse({"status": "error"}, status=405) - - def settings(request): if request.method == "GET": return settings_get(request) @@ -220,12 +224,9 @@ def settings_get(request): settings = {} for setting in Settings.objects.all(): settings[setting.name] = setting.value - settings["home_assistant_token_set"] = settings.get("home_assistant_token", "") != "" - del settings["home_assistant_token"] - settings["openhab_token_set"] = settings.get("openhab_token", "") != "" - del settings["openhab_token"] - settings["mqtt_password_set"] = settings.get("mqtt_password", "") != "" - del settings["mqtt_password"] + # Never return secrets, only whether they are set. Any of them may never have been saved. + for secret in ("home_assistant_token", "openhab_token", "mqtt_password"): + settings[f"{secret}_set"] = settings.pop(secret, "") != "" return JsonResponse({"status": "ok", "settings": settings}, status=200) @@ -250,43 +251,36 @@ def settings_post(request): return JsonResponse({"status": "error"}, status=500) -def rooms_get(request): - try: - rooms = list() - if request.GET.get("id"): - room_objects = Room.objects.filter(id=request.GET.get("id")) - else: - room_objects = Room.objects.all() - for room in room_objects: - rooms.append( - { - "id": room.id, - "friendly_name": room.friendly_name, - "display_order": room.displayOrder, - "room_temp_provider": room.room_temp_provider, - "room_temp_sensor": room.room_temp_sensor, - } - ) - return JsonResponse({"status": "ok", "rooms": rooms}, status=200) - except Exception as ex: - logging.exception(ex) - return JsonResponse({"status": "error"}, status=500) +# The entities page layouts the panel GUI has. +PAGE_SIZES = (4, 8, 12) + + +def get_page_size_error(page_type): + if page_type not in PAGE_SIZES: + return f"Unsupported page size {page_type!r}. Expected one of: {', '.join(map(str, PAGE_SIZES))}." + return None def put_room_entities_order(request, room_id): if request.method == "PUT": try: data = json.loads(request.body) - for entity in data["entities"]: - db_entity = Entity.objects.get(id=entity["id"]) - db_entity.room_view_position = entity["room_view_position"] - db_entity.entities_page_id = entity["entities_page_id"] - db_entity.save() - for scene in data["scenes"]: - db_scene = Scene.objects.get(id=scene["id"]) - db_scene.room_view_position = scene["room_view_position"] - db_scene.entities_page_id = scene["entities_page_id"] - db_scene.save() + for item in data["entities"] + data["scenes"]: + page = RoomEntitiesPage.objects.get(id=item["entities_page_id"]) + if not 0 <= item["room_view_position"] < page.page_type: + return JsonResponse({"status": "error", "message": f"room_view_position must be between 0 and {page.page_type - 1} on page {page.id}."}, status=400) + # All or nothing: a bad entry must not leave the entries before it already moved. + with transaction.atomic(): + for entity in data["entities"]: + db_entity = Entity.objects.get(id=entity["id"]) + db_entity.room_view_position = entity["room_view_position"] + db_entity.entities_page_id = entity["entities_page_id"] + db_entity.save() + for scene in data["scenes"]: + db_scene = Scene.objects.get(id=scene["id"]) + db_scene.room_view_position = scene["room_view_position"] + db_scene.entities_page_id = scene["entities_page_id"] + db_scene.save() send_mqttmanager_reload_command() return JsonResponse({"status": "ok"}, status=200) except Exception as ex: @@ -325,6 +319,9 @@ def room_entities_pages(request, room_id): for field in required_fields: if field not in data: return JsonResponse({"status": "error", "message": f"Missing required field: {field}"}, status=400) + page_size_error = get_page_size_error(data["type"]) + if page_size_error: + return JsonResponse({"status": "error", "message": page_size_error}, status=400) room = Room.objects.get(id=room_id) pages = RoomEntitiesPage.objects.filter(room=room).order_by("display_order") @@ -365,9 +362,18 @@ def room_entities_page(request, page_id): try: data = json.loads(request.body) db_page = RoomEntitiesPage.objects.get(id=page_id) - db_page.page_type = data.get("number_of_entities", db_page.page_type) + new_page_type = data.get("number_of_entities", db_page.page_type) + if new_page_type != db_page.page_type: + page_size_error = get_page_size_error(new_page_type) + if page_size_error: + return JsonResponse({"status": "error", "message": page_size_error}, status=400) + # Shrinking must not leave entities in slots the panel no longer shows. + if db_page.entity_set.filter(room_view_position__gte=new_page_type).exists() or db_page.scene_set.filter(room_view_position__gte=new_page_type).exists(): + return JsonResponse({"status": "error", "message": f"Move or remove the entities in slots {new_page_type + 1} and above before shrinking this page."}, status=400) + db_page.page_type = new_page_type db_page.display_order = data.get("display_order", db_page.display_order) db_page.save() + send_mqttmanager_reload_command() return JsonResponse({"status": "ok"}, status=200) except Exception as ex: logging.exception(ex) @@ -495,6 +501,9 @@ def global_entities_pages(request): for field in required_fields: if field not in data: return JsonResponse({"status": "error", "message": f"Missing required field: {field}"}, status=400) + page_size_error = get_page_size_error(data["type"]) + if page_size_error: + return JsonResponse({"status": "error", "message": page_size_error}, status=400) pages = RoomEntitiesPage.objects.filter(room=None).order_by("display_order") new_display_order = 0 # Default to zero of no pages exists @@ -518,20 +527,6 @@ def global_entities_pages(request): ###################### -@csrf_exempt -def room_delete(request, room_id): - if request.method == "DELETE": - try: - room = Room.objects.get(id=room_id) - room.delete() - return JsonResponse({"status": "ok", "room_id": room_id}, status=200) - except Exception as ex: - logging.exception(ex) - return JsonResponse({"status": "error"}, status=500) - else: - return JsonResponse({"status": "error"}, status=405) - - def room_entities(request, room_id): if request.method == "GET": try: @@ -544,45 +539,68 @@ def room_entities(request, room_id): return JsonResponse({"status": "error"}, status=405) -def room_put(request): - if request.method == "PUT": - try: - data = json.loads(request.body) - room_id = data["id"] - room = Room.objects.get(id=room_id) - - if "friendly_name" in data: - room.friendly_name = data["friendly_name"] - if "displayOrder" in data: - room.displayOrder = data["display_order"] - if "room_temp_provider" in data: - room.room_temp_provider = data["room_temp_provider"] - if "room_temp_sensor" in data: - room.room_temp_sensor = data["room_temp_sensor"] - room.save() - send_mqttmanager_reload_command() - return JsonResponse({"status": "ok", "room_id": room.id}, status=200) - except Exception as ex: - logging.exception(ex) - return JsonResponse({"status": "error", "message": str(ex)}, status=500) +def exception_handler(exc, context): + """REST_FRAMEWORK["EXCEPTION_HANDLER"]: keep the {"status": "error", "message": ...} shape the + plain views return, which the UI shows to the user, and add field errors for validation failures.""" + response = drf_exception_handler(exc, context) + if response is None: + logging.exception(exc) + return Response({"status": "error", "message": str(exc)}, status=500) + if isinstance(exc, drf_serializers.ValidationError) and isinstance(response.data, dict): + errors = {field: [str(m) for m in (messages if isinstance(messages, list) else [messages])] for field, messages in response.data.items()} + response.data = {"status": "error", "message": "; ".join(f"{field}: {' '.join(messages)}" for field, messages in errors.items()), "errors": errors} else: - return JsonResponse({"status": "error"}, status=405) - - -@csrf_exempt -def room_create(request): - if request.method == "POST": - try: - data = json.loads(request.body) - new_room = Room() - new_room.friendly_name = data["name"] - new_room.save() - return JsonResponse({"status": "ok", "room_id": new_room.id}, status=200) - except Exception as ex: - logging.exception(ex) - return JsonResponse({"status": "error"}, status=500) - else: - return JsonResponse({"status": "error"}, status=405) + response.data = {"status": "error", "message": str(response.data.get("detail", response.data) if isinstance(response.data, dict) else response.data)} + return response + + +class RoomsView(APIView): + @extend_schema(parameters=[RoomListQuerySerializer], responses={200: RoomListResponseSerializer, 400: ErrorResponseSerializer}) + def get(self, request): + query = RoomListQuerySerializer(data=request.query_params) + query.is_valid(raise_exception=True) + rooms = Room.objects.all() + if "id" in query.validated_data: + rooms = rooms.filter(id=query.validated_data["id"]) + return Response({"status": "ok", "rooms": RoomSerializer(rooms, many=True).data}) + + @extend_schema(request=RoomUpdateSerializer, responses={200: RoomIdResponseSerializer, 400: ErrorResponseSerializer, 404: ErrorResponseSerializer}) + def put(self, request): + update = RoomUpdateSerializer(data=request.data) + update.is_valid(raise_exception=True) + changes = dict(update.validated_data) + room = get_object_or_404(Room, id=changes.pop("id")) + changes = {field: value for field, value in changes.items() if getattr(room, field) != value} + # The UI sends every field on each save; only reload MQTTManager (and every panel's config) if something differs. + if changes: + for field, value in changes.items(): + setattr(room, field, value) + room.save(update_fields=list(changes)) + send_mqttmanager_reload_command() + return Response({"status": "ok", "room_id": room.id}) + + @extend_schema(request=RoomCreateSerializer, responses={200: RoomIdResponseSerializer, 400: ErrorResponseSerializer}) + def post(self, request): + create = RoomCreateSerializer(data=request.data) + create.is_valid(raise_exception=True) + with transaction.atomic(): + new_room = Room.objects.create(friendly_name=create.validated_data["name"]) + create_entity_pages_for_room(new_room) + send_mqttmanager_reload_command() + return Response({"status": "ok", "room_id": new_room.id}) + + +class RoomView(APIView): + @extend_schema(responses={200: RoomIdResponseSerializer, 404: ErrorResponseSerializer}) + def delete(self, request, room_id): + room = get_object_or_404(Room, id=room_id) + # Panels are not deleted with their room. As in the UI's delete_room view, move them to another room. + new_room = Room.objects.exclude(id=room.id).first() + if new_room: + NSPanel.objects.filter(room=room).update(room=new_room) + room.delete() + send_mqttmanager_reload_command() + return Response({"status": "ok", "room_id": room_id}) ##################### @@ -628,7 +646,7 @@ def scenes(request): def scenes_get(request): try: scenes = [] - if request.GET.get("light_id"): + if request.GET.get("scene_id"): scenes_objects = Scene.objects.filter(id=request.GET.get("scene_id")) elif request.GET.get("room_id"): scenes_objects = Scene.objects.filter(room_id=request.GET.get("room_id")) @@ -732,6 +750,7 @@ def get_scene(request, scene_id): elif request.method == "DELETE": scene = Scene.objects.get(id=scene_id) scene.delete() + send_mqttmanager_reload_command() return JsonResponse({"status": "success"}) except Exception as ex: logging.exception(ex) @@ -740,6 +759,43 @@ def get_scene(request, scene_id): ### Generic Entity section ### +# The controllers MQTTManager's entity classes (docker/MQTTManager/include/{light,switch,button,thermostat}/) +# accept for each entity type. Anything else is logged as an error when it loads the entity. +ENTITY_CONTROLLERS = { + Entity.EntityType.LIGHT: ("home_assistant", "openhab"), + Entity.EntityType.SWITCH: ("home_assistant", "openhab"), + Entity.EntityType.BUTTON: ("home_assistant", "nspm"), + Entity.EntityType.THERMOSTAT: ("home_assistant", "openhab"), +} + +# The scene types MQTTManager's EntityManager loads. It ignores scenes of any other type. +SCENE_TYPES = ("home_assistant", "openhab", "nspm_scene") + + +def get_controller_error(entity_type, controller): + if controller not in ENTITY_CONTROLLERS[entity_type]: + return f"Unknown controller {controller!r}. Expected one of: {', '.join(ENTITY_CONTROLLERS[entity_type])}." + return None + + +def get_placement_error(obj, room, entities_page, room_view_position): + """Return why obj (an Entity or Scene) cannot go in that slot, or None if it can. + + Only a new or changed placement is checked, so entities saved before these checks + existed can still be edited without being moved. + """ + room_id = room.id if room else None + if obj.pk is not None and (obj.room_id, obj.entities_page_id, obj.room_view_position) == (room_id, entities_page.id, room_view_position): + return None + if entities_page.room_id != room_id: + return "The entities page belongs to a different room." + if not 0 <= room_view_position < entities_page.page_type: + return f"room_view_position must be between 0 and {entities_page.page_type - 1} on this page." + if type(obj).objects.filter(entities_page=entities_page, room_view_position=room_view_position).exclude(pk=obj.pk).exists(): + return "That position on the page is already taken." + return None + + def get_rest_entitiy_representation(entity_id): entity = Entity.objects.get(id=entity_id) return { @@ -787,7 +843,7 @@ def entities_lights(request): except Exception as ex: logging.exception(ex) return JsonResponse({"status": "error"}, status=500) - return JsonResponse({"status": "error", "error": "Unsupported method"}, status=403) + return JsonResponse({"status": "error", "error": "Unsupported method"}, status=405) def put_light_entity(request): @@ -814,6 +870,10 @@ def put_light_entity(request): if field not in data: return JsonResponse({"status": "error", "message": f"Missing required field: {field}"}, status=400) + controller_error = get_controller_error(Entity.EntityType.LIGHT, data["controller"]) + if controller_error: + return JsonResponse({"status": "error", "message": controller_error}, status=400) + entity_data = { "controller": data["controller"], "home_assistant_name": data.get("home_assistant_name", ""), @@ -833,10 +893,17 @@ def put_light_entity(request): new_light = Entity() new_light.entity_type = Entity.EntityType.LIGHT + room = Room.objects.get(id=int(data["room_id"])) + entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) + room_view_position = int(data["room_view_position"]) + placement_error = get_placement_error(new_light, room, entities_page, room_view_position) + if placement_error: + return JsonResponse({"status": "error", "message": placement_error}, status=400) + new_light.friendly_name = data["friendly_name"] - new_light.room = Room.objects.get(id=int(data["room_id"])) - new_light.entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) - new_light.room_view_position = int(data["room_view_position"]) + new_light.room = room + new_light.entities_page = entities_page + new_light.room_view_position = room_view_position new_light.entity_data = entity_data new_light.save() @@ -860,7 +927,7 @@ def entities_switches(request): except Exception as ex: logging.exception(ex) return JsonResponse({"status": "error"}, status=500) - return JsonResponse({"status": "error", "error": "Unsupported method"}, status=403) + return JsonResponse({"status": "error", "error": "Unsupported method"}, status=405) def put_switch_entity(request): @@ -880,6 +947,10 @@ def put_switch_entity(request): if field not in data: return JsonResponse({"status": "error", "message": f"Missing required field: {field}"}, status=400) + controller_error = get_controller_error(Entity.EntityType.SWITCH, data["controller"]) + if controller_error: + return JsonResponse({"status": "error", "message": controller_error}, status=400) + entity_data = { "openhab_item_switch": data.get("openhab_item_switch", ""), "home_assistant_name": data.get("home_assistant_name", ""), @@ -891,10 +962,17 @@ def put_switch_entity(request): new_switch = Entity() new_switch.entity_type = Entity.EntityType.SWITCH + room = Room.objects.get(id=int(data["room_id"])) + entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) + room_view_position = int(data["room_view_position"]) + placement_error = get_placement_error(new_switch, room, entities_page, room_view_position) + if placement_error: + return JsonResponse({"status": "error", "message": placement_error}, status=400) + new_switch.friendly_name = data["friendly_name"] - new_switch.room = Room.objects.get(id=int(data["room_id"])) - new_switch.entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) - new_switch.room_view_position = int(data["room_view_position"]) + new_switch.room = room + new_switch.entities_page = entities_page + new_switch.room_view_position = room_view_position new_switch.entity_data = entity_data new_switch.save() @@ -918,7 +996,7 @@ def entities_buttons(request): except Exception as ex: logging.exception(ex) return JsonResponse({"status": "error"}, status=500) - return JsonResponse({"status": "error", "error": "Unsupported method"}, status=403) + return JsonResponse({"status": "error", "error": "Unsupported method"}, status=405) def put_button_entity(request): @@ -939,6 +1017,10 @@ def put_button_entity(request): if field not in data: return JsonResponse({"status": "error", "message": f"Missing required field: {field}"}, status=400) + controller_error = get_controller_error(Entity.EntityType.BUTTON, data["controller"]) + if controller_error: + return JsonResponse({"status": "error", "message": controller_error}, status=400) + entity_data = { "mqtt_topic": data.get("mqtt_topic", ""), "mqtt_payload": data.get("mqtt_payload", ""), @@ -951,10 +1033,17 @@ def put_button_entity(request): new_button = Entity() new_button.entity_type = Entity.EntityType.BUTTON + room = Room.objects.get(id=int(data["room_id"])) + entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) + room_view_position = int(data["room_view_position"]) + placement_error = get_placement_error(new_button, room, entities_page, room_view_position) + if placement_error: + return JsonResponse({"status": "error", "message": placement_error}, status=400) + new_button.friendly_name = data["friendly_name"] - new_button.room = Room.objects.get(id=int(data["room_id"])) - new_button.entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) - new_button.room_view_position = int(data["room_view_position"]) + new_button.room = room + new_button.entities_page = entities_page + new_button.room_view_position = room_view_position new_button.entity_data = entity_data new_button.save() @@ -973,12 +1062,12 @@ def put_button_entity(request): def entities_thermostats(request): try: - # if request.method == "PUT": - return put_thermostat_entity(request) + if request.method == "PUT": + return put_thermostat_entity(request) except Exception as ex: logging.exception(ex) return JsonResponse({"status": "error"}, status=500) - return JsonResponse({"status": "error", "error": "Unsupported method"}, status=403) + return JsonResponse({"status": "error", "error": "Unsupported method"}, status=405) def put_thermostat_entity(request): @@ -1019,6 +1108,10 @@ def put_thermostat_entity(request): if field not in item: return JsonResponse({"status": "error", "message": f"Missing required field in {mode}. Missing field: {field}"}, status=400) + controller_error = get_controller_error(Entity.EntityType.THERMOSTAT, data["controller"]) + if controller_error: + return JsonResponse({"status": "error", "message": controller_error}, status=400) + entity_data = { "controller": data.get("controller", ""), "fan_modes": data.get("fan_modes", []), @@ -1043,10 +1136,17 @@ def put_thermostat_entity(request): new_thermostat = Entity() new_thermostat.entity_type = Entity.EntityType.THERMOSTAT + room = Room.objects.get(id=int(data["room_id"])) + entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) + room_view_position = int(data["room_view_position"]) + placement_error = get_placement_error(new_thermostat, room, entities_page, room_view_position) + if placement_error: + return JsonResponse({"status": "error", "message": placement_error}, status=400) + new_thermostat.friendly_name = data["friendly_name"] - new_thermostat.room = Room.objects.get(id=int(data["room_id"])) - new_thermostat.entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) - new_thermostat.room_view_position = int(data["room_view_position"]) + new_thermostat.room = room + new_thermostat.entities_page = entities_page + new_thermostat.room_view_position = room_view_position new_thermostat.entity_data = entity_data new_thermostat.save() @@ -1193,12 +1293,12 @@ def put_media_player_entity(request): def entities_scenes(request): try: - # if request.method == "PUT": - return put_scene_entity(request) + if request.method == "PUT": + return put_scene_entity(request) except Exception as ex: logging.exception(ex) return JsonResponse({"status": "error"}, status=500) - return JsonResponse({"status": "error", "error": "Unsupported method"}, status=403) + return JsonResponse({"status": "error", "error": "Unsupported method"}, status=405) def put_scene_entity(request): @@ -1219,15 +1319,27 @@ def put_scene_entity(request): if field not in data: return JsonResponse({"status": "error", "message": f"Missing required field: {field}"}, status=400) + if data["scene_type"] not in SCENE_TYPES: + return JsonResponse({"status": "error", "message": f"Unknown scene_type {data['scene_type']!r}. Expected one of: {', '.join(SCENE_TYPES)}."}, status=400) + if data["scene_type"] != "nspm_scene" and not data["backend_name"]: + return JsonResponse({"status": "error", "message": "backend_name is required for Home Assistant and OpenHAB scenes."}, status=400) + if "id" in data and data["id"]: new_scene = Scene.objects.get(id=int(data["id"])) else: new_scene = Scene() + room = Room.objects.get(id=int(data["room_id"])) if data["room_id"] else None + entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) + room_view_position = int(data["room_view_position"]) + placement_error = get_placement_error(new_scene, room, entities_page, room_view_position) + if placement_error: + return JsonResponse({"status": "error", "message": placement_error}, status=400) + new_scene.friendly_name = data["friendly_name"] - new_scene.room = Room.objects.get(id=int(data["room_id"])) if data["room_id"] else None - new_scene.entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) - new_scene.room_view_position = int(data["room_view_position"]) + new_scene.room = room + new_scene.entities_page = entities_page + new_scene.room_view_position = room_view_position new_scene.scene_type = data.get("scene_type", "") new_scene.backend_name = data.get("backend_name", "") diff --git a/docker/web/nspanelmanager/web/serializers.py b/docker/web/nspanelmanager/web/serializers.py new file mode 100644 index 000000000..5c93740a7 --- /dev/null +++ b/docker/web/nspanelmanager/web/serializers.py @@ -0,0 +1,87 @@ +""" +Django REST Framework serializers for the /rest/* API. + +These are the contract between the React UI and Django: drf-spectacular generates +docker/web/openapi.yaml from them, and the UI's TypeScript types are generated from that +(see docker/web/generate_api.sh). Rename a field here and the UI stops compiling. +""" + +import logging +from typing import ClassVar + +from django.conf import settings +from rest_framework import serializers + +from .models import Room + +logger = logging.getLogger(__name__) + + +# DRF silently drops keys it doesn't know. Log them instead, so a misspelled field +# (e.g. "displayOrder" for "display_order") leaves a trace rather than a save that quietly does nothing. +# We COULD return a 400 in production too, which catches such bugs immediately, but then an older or +# newer UI that sends a field this version doesn't know (one that was added or removed) would have +# every save rejected. So only the test suite rejects them (REST_REJECT_UNKNOWN_FIELDS in +# test_settings.py), where a request body that has drifted from the serializer should fail loudly. +# (A comment rather than a docstring: drf-spectacular would copy a docstring into every schema.) +class WarnOnUnknownFieldsMixin: + def to_internal_value(self, data): + if isinstance(data, dict): + unknown = set(data) - set(self.fields) + if unknown: + if settings.REST_REJECT_UNKNOWN_FIELDS: + raise serializers.ValidationError({key: ["Unknown field."] for key in sorted(unknown)}) + logger.warning("%s: ignoring unknown field(s): %s", type(self).__name__, ", ".join(sorted(unknown))) + return super().to_internal_value(data) + + +class OkResponseSerializer(serializers.Serializer): + status = serializers.ChoiceField(choices=["ok"]) + + +class ErrorResponseSerializer(serializers.Serializer): + status = serializers.ChoiceField(choices=["error"]) + message = serializers.CharField() + errors = serializers.DictField(child=serializers.ListField(child=serializers.CharField()), required=False, help_text="Validation errors by field name.") + + +######### +# Rooms # +######### + + +class RoomSerializer(WarnOnUnknownFieldsMixin, serializers.ModelSerializer): + # The model field predates the snake_case API names. + display_order = serializers.IntegerField(source="displayOrder") + + class Meta: + model = Room + fields = ("id", "friendly_name", "display_order", "room_temp_provider", "room_temp_sensor") + # Blank means no temperature sensor. required=True only affects the response schema, so the UI type isn't optional. + extra_kwargs: ClassVar[dict] = {"room_temp_provider": {"allow_blank": True, "required": True}, "room_temp_sensor": {"allow_blank": True, "required": True}} + + +class RoomUpdateSerializer(RoomSerializer): + """PUT /rest/rooms: the room's id, plus only the fields to change.""" + + id = serializers.IntegerField() + display_order = serializers.IntegerField(source="displayOrder", required=False) + + class Meta(RoomSerializer.Meta): + extra_kwargs: ClassVar[dict] = {"friendly_name": {"required": False}, "room_temp_provider": {"allow_blank": True, "required": False}, "room_temp_sensor": {"allow_blank": True, "required": False}} + + +class RoomCreateSerializer(WarnOnUnknownFieldsMixin, serializers.Serializer): + name = serializers.CharField(max_length=Room._meta.get_field("friendly_name").max_length) + + +class RoomListQuerySerializer(serializers.Serializer): + id = serializers.IntegerField(required=False, help_text="Only return this room.") + + +class RoomListResponseSerializer(OkResponseSerializer): + rooms = RoomSerializer(many=True) + + +class RoomIdResponseSerializer(OkResponseSerializer): + room_id = serializers.IntegerField() diff --git a/docker/web/nspanelmanager/web/templates/base.html b/docker/web/nspanelmanager/web/templates/base.html index d4896ea08..7ce58cc7b 100644 --- a/docker/web/nspanelmanager/web/templates/base.html +++ b/docker/web/nspanelmanager/web/templates/base.html @@ -68,9 +68,6 @@ {% endfor %}
- - {% if manager_address == "" %}{% include 'modals/initial_setup/welcome.html' %}{% endif %} - {% block content %} {% endblock %} diff --git a/docker/web/nspanelmanager/web/templates/index.html b/docker/web/nspanelmanager/web/templates/index.html index 5f3c29333..9951689f8 100644 --- a/docker/web/nspanelmanager/web/templates/index.html +++ b/docker/web/nspanelmanager/web/templates/index.html @@ -4,7 +4,6 @@ {% block head %} {% load static %} -{% if manager_address == "" %}{% endif %} {% endblock %} {% block content %} diff --git a/docker/web/nspanelmanager/web/templates/modals/initial_setup/finished.html b/docker/web/nspanelmanager/web/templates/modals/initial_setup/finished.html deleted file mode 100644 index f611b63fe..000000000 --- a/docker/web/nspanelmanager/web/templates/modals/initial_setup/finished.html +++ /dev/null @@ -1,61 +0,0 @@ -{% load static %} - - - - - - - - diff --git a/docker/web/nspanelmanager/web/templates/modals/initial_setup/home_assistant.html b/docker/web/nspanelmanager/web/templates/modals/initial_setup/home_assistant.html deleted file mode 100644 index 87f90def8..000000000 --- a/docker/web/nspanelmanager/web/templates/modals/initial_setup/home_assistant.html +++ /dev/null @@ -1,74 +0,0 @@ -{% load static %} - - - - - - - - diff --git a/docker/web/nspanelmanager/web/templates/modals/initial_setup/manager_settings.html b/docker/web/nspanelmanager/web/templates/modals/initial_setup/manager_settings.html deleted file mode 100644 index 15aad28e7..000000000 --- a/docker/web/nspanelmanager/web/templates/modals/initial_setup/manager_settings.html +++ /dev/null @@ -1,99 +0,0 @@ -{% load static %} - - - - - - - - - diff --git a/docker/web/nspanelmanager/web/templates/modals/initial_setup/mqtt.html b/docker/web/nspanelmanager/web/templates/modals/initial_setup/mqtt.html deleted file mode 100644 index 60e65169c..000000000 --- a/docker/web/nspanelmanager/web/templates/modals/initial_setup/mqtt.html +++ /dev/null @@ -1,95 +0,0 @@ -{% load static %} - - - - - - - - diff --git a/docker/web/nspanelmanager/web/templates/modals/initial_setup/openhab.html b/docker/web/nspanelmanager/web/templates/modals/initial_setup/openhab.html deleted file mode 100644 index ca0f144da..000000000 --- a/docker/web/nspanelmanager/web/templates/modals/initial_setup/openhab.html +++ /dev/null @@ -1,70 +0,0 @@ -{% load static %} - - - - - - - - diff --git a/docker/web/nspanelmanager/web/templates/modals/initial_setup/welcome.html b/docker/web/nspanelmanager/web/templates/modals/initial_setup/welcome.html deleted file mode 100644 index 3f7313f38..000000000 --- a/docker/web/nspanelmanager/web/templates/modals/initial_setup/welcome.html +++ /dev/null @@ -1,59 +0,0 @@ -{% load static %} - - - - - - - - diff --git a/docker/web/nspanelmanager/web/tests.py b/docker/web/nspanelmanager/web/tests.py deleted file mode 100755 index 7ce503c2d..000000000 --- a/docker/web/nspanelmanager/web/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/docker/web/nspanelmanager/web/tests/__init__.py b/docker/web/nspanelmanager/web/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docker/web/nspanelmanager/web/tests/harness.py b/docker/web/nspanelmanager/web/tests/harness.py new file mode 100644 index 000000000..c31f3f880 --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/harness.py @@ -0,0 +1,231 @@ +""" +Shared test harness for the NSPanelManager Django app. + +Every test should inherit from NSPMTestCase. It gives you: + + * An isolated in-memory database per test (via django.test.TestCase). + * self.mqttmanager -- a stand-in for the MQTTManager process. Views that change + config call send_mqttmanager_reload_command(); here that is recorded instead of + SIGUSR1-ing whatever nspm_mqttmanager happens to be running on the host. Use + self.assertManagerReloaded() / self.assertManagerNotReloaded() to verify the + manager would have been told to pick up the change. + * self.home_assistant -- a fake Home Assistant /api/states endpoint. Add states with + self.home_assistant.add("light.kitchen", friendly_name="Kitchen"). + self.openhab_items -- the item list returned by the (faked) OpenHAB API. + * assertLoadableByManager(obj) -- checks a saved Entity/Scene has the shape MQTTManager + parses when it reads the database, e.g. entity_data["controller"] is a known string. + * self.client -- a Django test client that returns 500 responses instead of raising, + so tests can assert that bad input is rejected the same way a browser would see it. + * Builders for common fixtures (make_room, make_light, make_scene, ...) and put_json / + post_json helpers that send requests the way the React UI does. +""" + +import json +import sys +from typing import ClassVar +from unittest import mock + +from django.test import Client, TestCase + +import web.apps +from web.models import Entity, NSPanel, Room, RoomEntitiesPage, Scene +from web.settings_helper import get_setting_with_default, set_setting_value + +# MQTTManager process-control helpers in web.apps. Modules import these by name +# (`from .apps import x`), which binds a separate reference, so every web.* module holding +# one is patched -- found at runtime so that adding or removing an import needs no change here. +_MANAGER_HOOKS = ("send_mqttmanager_reload_command", "restart_mqtt_manager_process", "start_mqtt_manager") + + +def _modules_holding(name): + original = getattr(web.apps, name) + return [module_name for module_name, module in list(sys.modules.items()) if (module_name == "web" or module_name.startswith("web.")) and getattr(module, name, None) is original] + + +class FakeMQTTManager: + """Records the process-control calls the web app makes towards MQTTManager.""" + + def __init__(self): + self.reloads = 0 + self.restarts = 0 + + def _reload(self): + self.reloads += 1 + + def _restart(self): + self.restarts += 1 + + def reset(self): + self.reloads = 0 + self.restarts = 0 + + +class FakeHomeAssistant: + """Serves a canned /api/states response to web.home_assistant_api.""" + + def __init__(self): + self.states = [] + self.fail = False + + def add(self, entity_id, friendly_name=None, state="off", **attributes): + if friendly_name is not None: + attributes["friendly_name"] = friendly_name + self.states.append({"entity_id": entity_id, "state": state, "attributes": attributes}) + + def _get(self, url, *args, **kwargs): + response = mock.Mock() + if self.fail or not url.endswith("/api/states"): + response.status_code = 500 + response.json.return_value = {} + else: + response.status_code = 200 + response.json.return_value = self.states + return response + + +class NSPMTestCase(TestCase): + def setUp(self): + super().setUp() + self.client = Client(raise_request_exception=False) + + # Data migrations seed a "Dummy room" (and pages for it). Start every test from an + # empty configuration so tests only see what they create themselves. + Room.objects.all().delete() + RoomEntitiesPage.objects.all().delete() + + self.mqttmanager = FakeMQTTManager() + hooks = { + "send_mqttmanager_reload_command": self.mqttmanager._reload, + "restart_mqtt_manager_process": self.mqttmanager._restart, + "start_mqtt_manager": self.mqttmanager._restart, + } + for name in _MANAGER_HOOKS: + for module in _modules_holding(name): + self._patch(f"{module}.{name}", side_effect=hooks[name]) + # Safety net: even if a new code path finds another way to reach these, never + # let a test signal or spawn a real MQTTManager on the host. + self._patch("web.apps.psutil.process_iter", return_value=[]) + self._patch("web.apps.subprocess.Popen", side_effect=AssertionError("Tests must not start MQTTManager")) + + self.home_assistant = FakeHomeAssistant() + set_setting_value("home_assistant_address", "http://home-assistant.test") + set_setting_value("home_assistant_token", "test-token") + + # OpenHAB is faked one level higher, at its item-listing functions. + self.openhab_items = [] + self._patch("web.openhab_api.get_all_openhab_items", side_effect=lambda *args, **kwargs: {"items": self.openhab_items, "errors": []}) + self._patch("web.openhab_api.get_all_openhab_scenes", side_effect=lambda *args, **kwargs: {"items": self.openhab_items, "errors": []}) + + # Every module shares the one `requests` module, so route all HTTP through here: + # Home Assistant goes to the fake, anything else fails the test. + self._patch("requests.get", side_effect=self._fake_http_get) + + def _fake_http_get(self, url, *args, **kwargs): + if url.startswith(get_setting_with_default("home_assistant_address")): + return self.home_assistant._get(url, *args, **kwargs) + raise AssertionError(f"Unexpected network request from test: GET {url}") + + def _patch(self, target, **kwargs): + patcher = mock.patch(target, **kwargs) + patcher.start() + self.addCleanup(patcher.stop) + + # ---------------------------------------------------------------- assertions + + def assertManagerReloaded(self, times=None): + if times is None: + self.assertGreater(self.mqttmanager.reloads, 0, "Expected MQTTManager to be told to reload its config") + else: + self.assertEqual(self.mqttmanager.reloads, times, f"Expected MQTTManager to be told to reload {times} time(s)") + + def assertManagerNotReloaded(self): + self.assertEqual(self.mqttmanager.reloads, 0, "MQTTManager was told to reload although nothing should have changed") + + # MQTTManager reads entities straight from the SQLite database. These are the values its + # entity classes (docker/MQTTManager/include/{light,switch,button,thermostat}/) accept for + # entity_data["controller"]; anything else is logged as an error, and a non-string throws. + MANAGER_CONTROLLERS: ClassVar = { + Entity.EntityType.LIGHT: {"home_assistant", "openhab"}, + Entity.EntityType.SWITCH: {"home_assistant", "openhab"}, + Entity.EntityType.BUTTON: {"home_assistant", "nspm"}, + Entity.EntityType.THERMOSTAT: {"home_assistant", "openhab"}, + } + MANAGER_SCENE_TYPES: ClassVar = {"home_assistant", "openhab", "nspm_scene"} + + def assertLoadableByManager(self, obj): + """Check a saved Entity/Scene row has the shape MQTTManager expects to parse.""" + obj.refresh_from_db() + if isinstance(obj, Scene): + self.assertIn(obj.scene_type, self.MANAGER_SCENE_TYPES) + if obj.scene_type != "nspm_scene": + self.assertTrue(obj.backend_name, "Home Assistant/OpenHAB scenes need a backend_name to activate") + else: + controller = obj.entity_data.get("controller") + self.assertIsInstance(controller, str, f"entity_data['controller'] must be a string, got {controller!r}") + self.assertIn(controller, self.MANAGER_CONTROLLERS[obj.entity_type]) + self.assertIsNotNone(obj.entities_page_id) + self.assertLess(obj.room_view_position, obj.entities_page.page_type, "Entity is placed in a slot outside its page") + + def assertRejected(self, response): + """The request was refused, however the view chose to signal it.""" + if response.status_code >= 400: + return + if response.get("Content-Type", "").startswith("application/json"): + self.assertEqual(response.json().get("status"), "error", f"Expected an error response, got: {response.content!r}") + return + self.fail(f"Expected request to be rejected, got HTTP {response.status_code}") + + # ---------------------------------------------------------------- builders + + def make_room(self, name="Room"): + """Create a room the way the UI does: with one entities page and one scenes page.""" + room = Room.objects.create(friendly_name=name) + RoomEntitiesPage.objects.create(room=room, page_type=4, is_scenes_page=True, display_order=0) + RoomEntitiesPage.objects.create(room=room, page_type=4, is_scenes_page=False, display_order=0) + return room + + def entities_page(self, room, index=0): + return RoomEntitiesPage.objects.filter(room=room, is_scenes_page=False).order_by("display_order")[index] + + def scenes_page(self, room, index=0): + return RoomEntitiesPage.objects.filter(room=room, is_scenes_page=True).order_by("display_order")[index] + + def global_scenes_page(self): + page, _ = RoomEntitiesPage.objects.get_or_create(room=None, is_scenes_page=True, defaults={"page_type": 4, "display_order": 0}) + return page + + def make_entity(self, room, entity_type=Entity.EntityType.LIGHT, name="Entity", page=None, slot=0, **entity_data): + entity_data.setdefault("controller", "home_assistant") + return Entity.objects.create( + room=room, + friendly_name=name, + entity_type=entity_type, + entities_page=page or self.entities_page(room), + room_view_position=slot, + entity_data=entity_data, + ) + + def make_light(self, room, name="Light", **kwargs): + return self.make_entity(room, Entity.EntityType.LIGHT, name, **kwargs) + + def make_scene(self, room, name="Scene", page=None, slot=0, scene_type="home_assistant", backend_name="scene.test"): + return Scene.objects.create( + room=room, + friendly_name=name, + scene_type=scene_type, + backend_name=backend_name, + entities_page=page or (self.scenes_page(room) if room else self.global_scenes_page()), + room_view_position=slot, + ) + + def make_panel(self, room, name="Panel", mac="AA:BB:CC:DD:EE:FF", **fields): + return NSPanel.objects.create(room=room, friendly_name=name, mac_address=mac, model=NSPanel.NSPanelModel.SONOFF, **fields) + + # ---------------------------------------------------------------- REST helpers + + def put_json(self, url, data): + """Send JSON the way the React UI does (fetch with a JSON body).""" + return self.client.put(url, json.dumps(data) if not isinstance(data, str) else data, content_type="application/json") + + def post_json(self, url, data): + return self.client.post(url, json.dumps(data) if not isinstance(data, str) else data, content_type="application/json") diff --git a/docker/web/nspanelmanager/web/tests/test_entities.py b/docker/web/nspanelmanager/web/tests/test_entities.py new file mode 100644 index 000000000..cd10c808b --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/test_entities.py @@ -0,0 +1,350 @@ +""" +Creating and editing entities through the /rest/entities/* endpoints, using the same +payloads the React "add/edit entity" modal sends. +""" + +from django.urls import reverse + +from web.models import Entity, Scene + +from .harness import NSPMTestCase + + +def placement(page, slot): + return {"room_id": page.room_id, "entities_page_id": page.id, "room_view_position": slot} + + +class EntityRESTTestCase(NSPMTestCase): + """Base for the per-type entity endpoints. Subclasses define url and payload().""" + + url = None + model = Entity + + def setUp(self): + super().setUp() + self.room = self.make_room("Kitchen") + self.page = self.entities_page(self.room) + + def payload(self, slot=0, **overrides): + raise NotImplementedError + + def fields(self, payload): + """The dict holding the entity fields (lights wrap them in "values").""" + return payload + + def save(self, payload): + return self.put_json(reverse(self.url), payload) + + def assertCreatesNothing(self, response): + self.assertRejected(response) + self.assertFalse(self.model.objects.exists()) + self.assertManagerNotReloaded() + + +class EntityRESTCommonTests: + """Checks shared by every entity type. A mixin so it is not collected and run on its own.""" + + def test_every_required_field_is_enforced(self): + for field in list(self.fields(self.payload())): + with self.subTest(missing=field): + payload = self.payload() + del self.fields(payload)[field] + response = self.save(payload) + self.assertEqual(response.status_code, 400) + self.assertIn(field, response.json()["message"]) + self.assertFalse(self.model.objects.exists()) + self.assertManagerNotReloaded() + + def test_unknown_room_or_page_is_rejected(self): + for field in ("room_id", "entities_page_id"): + with self.subTest(field=field): + self.assertRejected(self.save(self.payload(**{field: 999}))) + self.assertFalse(self.model.objects.exists()) + self.assertManagerNotReloaded() + + def test_malformed_body_is_rejected(self): + self.assertCreatesNothing(self.put_json(reverse(self.url), "not json")) + + def test_edit_updates_in_place(self): + self.save(self.payload(slot=1)) + obj = self.model.objects.get() + self.mqttmanager.reset() + + response = self.save(self.payload(slot=1, id=obj.id, friendly_name="Renamed")) + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.model.objects.count(), 1) + obj.refresh_from_db() + self.assertEqual(obj.friendly_name, "Renamed") + self.assertEqual(obj.room_view_position, 1) + self.assertManagerReloaded(times=1) + + def test_edit_unknown_id_is_rejected(self): + self.assertCreatesNothing(self.save(self.payload(id=999))) + + def test_other_methods_are_rejected(self): + # POST carries a valid payload, so only the method check can stop it being saved. + self.assertEqual(self.client.get(reverse(self.url)).status_code, 405) + self.assertCreatesNothing(self.post_json(reverse(self.url), self.payload())) + + def test_add_to_occupied_slot_is_rejected(self): + self.save(self.payload(slot=0)) + + self.assertRejected(self.save(self.payload(slot=0))) + self.assertEqual(self.model.objects.count(), 1) + + def test_add_to_slot_outside_page_is_rejected(self): + self.assertCreatesNothing(self.save(self.payload(slot=7))) + + +class LightTests(EntityRESTCommonTests, EntityRESTTestCase): + url = "rest_entities_lights" + + def payload(self, slot=0, **overrides): + values = { + **placement(self.page, slot), + "controller": "home_assistant", + "type": "light", + "friendly_name": "Ceiling", + "can_color_temperature": True, + "can_dim": True, + "can_rgb": False, + "controlled_by_nspanel_main_page": True, + "home_assistant_name": "light.kitchen_ceiling", + "is_ceiling_light": True, + "openhab_item_color_temp": "", + "openhab_item_dimmer": "", + "openhab_item_rgb": "", + } + return {"values": values | overrides} + + def fields(self, payload): + return payload["values"] + + def test_add_home_assistant_light(self): + response = self.save(self.payload(slot=2)) + + self.assertEqual(response.status_code, 200) + light = Entity.objects.get() + self.assertEqual(light.entity_type, Entity.EntityType.LIGHT) + self.assertEqual(light.friendly_name, "Ceiling") + self.assertEqual((light.room, light.entities_page, light.room_view_position), (self.room, self.page, 2)) + self.assertEqual(light.entity_data["home_assistant_name"], "light.kitchen_ceiling") + self.assertTrue(light.entity_data["can_dim"]) + self.assertTrue(light.entity_data["can_color_temperature"]) + self.assertFalse(light.entity_data["can_rgb"]) + self.assertTrue(light.entity_data["is_ceiling_light"]) + self.assertLoadableByManager(light) + self.assertManagerReloaded(times=1) + + def test_add_openhab_light(self): + payload = self.payload(controller="openhab", home_assistant_name="", can_dim=False, openhab_item_dimmer="Lamp_Switch") + + self.assertEqual(self.save(payload).status_code, 200) + + light = Entity.objects.get() + self.assertEqual(light.entity_data["openhab_control_mode"], "switch") + self.assertEqual(light.entity_data["openhab_item_dimmer"], "Lamp_Switch") + self.assertLoadableByManager(light) + + def test_boolean_flags_accept_strings(self): + self.save(self.payload(can_dim="false", can_rgb="true", is_ceiling_light="False")) + + data = Entity.objects.get().entity_data + self.assertEqual((data["can_dim"], data["can_rgb"], data["is_ceiling_light"]), (False, True, False)) + + def test_unknown_controller_is_rejected(self): + self.assertCreatesNothing(self.save(self.payload(controller="zigbee"))) + + def test_page_from_another_room_is_rejected(self): + other_page = self.entities_page(self.make_room("Lounge")) + + self.assertCreatesNothing(self.save(self.payload(entities_page_id=other_page.id))) + + def test_entity_saved_before_placement_checks_can_still_be_edited(self): + # Only a changed placement is validated, so rows already in a bad slot stay editable. + light = self.make_light(self.room, slot=7) + + response = self.save(self.payload(slot=7, id=light.id, friendly_name="Renamed")) + + self.assertEqual(response.status_code, 200) + light.refresh_from_db() + self.assertEqual(light.friendly_name, "Renamed") + + +class SwitchTests(EntityRESTCommonTests, EntityRESTTestCase): + url = "rest_entities_switches" + + def payload(self, slot=0, **overrides): + return { + **placement(self.page, slot), + "controller": "home_assistant", + "type": "switch", + "friendly_name": "Fan", + "home_assistant_name": "switch.kitchen_fan", + "openhab_item_switch": "", + } | overrides + + def test_add_switch(self): + self.assertEqual(self.save(self.payload()).status_code, 200) + + switch = Entity.objects.get() + self.assertEqual(switch.entity_type, Entity.EntityType.SWITCH) + self.assertEqual(switch.entity_data["home_assistant_name"], "switch.kitchen_fan") + self.assertLoadableByManager(switch) + self.assertManagerReloaded(times=1) + + def test_add_openhab_switch(self): + self.save(self.payload(controller="openhab", home_assistant_name="", openhab_item_switch="Kitchen_Fan")) + + switch = Entity.objects.get() + self.assertEqual(switch.entity_data["openhab_item_switch"], "Kitchen_Fan") + self.assertLoadableByManager(switch) + + +class ButtonTests(EntityRESTCommonTests, EntityRESTTestCase): + url = "rest_entities_buttons" + + def payload(self, slot=0, **overrides): + return { + **placement(self.page, slot), + "controller": "nspm", + "type": "button", + "friendly_name": "Gate", + "home_assistant_name": "", + "mqtt_topic": "gate/cmd", + "mqtt_payload": "open", + } | overrides + + def test_add_mqtt_button(self): + self.assertEqual(self.save(self.payload()).status_code, 200) + + button = Entity.objects.get() + self.assertEqual(button.entity_type, Entity.EntityType.BUTTON) + self.assertEqual((button.entity_data["mqtt_topic"], button.entity_data["mqtt_payload"]), ("gate/cmd", "open")) + self.assertLoadableByManager(button) + self.assertManagerReloaded(times=1) + + def test_add_home_assistant_button(self): + self.save(self.payload(controller="home_assistant", home_assistant_name="input_button.doorbell", mqtt_topic="", mqtt_payload="")) + + button = Entity.objects.get() + self.assertEqual(button.entity_data["home_assistant_name"], "input_button.doorbell") + self.assertLoadableByManager(button) + + def test_openhab_controller_is_rejected(self): + # MQTTManager only has Home Assistant and MQTT ("nspm") buttons. + self.assertCreatesNothing(self.save(self.payload(controller="openhab"))) + + +class ThermostatTests(EntityRESTCommonTests, EntityRESTTestCase): + url = "rest_entities_thermostats" + + def payload(self, slot=0, **overrides): + return { + **placement(self.page, slot), + "controller": "home_assistant", + "type": "thermostat", + "friendly_name": "Heat pump", + "step_size": "0.5", + "use_current_temperature": "True", + "home_assistant_name": "climate.kitchen", + "openhab_fan_mode_item": "", + "openhab_hvac_mode_item": "", + "openhab_preset_mode_item": "", + "openhab_swing_mode_item": "", + "openhab_swingh_mode_item": "", + "openhab_temperature_item": "", + "fan_modes": [], + "hvac_modes": [{"value": "heat", "icon": "fire", "label": "Heat"}, {"value": "cool", "icon": "snow", "label": "Cool"}], + "preset_modes": [], + "swing_modes": [], + "swingh_modes": [], + } | overrides + + def test_add_thermostat(self): + self.assertEqual(self.save(self.payload()).status_code, 200) + + thermostat = Entity.objects.get() + self.assertEqual(thermostat.entity_type, Entity.EntityType.THERMOSTAT) + self.assertEqual(thermostat.entity_data["step_size"], 0.5) + self.assertEqual([m["value"] for m in thermostat.entity_data["hvac_modes"]], ["heat", "cool"]) + self.assertEqual(thermostat.entity_data["home_assistant_name"], "climate.kitchen") + self.assertIs(thermostat.entity_data["use_current_temperature"], True) + self.assertLoadableByManager(thermostat) + self.assertManagerReloaded(times=1) + + def test_mode_missing_a_field_is_rejected(self): + for field in ("value", "icon", "label"): + with self.subTest(missing=field): + mode = {"value": "heat", "icon": "fire", "label": "Heat"} + del mode[field] + self.assertEqual(self.save(self.payload(hvac_modes=[mode])).status_code, 400) + self.assertFalse(Entity.objects.exists()) + + def test_non_numeric_step_size_is_rejected(self): + self.assertCreatesNothing(self.save(self.payload(step_size="lots"))) + + +class SceneTests(EntityRESTCommonTests, EntityRESTTestCase): + url = "rest_entities_scenes" + model = Scene + + def setUp(self): + super().setUp() + self.page = self.scenes_page(self.room) + + def payload(self, slot=0, **overrides): + return { + **placement(self.page, slot), + "controller": "home_assistant", + "type": "scene", + "friendly_name": "Movie", + "scene_type": "home_assistant", + "backend_name": "scene.movie_time", + } | overrides + + def test_add_room_scene(self): + self.assertEqual(self.save(self.payload(slot=1)).status_code, 200) + + scene = Scene.objects.get() + self.assertEqual((scene.room, scene.entities_page, scene.room_view_position), (self.room, self.page, 1)) + self.assertEqual(scene.backend_name, "scene.movie_time") + self.assertLoadableByManager(scene) + self.assertManagerReloaded(times=1) + + def test_add_global_nspm_scene(self): + page = self.global_scenes_page() + + response = self.save(self.payload(room_id=None, entities_page_id=page.id, controller="nspm", scene_type="nspm_scene", backend_name="")) + + self.assertEqual(response.status_code, 200) + scene = Scene.objects.get() + self.assertIsNone(scene.room) + self.assertLoadableByManager(scene) + + def test_add_home_assistant_script_as_a_room_scene(self): + self.assertEqual(self.save(self.payload(friendly_name="Lights out", backend_name="script.lights_out")).status_code, 200) + + scene = Scene.objects.get() + self.assertEqual((scene.scene_type, scene.backend_name), ("home_assistant", "script.lights_out")) + self.assertEqual(scene.room, self.room) + self.assertLoadableByManager(scene) + self.assertManagerReloaded(times=1) + + def test_add_home_assistant_script_as_a_global_scene(self): + page = self.global_scenes_page() + + response = self.save(self.payload(room_id=None, entities_page_id=page.id, friendly_name="All off", backend_name="script.all_off")) + + self.assertEqual(response.status_code, 200) + scene = Scene.objects.get() + self.assertIsNone(scene.room) + self.assertEqual(scene.backend_name, "script.all_off") + self.assertLoadableByManager(scene) + + def test_home_assistant_scene_without_backend_name_is_rejected(self): + self.assertCreatesNothing(self.save(self.payload(backend_name=""))) + + def test_unknown_scene_type_is_rejected(self): + self.assertCreatesNothing(self.save(self.payload(scene_type="zigbee"))) diff --git a/docker/web/nspanelmanager/web/tests/test_files.py b/docker/web/nspanelmanager/web/tests/test_files.py new file mode 100644 index 000000000..0b3ef7711 --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/test_files.py @@ -0,0 +1,162 @@ +""" +Firmware, LittleFS and TFT images: uploading from the UI, and the checksum and download +endpoints panels use to decide whether to update and to fetch the image. +""" + +import hashlib +import shutil +import tempfile + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import override_settings +from django.urls import reverse + +from web.settings_helper import set_nspanel_setting_value + +from .harness import NSPMTestCase + +FIRMWARE = bytes(range(256)) * 4 # 1 KiB with a recognisable pattern + + +def md5(data): + return hashlib.md5(data).hexdigest() + + +class FileTestCase(NSPMTestCase): + def setUp(self): + super().setUp() + media_root = tempfile.mkdtemp(prefix="nspm-media-") + self.addCleanup(shutil.rmtree, media_root, ignore_errors=True) + override = override_settings(MEDIA_ROOT=media_root) + override.enable() + self.addCleanup(override.disable) + + def upload(self, url_name, field, data, **form): + return self.client.post(reverse(url_name), {field: SimpleUploadedFile("upload.bin", data), **form}) + + +class FirmwareTests(FileTestCase): + def test_upload_then_checksum_and_download(self): + response = self.upload("save_new_firmware", "firmware", FIRMWARE, model="sonoff") + + self.assertRedirects(response, "/", fetch_redirect_response=False) + self.assertEqual(self.client.get(reverse("checksum_firmware")).content.decode(), md5(FIRMWARE)) + self.assertEqual(self.client.get(reverse("download_firmware")).content, FIRMWARE) + self.assertManagerReloaded(times=1) + + def test_models_are_stored_separately(self): + self.upload("save_new_firmware", "firmware", b"sonoff", model="sonoff") + self.upload("save_new_firmware", "firmware", b"custom", model="custom") + + self.assertEqual(self.client.get(reverse("download_firmware"), {"model": "custom"}).content, b"custom") + self.assertEqual(self.client.get(reverse("download_firmware"), {"model": "sonoff"}).content, b"sonoff") + self.assertEqual(self.client.get(reverse("checksum_firmware"), {"model": "custom"}).content.decode(), md5(b"custom")) + + def test_upload_replaces_previous_image(self): + self.upload("save_new_firmware", "firmware", b"old", model="sonoff") + self.upload("save_new_firmware", "firmware", b"new", model="sonoff") + + self.assertEqual(self.client.get(reverse("download_firmware")).content, b"new") + + def test_upload_without_model_is_ignored(self): + self.upload("save_new_firmware", "firmware", FIRMWARE) + + self.assertManagerNotReloaded() + self.assertRejected(self.client.get(reverse("download_firmware"))) + + def test_unknown_model_is_rejected(self): + for url_name in ("download_firmware", "checksum_firmware", "download_data_file", "checksum_data_file"): + with self.subTest(url_name=url_name): + self.assertEqual(self.client.get(reverse(url_name), {"model": "../../etc"}).status_code, 400) + + def test_range_requests_use_an_exclusive_end(self): + # CONTRACT with the panel firmware (NSPanelManager-firmware, UpdateManager.cpp): the + # "bytes=start-end" end is treated as EXCLUSIVE (Python slice), not inclusive as RFC 7233 + # says. The firmware deliberately sends ranges in the same convention. Changing either + # side alone corrupts OTA updates. + self.upload("save_new_firmware", "firmware", FIRMWARE, model="sonoff") + + response = self.client.get(reverse("download_firmware"), HTTP_RANGE="bytes=100-200") + + self.assertEqual(response.content, FIRMWARE[100:200]) + self.assertEqual(len(response.content), 100) + + def test_open_ended_range(self): + self.upload("save_new_firmware", "firmware", FIRMWARE, model="sonoff") + + self.assertEqual(self.client.get(reverse("download_firmware"), HTTP_RANGE="bytes=1000-").content, FIRMWARE[1000:]) + + def test_merged_flash_upload_does_not_reload_manager(self): + # MQTTManager does not track the merged image's checksum, so there is nothing to reload. + self.upload("save_new_merged_flash", "bin", FIRMWARE, model="sonoff") + + self.assertManagerNotReloaded() + + def test_checksum_of_missing_image_is_not_found(self): + panel = self.make_panel(self.make_room()) + for url in (reverse("checksum_firmware"), reverse("checksum_data_file"), reverse("checksum_tft_file", kwargs={"panel_id": panel.id})): + with self.subTest(url=url): + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + self.assertEqual(response.content, b"") + + +class DataFileTests(FileTestCase): + def test_upload_then_checksum_and_download(self): + self.upload("save_new_data_file", "data_file", FIRMWARE, model="sonoff") + + self.assertEqual(self.client.get(reverse("checksum_data_file")).content.decode(), md5(FIRMWARE)) + self.assertEqual(self.client.get(reverse("download_data_file")).content, FIRMWARE) + self.assertEqual(self.client.get(reverse("download_data_file"), HTTP_RANGE="bytes=0-16").content, FIRMWARE[:16]) + self.assertManagerReloaded(times=1) + + +class TFTTests(FileTestCase): + def setUp(self): + super().setUp() + self.room = self.make_room() + self.panel = self.make_panel(self.room) + + def upload_tft(self, tft_file_type, data): + return self.upload("save_new_tft_file", "tft_file", data, tft_file_type=tft_file_type) + + def panel_tft(self, **headers): + return self.client.get(reverse("download_tft", kwargs={"panel_id": self.panel.id}), **headers).content + + def configure_panel(self, is_us_panel="False", orientation=None, tft="tft1"): + set_nspanel_setting_value(self.panel.id, "is_us_panel", is_us_panel) + set_nspanel_setting_value(self.panel.id, "selected_tft", tft) + if orientation: + set_nspanel_setting_value(self.panel.id, "us_panel_orientation", orientation) + + def test_each_panel_gets_the_image_for_its_type_and_selected_tft(self): + for tft_file_type in ("eu1", "eu3", "us2", "us4_horizontal_mirrored"): + self.upload_tft(tft_file_type, tft_file_type.encode()) + + cases = [ + ({}, b"eu1"), # defaults: EU panel, tft1 + ({"tft": "tft3"}, b"eu3"), + ({"is_us_panel": "True", "orientation": "vertical", "tft": "tft2"}, b"us2"), + # A horizontal US panel has the same screen orientation as an EU panel. + ({"is_us_panel": "True", "orientation": "horizontal", "tft": "tft3"}, b"eu3"), + ({"is_us_panel": "True", "orientation": "horizontal_mirrored", "tft": "tft4"}, b"us4_horizontal_mirrored"), + ] + for config, expected in cases: + with self.subTest(config=config): + self.configure_panel(**config) + self.assertEqual(self.panel_tft(), expected) + self.assertEqual(self.client.get(reverse("checksum_tft_file", kwargs={"panel_id": self.panel.id})).content.decode(), md5(expected)) + + def test_tft_range_download(self): + self.upload_tft("eu1", FIRMWARE) + + self.assertEqual(self.panel_tft(HTTP_RANGE="bytes=10-20"), FIRMWARE[10:20]) + + def test_upload_reloads_manager(self): + self.upload_tft("eu1", FIRMWARE) + + self.assertManagerReloaded(times=1) + + def test_unknown_tft_type_is_rejected(self): + self.assertEqual(self.upload_tft("eu9", FIRMWARE).status_code, 500) + self.assertManagerNotReloaded() diff --git a/docker/web/nspanelmanager/web/tests/test_layout.py b/docker/web/nspanelmanager/web/tests/test_layout.py new file mode 100644 index 000000000..91aa2e9d6 --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/test_layout.py @@ -0,0 +1,286 @@ +""" +Moving and deleting entities and managing entities pages, through the REST endpoints the +React entities-page view calls. +""" + +from django.urls import reverse + +from web.models import Entity, RoomEntitiesPage, Scene + +from .harness import NSPMTestCase + + +def position(obj, page, slot): + return {"id": obj.id, "entities_page_id": page.id, "room_view_position": slot} + + +class ReadEntityTests(NSPMTestCase): + def test_get_entity(self): + room = self.make_room("Kitchen") + light = self.make_light(room, "Ceiling", slot=2, home_assistant_name="light.ceiling") + + response = self.client.get(reverse("rest_get_entity", kwargs={"entity_id": light.id})) + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual((body["id"], body["friendly_name"], body["room_view_position"]), (light.id, "Ceiling", 2)) + self.assertEqual((body["controller"], body["home_assistant_name"]), ("home_assistant", "light.ceiling")) + + def test_get_room_pages_lists_entities_and_scenes(self): + room = self.make_room("Kitchen") + light = self.make_light(room) + scene = self.make_scene(room) + + response = self.client.get(reverse("rest_room_entities_pages", kwargs={"room_id": room.id})) + + self.assertEqual(response.status_code, 200) + pages = {page["type"]: page for page in response.json()["entities_pages"]} + self.assertEqual([e["id"] for e in pages["entity"]["entities"]], [light.id]) + self.assertEqual([s["id"] for s in pages["scene"]["scenes"]], [scene.id]) + + +class MoveEntityTests(NSPMTestCase): + def setUp(self): + super().setUp() + self.room = self.make_room("Kitchen") + self.page = self.entities_page(self.room) + self.second_page = RoomEntitiesPage.objects.create(room=self.room, page_type=8, is_scenes_page=False, display_order=1) + + def save_order(self, entities=(), scenes=()): + return self.put_json(reverse("rest_put_room_entities_order", kwargs={"room_id": self.room.id}), {"entities": list(entities), "scenes": list(scenes)}) + + def assertAt(self, obj, page, slot): + obj.refresh_from_db() + self.assertEqual((obj.entities_page_id, obj.room_view_position), (page.id, slot)) + + def test_move_entity(self): + light = self.make_light(self.room, slot=0) + + response = self.save_order([position(light, self.page, 3)]) + + self.assertEqual(response.status_code, 200) + self.assertAt(light, self.page, 3) + self.assertManagerReloaded(times=1) + + def test_swap_entities_across_pages(self): + ceiling = self.make_light(self.room, "Ceiling", slot=0) + lamp = self.make_light(self.room, "Lamp", page=self.second_page, slot=5) + + response = self.save_order([position(ceiling, self.second_page, 5), position(lamp, self.page, 0)]) + + self.assertEqual(response.status_code, 200) + self.assertAt(ceiling, self.second_page, 5) + self.assertAt(lamp, self.page, 0) + + def test_move_scenes(self): + scenes_page = self.scenes_page(self.room) + movie = self.make_scene(self.room, "Movie", slot=0) + dinner = self.make_scene(self.room, "Dinner", slot=1) + + response = self.save_order(scenes=[position(movie, scenes_page, 1), position(dinner, scenes_page, 0)]) + + self.assertEqual(response.status_code, 200) + self.assertAt(movie, scenes_page, 1) + self.assertAt(dinner, scenes_page, 0) + self.assertManagerReloaded(times=1) + + def test_bad_payloads_are_rejected(self): + light = self.make_light(self.room, slot=0) + url = reverse("rest_put_room_entities_order", kwargs={"room_id": self.room.id}) + for body in ({"entities": []}, {"scenes": []}, {"entities": [{"id": light.id}], "scenes": []}, "not json"): + with self.subTest(body=body): + self.assertRejected(self.put_json(url, body)) + self.assertAt(light, self.page, 0) + self.assertManagerNotReloaded() + + def test_unknown_entity_is_rejected(self): + self.assertRejected(self.save_order([{"id": 999, "entities_page_id": self.page.id, "room_view_position": 0}])) + self.assertManagerNotReloaded() + + def test_other_methods_are_rejected(self): + self.assertEqual(self.client.get(reverse("rest_put_room_entities_order", kwargs={"room_id": self.room.id})).status_code, 405) + + def test_failed_reorder_changes_nothing(self): + light = self.make_light(self.room, slot=0) + + response = self.save_order([position(light, self.page, 3), {"id": 999, "entities_page_id": self.page.id, "room_view_position": 0}]) + + self.assertRejected(response) + self.assertAt(light, self.page, 0) + self.assertManagerNotReloaded() + + def test_move_to_slot_outside_page_is_rejected(self): + light = self.make_light(self.room, slot=0) + + self.assertRejected(self.save_order([position(light, self.page, 11)])) + self.assertAt(light, self.page, 0) + + +class DeleteEntityTests(NSPMTestCase): + def setUp(self): + super().setUp() + self.room = self.make_room("Kitchen") + + def test_delete_entity(self): + doomed = self.make_light(self.room, "Ceiling", slot=1) + kept = self.make_light(self.room, "Lamp", slot=2) + + response = self.client.delete(reverse("rest_get_entity", kwargs={"entity_id": doomed.id})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(list(Entity.objects.values_list("id", flat=True)), [kept.id]) + self.assertManagerReloaded(times=1) + + def test_delete_unknown_entity_is_rejected(self): + self.make_light(self.room) + + self.assertRejected(self.client.delete(reverse("rest_get_entity", kwargs={"entity_id": 999}))) + self.assertEqual(Entity.objects.count(), 1) + self.assertManagerNotReloaded() + + def test_delete_scene(self): + scene = self.make_scene(self.room) + + response = self.client.delete(reverse("rest_get_scene", kwargs={"scene_id": scene.id})) + + self.assertEqual(response.status_code, 200) + self.assertFalse(Scene.objects.exists()) + + def test_delete_scene_reloads_manager(self): + scene = self.make_scene(self.room) + + self.client.delete(reverse("rest_get_scene", kwargs={"scene_id": scene.id})) + + self.assertManagerReloaded(times=1) + + def test_delete_unknown_scene_is_rejected(self): + self.assertRejected(self.client.delete(reverse("rest_get_scene", kwargs={"scene_id": 999}))) + + +class EntitiesPageTests(NSPMTestCase): + def setUp(self): + super().setUp() + self.room = self.make_room("Kitchen") + + def pages(self, room, is_scenes_page=False): + return list(RoomEntitiesPage.objects.filter(room=room, is_scenes_page=is_scenes_page).order_by("display_order")) + + def create_page(self, page_type, is_scenes_page=False, room=None): + room = room or self.room + return self.put_json(reverse("rest_room_entities_pages", kwargs={"room_id": room.id}), {"type": page_type, "is_scenes_page": is_scenes_page}) + + def test_create_pages(self): + for page_type in (8, 12): + self.assertEqual(self.create_page(page_type).status_code, 200) + + pages = self.pages(self.room) + self.assertEqual([p.page_type for p in pages], [4, 8, 12]) + self.assertManagerReloaded(times=2) + + def test_create_global_scenes_page(self): + self.global_scenes_page() + + response = self.put_json(reverse("rest_global_entities_pages"), {"type": 4, "is_scenes_page": True}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(self.pages(None, is_scenes_page=True)), 2) + + def test_create_page_missing_field_is_rejected(self): + before = RoomEntitiesPage.objects.count() + for body in ({"type": 4}, {"is_scenes_page": False}): + with self.subTest(body=body): + self.assertEqual(self.put_json(reverse("rest_room_entities_pages", kwargs={"room_id": self.room.id}), body).status_code, 400) + self.assertEqual(RoomEntitiesPage.objects.count(), before) + self.assertManagerNotReloaded() + + def test_create_page_in_unknown_room_is_rejected(self): + before = RoomEntitiesPage.objects.count() + + self.assertRejected(self.put_json(reverse("rest_room_entities_pages", kwargs={"room_id": 999}), {"type": 4, "is_scenes_page": False})) + + self.assertEqual(RoomEntitiesPage.objects.count(), before) + self.assertManagerNotReloaded() + + def test_create_page_with_unsupported_size_is_rejected(self): + before = RoomEntitiesPage.objects.count() + + self.assertRejected(self.create_page(6)) + self.assertEqual(RoomEntitiesPage.objects.count(), before) + + def test_delete_page_removes_its_entities(self): + second = RoomEntitiesPage.objects.create(room=self.room, page_type=8, is_scenes_page=False, display_order=2) + on_first = self.make_light(self.room, slot=0) + on_second = self.make_light(self.room, page=second, slot=0) + + response = self.client.delete(reverse("rest_room_entities_page", kwargs={"page_id": second.id})) + + self.assertEqual(response.status_code, 200) + self.assertFalse(RoomEntitiesPage.objects.filter(id=second.id).exists()) + self.assertEqual(list(Entity.objects.values_list("id", flat=True)), [on_first.id]) + self.assertNotEqual(on_second.id, on_first.id) + self.assertManagerReloaded(times=1) + + def test_delete_unknown_page_is_rejected(self): + self.assertRejected(self.client.delete(reverse("rest_room_entities_page", kwargs={"page_id": 999}))) + self.assertManagerNotReloaded() + + def test_reorder_pages(self): + first = self.entities_page(self.room) + second = RoomEntitiesPage.objects.create(room=self.room, page_type=8, is_scenes_page=False, display_order=2) + + response = self.put_json(reverse("rest_room_entities_pages_order"), {"order": [[second.id, 0], [first.id, 1]]}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.pages(self.room), [second, first]) + self.assertManagerReloaded(times=1) + + def test_reorder_pages_bad_payload_is_rejected(self): + url = reverse("rest_room_entities_pages_order") + self.assertEqual(self.put_json(url, {}).status_code, 400) + self.assertRejected(self.put_json(url, {"order": [[999, 0]]})) + self.assertRejected(self.put_json(url, "not json")) + self.assertManagerNotReloaded() + + def test_resize_page(self): + page = self.entities_page(self.room) + + response = self.put_json(reverse("rest_room_entities_page", kwargs={"page_id": page.id}), {"id": page.id, "number_of_entities": 12}) + + self.assertEqual(response.status_code, 200) + page.refresh_from_db() + self.assertEqual(page.page_type, 12) + + def test_resize_page_reloads_manager(self): + page = self.entities_page(self.room) + + self.put_json(reverse("rest_room_entities_page", kwargs={"page_id": page.id}), {"number_of_entities": 12}) + + self.assertManagerReloaded(times=1) + + def test_shrinking_page_does_not_strand_entities(self): + page = RoomEntitiesPage.objects.create(room=self.room, page_type=12, is_scenes_page=False, display_order=2) + light = self.make_light(self.room, page=page, slot=9) + + response = self.put_json(reverse("rest_room_entities_page", kwargs={"page_id": page.id}), {"number_of_entities": 4}) + + self.assertRejected(response) + page.refresh_from_db() + self.assertEqual(page.page_type, 12) + self.assertLoadableByManager(light) + self.assertManagerNotReloaded() + + def test_shrinking_page_with_free_trailing_slots(self): + page = RoomEntitiesPage.objects.create(room=self.room, page_type=12, is_scenes_page=False, display_order=2) + light = self.make_light(self.room, page=page, slot=3) + + response = self.put_json(reverse("rest_room_entities_page", kwargs={"page_id": page.id}), {"number_of_entities": 4}) + + self.assertEqual(response.status_code, 200) + self.assertLoadableByManager(light) + + def test_resize_to_unsupported_size_is_rejected(self): + page = self.entities_page(self.room) + + self.assertRejected(self.put_json(reverse("rest_room_entities_page", kwargs={"page_id": page.id}), {"number_of_entities": 6})) + page.refresh_from_db() + self.assertEqual(page.page_type, 4) diff --git a/docker/web/nspanelmanager/web/tests/test_misc.py b/docker/web/nspanelmanager/web/tests/test_misc.py new file mode 100644 index 000000000..559752dde --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/test_misc.py @@ -0,0 +1,174 @@ +""" +Smaller endpoints: messages, Home Assistant / OpenHAB entity lookups used by the entity +modal, hostname lookup and read-only listings. +""" + +import json +import socket +from unittest import mock + +from django.urls import reverse + +from web.models import Message + +from .harness import NSPMTestCase + + +class MessageTests(NSPMTestCase): + def test_unread_messages_are_flagged_and_can_be_marked_read(self): + message = Message.objects.create(title="Update available", message="v2.1 is out") + self.assertTrue(self.client.get(reverse("index")).context["has_unread_messages"]) + + response = self.client.get(reverse("htmx_mark_message_read", kwargs={"message_id": message.id})) + + self.assertContains(response, "Update available") + message.refresh_from_db() + self.assertTrue(message.read) + self.assertFalse(self.client.get(reverse("index")).context["has_unread_messages"]) + + def test_mark_unknown_message_is_rejected(self): + self.assertEqual(self.client.get(reverse("htmx_mark_message_read", kwargs={"message_id": 999})).status_code, 500) + + +class EntitySourceLookupTests(NSPMTestCase): + """The lists the React entity modal offers when picking a Home Assistant / OpenHAB item.""" + + def test_home_assistant_entities(self): + self.home_assistant.add("light.kitchen", friendly_name="Kitchen") + self.home_assistant.add("switch.fan", friendly_name="Fan") + + response = self.client.get(reverse("rest_get_home_assistant_entities")) + + self.assertEqual(response.status_code, 200) + self.assertEqual([(i["item_id"], i["label"]) for i in response.json()["items"]], [("light.kitchen", "Kitchen"), ("switch.fan", "Fan")]) + + def test_home_assistant_entities_filtered_by_domain(self): + self.home_assistant.add("light.kitchen") + self.home_assistant.add("switch.fan") + self.home_assistant.add("climate.lounge") + + response = self.client.get(reverse("rest_get_home_assistant_entities"), {"filter": json.dumps({"type": ["switch", "climate"]})}) + + self.assertEqual([i["item_id"] for i in response.json()["items"]], ["switch.fan", "climate.lounge"]) + + def test_home_assistant_scripts_are_offered_with_scenes(self): + # The React scene picker fetches every entity and keeps scene.* and script.* itself. + self.home_assistant.add("scene.movie_time", friendly_name="Movie time") + self.home_assistant.add("script.lights_out", friendly_name="Lights out") + self.home_assistant.add("light.kitchen") + + response = self.client.get(reverse("rest_get_home_assistant_entities")) + + items = {i["item_id"]: i["label"] for i in response.json()["items"]} + self.assertEqual(items["scene.movie_time"], "Movie time") + self.assertEqual(items["script.lights_out"], "Lights out") + + def test_entity_without_friendly_name_is_labelled_by_id(self): + self.home_assistant.add("light.unnamed") + + self.assertEqual(self.client.get(reverse("rest_get_home_assistant_entities")).json()["items"][0]["label"], "light.unnamed") + + def test_home_assistant_error_is_reported(self): + self.home_assistant.fail = True + + response = self.client.get(reverse("rest_get_home_assistant_entities")) + + self.assertEqual(response.json()["items"], []) + self.assertTrue(response.json()["errors"]) + + def test_home_assistant_not_configured_is_reported(self): + from web.settings_helper import set_setting_value + + set_setting_value("home_assistant_token", "") + + self.assertTrue(self.client.get(reverse("rest_get_home_assistant_entities")).json()["errors"]) + + def test_openhab_items_include_scenes(self): + self.openhab_items = [{"type": "openhab", "label": "Lamp", "item_id": "Lamp"}] + + response = self.client.get(reverse("rest_get_openhab_items")) + + # The fake returns the same list for items and for scenes (rules), so both are merged in. + self.assertEqual([i["item_id"] for i in response.json()["items"]], ["Lamp", "Lamp"]) + + def test_lookups_are_read_only(self): + self.assertEqual(self.client.post(reverse("rest_get_home_assistant_entities")).status_code, 405) + self.assertEqual(self.client.post(reverse("rest_get_openhab_items")).status_code, 405) + + +class HostnameLookupTests(NSPMTestCase): + def test_resolves_hostname(self): + with mock.patch("web.rest.socket.gethostbyname", return_value="192.168.1.20") as lookup: + response = self.post_json(reverse("get_ip_by_hostname"), {"hostname": "mqtt.local"}) + + lookup.assert_called_once_with("mqtt.local") + self.assertEqual(response.json(), {"ip": "192.168.1.20"}) + + def test_unknown_hostname_is_404(self): + with mock.patch("web.rest.socket.gethostbyname", side_effect=socket.gaierror): + self.assertEqual(self.post_json(reverse("get_ip_by_hostname"), {"hostname": "nope.invalid"}).status_code, 404) + + def test_missing_hostname_is_rejected(self): + with mock.patch("web.rest.socket.gethostbyname") as lookup: + self.assertEqual(self.post_json(reverse("get_ip_by_hostname"), {}).status_code, 500) + lookup.assert_not_called() + + +class ReadOnlyListingTests(NSPMTestCase): + def setUp(self): + super().setUp() + self.room = self.make_room("Kitchen") + + def test_room_entities(self): + light = self.make_light(self.room, "Ceiling") + self.make_light(self.make_room("Lounge"), "Lamp") + + response = self.client.get(reverse("rest_room_entities", kwargs={"room_id": self.room.id})) + + self.assertEqual([e["id"] for e in response.json()["entities"]], [light.id]) + + def test_global_entities_pages(self): + page = self.global_scenes_page() + scene = self.make_scene(None, "Goodnight") + + response = self.client.get(reverse("rest_global_entities_pages")) + + pages = response.json()["entities_pages"] + self.assertEqual([(p["id"], p["room_id"]) for p in pages], [(page.id, None)]) + self.assertEqual([s["id"] for s in pages[0]["scenes"]], [scene.id]) + + def test_single_entities_page(self): + light = self.make_light(self.room, slot=1) + + response = self.client.get(reverse("rest_room_entities_page", kwargs={"page_id": self.entities_page(self.room).id})) + + self.assertEqual([e["id"] for e in response.json()["entities"]], [light.id]) + + def test_get_scene(self): + scene = self.make_scene(self.room, "Dinner", backend_name="scene.dinner") + + result = self.client.get(reverse("rest_get_scene", kwargs={"scene_id": scene.id})).json()["result"] + + self.assertEqual((result["friendly_name"], result["backend_name"], result["room_id"]), ("Dinner", "scene.dinner", self.room.id)) + + def test_scenes_listing(self): + scene = self.make_scene(self.room, "Dinner") + self.make_scene(None, "Goodnight") + + response = self.client.get(reverse("rest_lights"), {"room_id": self.room.id}) + + self.assertEqual([s["scene_id"] for s in response.json()["scenes"]], [scene.id]) + + def test_legacy_scene_and_room_config(self): + scene = self.make_scene(self.room, "Dinner") + + self.assertEqual(self.client.get(reverse("get_scenes")).json()["scenes"][0]["scene_id"], scene.id) + self.assertEqual(self.client.get(reverse("get_nspanel_config", kwargs={"room_id": self.room.id})).json()["name"], "Kitchen") + + def test_scenes_listing_filters_by_scene_id(self): + scene = self.make_scene(self.room, "Dinner") + self.make_scene(self.room, "Breakfast") + + response = self.client.get(reverse("rest_lights"), {"scene_id": scene.id}) + + self.assertEqual([s["scene_id"] for s in response.json()["scenes"]], [scene.id]) diff --git a/docker/web/nspanelmanager/web/tests/test_openapi.py b/docker/web/nspanelmanager/web/tests/test_openapi.py new file mode 100644 index 000000000..3f1939af0 --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/test_openapi.py @@ -0,0 +1,17 @@ +import tempfile +from io import StringIO +from pathlib import Path + +from django.core.management import call_command +from django.test import SimpleTestCase + +# Committed so the UI can generate its types without running Django, and so API changes show up in review. +SCHEMA_FILE = Path(__file__).resolve().parents[3] / "openapi.yaml" + + +class OpenAPISchemaTests(SimpleTestCase): + def test_committed_schema_is_up_to_date(self): + # --fail-on-warn: a view drf-spectacular can't describe fails here instead of producing a loose type. + with tempfile.NamedTemporaryFile(suffix=".yaml") as generated: + call_command("spectacular", "--validate", "--fail-on-warn", "--file", generated.name, stderr=StringIO()) + self.assertEqual(Path(generated.name).read_text(), SCHEMA_FILE.read_text(), "docker/web/openapi.yaml is out of date. Run docker/web/generate_api.sh and commit the result (see CONTRIBUTING.md).") diff --git a/docker/web/nspanelmanager/web/tests/test_pages.py b/docker/web/nspanelmanager/web/tests/test_pages.py new file mode 100644 index 000000000..41aaf033b --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/test_pages.py @@ -0,0 +1,120 @@ +""" +Every page and htmx partial renders. These catch broken templates, template tags and +view context (e.g. a renamed model field referenced from a template), which otherwise +only show up when someone opens the page. +""" + +from django.urls import reverse + +from web.models import Message, RelayGroup +from web.settings_helper import set_setting_value + +from .harness import NSPMTestCase + + +class PageRenderTests(NSPMTestCase): + def setUp(self): + super().setUp() + self.room = self.make_room("Kitchen") + self.make_light(self.room, "Ceiling") + self.make_scene(self.room, "Dinner") + self.make_scene(None, "Goodnight", scene_type="nspm_scene", backend_name="") + self.panel = self.make_panel(self.room, "Kitchen panel", accepted=True) + self.denied_panel = self.make_panel(self.room, "Denied panel", mac="AA:BB:CC:DD:EE:01", denied=True) + self.relay_group = RelayGroup.objects.create(friendly_name="Hall lights") + Message.objects.create(title="Hello", message="World") + + def assertRenders(self, url): + response = self.client.get(url) + self.assertEqual(response.status_code, 200, f"GET {url} returned {response.status_code}") + return response + + def test_full_pages(self): + pages = [ + reverse("index"), + reverse("rooms"), + reverse("edit_room", kwargs={"room_id": self.room.id}), + reverse("edit_nspanel", kwargs={"panel_id": self.panel.id}), + reverse("settings"), + reverse("relay_groups"), + reverse("global_scenes"), + reverse("weather_and_time"), + reverse("denied_nspanels"), + ] + for url in pages: + with self.subTest(url=url): + self.assertRenders(url) + + def test_pages_render_with_no_configuration(self): + # A fresh install: no settings, no rooms, no panels. + self.room.delete() + for url in (reverse("index"), reverse("rooms"), reverse("settings"), reverse("weather_and_time"), reverse("global_scenes"), reverse("relay_groups")): + with self.subTest(url=url): + self.assertRenders(url) + + def test_index_shows_initial_setup_until_manager_address_is_configured(self): + self.assertContains(self.assertRenders(reverse("index")), "data-react-component='InitialSetup'") + + set_setting_value("manager_address", "192.168.1.10") + + self.assertNotContains(self.assertRenders(reverse("index")), "data-react-component='InitialSetup'") + + def test_htmx_partials(self): + partials = [ + reverse("htmx_partial_select_new_entity_type_react", kwargs={"entities_page_id": self.entities_page(self.room).id, "room_view_position": 1}), + reverse("htmx_partial_select_weather_location"), + reverse("htmx_partial_select_weather_outside_temperature_sensor"), + reverse("htmx_show_messages"), + reverse("htmx_modal_create_new_relay_group"), + reverse("htmx_modal_edit_relay_group", kwargs={"relay_group_id": self.relay_group.id}), + reverse("htmx_modal_add_relay_to_relay_group", kwargs={"relay_group_id": self.relay_group.id}), + ] + for url in partials: + with self.subTest(url=url): + self.assertRenders(url) + + def test_sensor_pickers_list_home_assistant_entities(self): + self.home_assistant.add("sensor.kitchen_temperature", friendly_name="Kitchen temperature") + + for url in ( + reverse("htmx_partial_select_room_temperature_sensor_from_list", kwargs={"entity_source": "home_assistant", "room_id": self.room.id}), + reverse("htmx_partial_select_weather_outside_temperature_sensor_from_list", kwargs={"entity_source": "home_assistant"}), + ): + with self.subTest(url=url): + self.assertContains(self.assertRenders(url), "Kitchen temperature") + + def test_sensor_provider_picker_skips_straight_to_the_only_configured_source(self): + room_list = reverse("htmx_partial_select_room_temperature_sensor_from_list", kwargs={"entity_source": "home_assistant", "room_id": self.room.id}) + weather_list = reverse("htmx_partial_select_weather_outside_temperature_sensor_from_list", kwargs={"entity_source": "home_assistant"}) + + self.assertRedirects(self.client.get(reverse("htmx_partial_select_room_temperature_sensor_provider", kwargs={"room_id": self.room.id})), room_list, fetch_redirect_response=False) + self.assertRedirects(self.client.get(reverse("htmx_partial_select_weather_outside_temperature_sensor_provider")), weather_list, fetch_redirect_response=False) + + set_setting_value("home_assistant_token", "") + set_setting_value("openhab_address", "http://openhab.test") + set_setting_value("openhab_token", "token") + self.assertRedirects( + self.client.get(reverse("htmx_partial_select_weather_outside_temperature_sensor_provider")), + reverse("htmx_partial_select_weather_outside_temperature_sensor_from_list", kwargs={"entity_source": "openhab"}), + fetch_redirect_response=False, + ) + + def test_sensor_provider_picker_offers_a_choice_when_both_are_configured(self): + set_setting_value("openhab_address", "http://openhab.test") + set_setting_value("openhab_token", "token") + + self.assertRenders(reverse("htmx_partial_select_room_temperature_sensor_provider", kwargs={"room_id": self.room.id})) + self.assertRenders(reverse("htmx_partial_select_weather_outside_temperature_sensor_provider")) + + def test_sensor_provider_picker_with_nothing_configured_is_an_error(self): + set_setting_value("home_assistant_token", "") + + self.assertRejected(self.client.get(reverse("htmx_partial_select_weather_outside_temperature_sensor_provider"))) + + def test_unknown_objects_are_rejected(self): + for url in ( + reverse("edit_nspanel", kwargs={"panel_id": 999}), + reverse("htmx_modal_edit_relay_group", kwargs={"relay_group_id": 999}), + ): + with self.subTest(url=url): + self.assertRejected(self.client.get(url)) diff --git a/docker/web/nspanelmanager/web/tests/test_panels.py b/docker/web/nspanelmanager/web/tests/test_panels.py new file mode 100644 index 000000000..b16cec30f --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/test_panels.py @@ -0,0 +1,265 @@ +""" +Panel lifecycle (accept, deny, delete, unblock), the per-panel settings form, and the +legacy panel-facing api/ endpoints. +""" + +from django.urls import reverse + +from web.models import Entity, NSPanel +from web.settings_helper import get_nspanel_setting_with_default, set_nspanel_setting_value + +from .harness import NSPMTestCase + + +class PanelLifecycleTests(NSPMTestCase): + def setUp(self): + super().setUp() + self.room = self.make_room("Kitchen") + self.panel = self.make_panel(self.room, "New panel") + + def test_accept_register_request(self): + self.panel.denied = True + self.panel.save() + + response = self.client.post(reverse("htmx_nspanel_accept_register_request", kwargs={"nspanel_id": self.panel.id})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response["HX-Refresh"], "true") + self.panel.refresh_from_db() + self.assertEqual((self.panel.accepted, self.panel.denied), (True, False)) + self.assertManagerReloaded(times=1) + + def test_deny_register_request(self): + response = self.client.post(reverse("htmx_nspanel_deny_register_request", kwargs={"nspanel_id": self.panel.id})) + + self.assertEqual(response.status_code, 200) + self.panel.refresh_from_db() + self.assertTrue(self.panel.denied) + self.assertManagerReloaded(times=1) + + def test_denied_panels_page_lists_denied_panel(self): + self.client.post(reverse("htmx_nspanel_deny_register_request", kwargs={"nspanel_id": self.panel.id})) + + self.assertContains(self.client.get(reverse("denied_nspanels")), "New panel") + + def test_accept_and_deny_require_post(self): + for name in ("htmx_nspanel_accept_register_request", "htmx_nspanel_deny_register_request"): + with self.subTest(name=name): + self.assertEqual(self.client.get(reverse(name, kwargs={"nspanel_id": self.panel.id})).status_code, 405) + self.panel.refresh_from_db() + self.assertEqual((self.panel.accepted, self.panel.denied), (False, False)) + self.assertManagerNotReloaded() + + def test_accept_unknown_panel_is_rejected(self): + self.assertRejected(self.client.post(reverse("htmx_nspanel_accept_register_request", kwargs={"nspanel_id": 999}))) + self.assertManagerNotReloaded() + + def test_delete_panel(self): + set_nspanel_setting_value(self.panel.id, "screen_dim_level", "50") + + response = self.client.delete(reverse("htmx_nspanel_delete", kwargs={"nspanel_id": self.panel.id})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response["HX-Redirect"], "/") + self.assertFalse(NSPanel.objects.exists()) + self.assertEqual(get_nspanel_setting_with_default(self.panel.id, "screen_dim_level", "gone"), "gone") + + def test_delete_panel_reloads_manager(self): + self.client.delete(reverse("htmx_nspanel_delete", kwargs={"nspanel_id": self.panel.id})) + + self.assertManagerReloaded(times=1) + + def test_delete_requires_delete_method(self): + self.assertEqual(self.client.post(reverse("htmx_nspanel_delete", kwargs={"nspanel_id": self.panel.id})).status_code, 405) + self.assertTrue(NSPanel.objects.exists()) + + def test_unblock_removes_the_panel_so_it_can_register_again(self): + for url_name, method in (("htmx_unblock_nspanel", "delete"), ("unblock_nspanel", "get")): + with self.subTest(url_name=url_name): + panel = self.make_panel(self.room, "Blocked", mac=f"AA:BB:CC:DD:EE:{len(url_name):02d}", denied=True) + getattr(self.client, method)(reverse(url_name, kwargs={"nspanel_id": panel.id})) + self.assertFalse(NSPanel.objects.filter(id=panel.id).exists()) + self.assertManagerReloaded(times=2) + + def test_deleting_a_room_keeps_its_panels(self): + other = self.make_room("Lounge") + + self.client.get(reverse("delete_room", kwargs={"room_id": self.room.id})) + + self.panel.refresh_from_db() + self.assertEqual(self.panel.room, other) + + +# Every field the panel settings form submits, with "use global default" choices. +PANEL_FORM = { + "name": "Kitchen panel", + "button1_mode": "0", + "button1_detached_mode_light": "", + "button1_custom_mqtt_topic": "", + "button1_custom_mqtt_payload": "", + "button2_mode": "0", + "button2_detached_mode_light": "", + "button2_custom_mqtt_topic": "", + "button2_custom_mqtt_payload": "", + "screen_dim_level": "", + "screensaver_dim_level": "", + "screensaver_activation_timeout": "", + "screensaver_mode": "global", + "show_screensaver_inside_temperature": "global", + "show_screensaver_outside_temperature": "global", + "temperature_calibration": "", + "relay1_default_mode": "False", + "relay2_default_mode": "False", + "default_page": "0", + "lock_to_default_room": "False", + "reverse_relays": "False", + "button1_relay_lower_temperature": "", + "button1_relay_upper_temperature": "", + "button2_relay_lower_temperature": "", + "button2_relay_upper_temperature": "", + "panel_type": "eu", + "selected_tft": "tft1", + "relay1_is_light": "False", + "relay2_is_light": "False", +} + + +class PanelSettingsFormTests(NSPMTestCase): + def setUp(self): + super().setUp() + self.room = self.make_room("Kitchen") + self.panel = self.make_panel(self.room, "Panel", accepted=True) + + def save(self, **overrides): + return self.client.post(reverse("save_panel_settings", kwargs={"panel_id": self.panel.id}), PANEL_FORM | {"room_id": self.room.id} | overrides) + + def panel_setting(self, name, default=None): + return get_nspanel_setting_with_default(self.panel.id, name, default) + + def test_save_basic_settings(self): + lounge = self.make_room("Lounge") + + response = self.save(room_id=lounge.id, name="Renamed", default_page="2", temperature_calibration="-1.5") + + self.assertRedirects(response, reverse("edit_nspanel", kwargs={"panel_id": self.panel.id}), fetch_redirect_response=False) + self.panel.refresh_from_db() + self.assertEqual((self.panel.friendly_name, self.panel.room), ("Renamed", lounge)) + self.assertEqual(self.panel_setting("default_page"), "2") + self.assertEqual(self.panel_setting("temperature_calibration"), "-1.5") + self.assertManagerReloaded(times=1) + + def test_blank_overrides_fall_back_to_global_settings(self): + set_nspanel_setting_value(self.panel.id, "screen_dim_level", "40") + set_nspanel_setting_value(self.panel.id, "screensaver_mode", "no_background") + + self.save() + + self.assertIsNone(self.panel_setting("screen_dim_level")) + self.assertIsNone(self.panel_setting("screensaver_mode")) + self.assertEqual(self.panel_setting("temperature_calibration"), "0.0") + + def test_per_panel_overrides_are_stored(self): + self.save(screen_dim_level="40", screensaver_mode="no_background", show_screensaver_inside_temperature="False") + + self.assertEqual(self.panel_setting("screen_dim_level"), "40") + self.assertEqual(self.panel_setting("screensaver_mode"), "no_background") + self.assertEqual(self.panel_setting("show_screensaver_inside_temperature"), "False") + + def test_panel_types(self): + cases = { + "eu": ("False", None), + "us_vertical": ("True", "vertical"), + "us_horizontal": ("True", "horizontal"), + "us_horizontal_mirrored": ("True", "horizontal_mirrored"), + } + for panel_type, expected in cases.items(): + with self.subTest(panel_type=panel_type): + self.save(panel_type=panel_type) + is_us = self.panel_setting("is_us_panel") + self.assertEqual(is_us, expected[0]) + if expected[1]: + self.assertEqual(self.panel_setting("us_panel_orientation"), expected[1]) + + def test_button_detached_mode_links_an_entity(self): + light = self.make_light(self.room, "Ceiling") + + self.save(button1_mode="1", button1_detached_mode_light=str(light.id)) + + self.panel.refresh_from_db() + self.assertEqual(self.panel.button1_mode, 1) + self.assertEqual(self.panel.button1_detached_mode_entity, light) + + self.save(button1_mode="0") + + self.panel.refresh_from_db() + self.assertIsNone(self.panel.button1_detached_mode_entity) + + def test_deleting_the_detached_entity_unlinks_it(self): + light = self.make_light(self.room, "Ceiling") + self.save(button2_mode="1", button2_detached_mode_light=str(light.id)) + + Entity.objects.all().delete() + + self.panel.refresh_from_db() + self.assertIsNone(self.panel.button2_detached_mode_entity) + + def test_button_custom_mqtt_mode(self): + self.save(button2_mode="2", button2_custom_mqtt_topic="garage/door", button2_custom_mqtt_payload="toggle") + + self.assertEqual((self.panel_setting("button2_mqtt_topic"), self.panel_setting("button2_mqtt_payload")), ("garage/door", "toggle")) + + self.save(button2_mode="0") + + self.assertIsNone(self.panel_setting("button2_mqtt_topic")) + + def test_detached_mode_with_unknown_entity_is_rejected(self): + self.assertRejected(self.save(button1_mode="1", button1_detached_mode_light="999")) + self.panel.refresh_from_db() + self.assertEqual(self.panel.button1_mode, 0) + self.assertManagerNotReloaded() + + def test_non_numeric_temperature_calibration_is_rejected(self): + self.assertRejected(self.save(temperature_calibration="warm")) + self.assertManagerNotReloaded() + + def test_unknown_room_is_rejected(self): + self.assertRejected(self.save(room_id=999)) + self.panel.refresh_from_db() + self.assertEqual(self.panel.room, self.room) + + def test_edit_page_shows_saved_values(self): + self.save(name="Hallway", screen_dim_level="40") + + response = self.client.get(reverse("edit_nspanel", kwargs={"panel_id": self.panel.id})) + + self.assertContains(response, "Hallway") + self.assertEqual(response.context["settings"]["screen_dim_level"], "40") + + +class LegacyPanelAPITests(NSPMTestCase): + """api/* endpoints from before panel config moved to protobuf over MQTT.""" + + def setUp(self): + super().setUp() + self.room = self.make_room("Kitchen") + self.panel = self.make_panel(self.room, "Kitchen panel", mac="C0:49:EF:00:00:01") + + def test_get_nspanel_config(self): + response = self.client.get(reverse("get_nspanel_config"), {"mac": self.panel.mac_address}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["name"], "Kitchen panel") + + def test_get_nspanel_config_for_unknown_panel_is_rejected(self): + self.assertEqual(self.client.get(reverse("get_nspanel_config"), {"mac": "00:00:00:00:00:00"}).status_code, 500) + + def test_online_status_for_unknown_panel_is_rejected(self): + response = self.client.post(reverse("set_panel_online_status", kwargs={"panel_mac": "00:00:00:00:00:00"}), '{"state": "online"}', content_type="application/json") + + self.assertEqual(response.status_code, 500) + + def test_restart_mqtt_manager(self): + response = self.client.post(reverse("restart_mqtt_manager")) + + self.assertEqual(response.json(), {"result": "OK"}) + self.assertEqual(self.mqttmanager.restarts, 1) diff --git a/docker/web/nspanelmanager/web/tests/test_relay_groups.py b/docker/web/nspanelmanager/web/tests/test_relay_groups.py new file mode 100644 index 000000000..eb74882a1 --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/test_relay_groups.py @@ -0,0 +1,166 @@ +""" +Relay groups: tie panel relays together so switching one switches them all. +Covered through both the form views and the htmx endpoints, plus the REST listing +MQTTManager reads. +""" + +from django.urls import reverse + +from web.models import NSPanel, RelayGroup, RelayGroupBinding + +from .harness import NSPMTestCase + + +class RelayGroupTestCase(NSPMTestCase): + def setUp(self): + super().setUp() + self.room = self.make_room("Hall") + self.panel = self.make_panel(self.room, "Hall panel", accepted=True) + self.other_panel = self.make_panel(self.room, "Stairs panel", mac="AA:BB:CC:DD:EE:01", accepted=True) + self.group = RelayGroup.objects.create(friendly_name="Hall lights") + + def bind(self, panel, relay_num): + return RelayGroupBinding.objects.create(relay_group=self.group, nspanel=panel, relay_num=relay_num) + + +class RelayGroupViewTests(RelayGroupTestCase): + def test_create_group(self): + response = self.client.post(reverse("create_or_update_relay_group"), {"relay_group_id": "", "relay_group_name": "Porch"}) + + self.assertRedirects(response, reverse("relay_groups"), fetch_redirect_response=False) + self.assertTrue(RelayGroup.objects.filter(friendly_name="Porch").exists()) + self.assertManagerReloaded(times=1) + + def test_rename_group(self): + self.client.post(reverse("create_or_update_relay_group"), {"relay_group_id": self.group.id, "relay_group_name": "Landing"}) + + self.group.refresh_from_db() + self.assertEqual(self.group.friendly_name, "Landing") + self.assertEqual(RelayGroup.objects.count(), 1) + + def test_add_relay_to_group(self): + response = self.client.post(reverse("add_nspanel_relay_to_group"), {"relay_group_id": self.group.id, "nspanel_id": self.panel.id, "relay_num": "2"}) + + self.assertEqual(response.status_code, 302) + binding = RelayGroupBinding.objects.get() + self.assertEqual((binding.relay_group, binding.nspanel, binding.relay_num), (self.group, self.panel, 2)) + self.assertManagerReloaded(times=1) + + def test_adding_the_same_relay_twice_is_a_no_op(self): + self.bind(self.panel, 1) + + self.client.post(reverse("add_nspanel_relay_to_group"), {"relay_group_id": self.group.id, "nspanel_id": self.panel.id, "relay_num": "1"}) + + self.assertEqual(RelayGroupBinding.objects.count(), 1) + self.assertManagerNotReloaded() + + def test_add_relay_with_unknown_group_or_panel_is_rejected(self): + for form in ({"relay_group_id": 999, "nspanel_id": self.panel.id, "relay_num": "1"}, {"relay_group_id": self.group.id, "nspanel_id": 999, "relay_num": "1"}): + with self.subTest(form=form): + self.assertRejected(self.client.post(reverse("add_nspanel_relay_to_group"), form)) + self.assertFalse(RelayGroupBinding.objects.exists()) + self.assertManagerNotReloaded() + + def test_remove_relay_from_group(self): + binding = self.bind(self.panel, 1) + kept = self.bind(self.other_panel, 1) + + self.client.get(reverse("delete_relay_group_binding", kwargs={"relay_binding_id": binding.id})) + + self.assertEqual(list(RelayGroupBinding.objects.all()), [kept]) + self.assertManagerReloaded(times=1) + + def test_delete_group_removes_its_bindings(self): + self.bind(self.panel, 1) + + self.client.get(reverse("delete_relay_group", kwargs={"relay_group_id": self.group.id})) + + self.assertFalse(RelayGroup.objects.exists()) + self.assertFalse(RelayGroupBinding.objects.exists()) + self.assertManagerReloaded(times=1) + + def test_delete_unknown_group_is_rejected(self): + self.assertRejected(self.client.get(reverse("delete_relay_group", kwargs={"relay_group_id": 999}))) + self.assertManagerNotReloaded() + + def test_deleting_a_panel_removes_its_bindings(self): + self.bind(self.panel, 1) + + NSPanel.objects.filter(id=self.panel.id).delete() + + self.assertFalse(RelayGroupBinding.objects.exists()) + self.assertTrue(RelayGroup.objects.exists()) + + def test_relay_groups_page_lists_groups_and_panels(self): + self.bind(self.panel, 2) + + response = self.client.get(reverse("relay_groups")) + + self.assertContains(response, "Hall lights") + self.assertContains(response, "Hall panel") + + +class RelayGroupHTMXTests(RelayGroupTestCase): + def test_create_and_rename(self): + response = self.client.post(reverse("htmx_save_relay_group"), {"name": "Porch"}) + + self.assertEqual(response["HX-Refresh"], "true") + porch = RelayGroup.objects.get(friendly_name="Porch") + + self.client.post(reverse("htmx_save_relay_group"), {"relay_group_id": porch.id, "name": "Deck"}) + + porch.refresh_from_db() + self.assertEqual(porch.friendly_name, "Deck") + + def test_add_and_remove_relay(self): + response = self.client.post(reverse("htmx_add_relay_to_relay_group", kwargs={"relay_group_id": self.group.id}), {"nspanel_id": self.panel.id, "relay_selection": "1"}) + + self.assertEqual(response["HX-Refresh"], "true") + binding = RelayGroupBinding.objects.get() + self.assertManagerReloaded(times=1) + + self.client.post(reverse("htmx_add_relay_to_relay_group", kwargs={"relay_group_id": self.group.id}), {"nspanel_id": self.panel.id, "relay_selection": "1"}) + self.assertEqual(RelayGroupBinding.objects.count(), 1) + + self.client.delete(reverse("htmx_remove_relay_from_relay_group", kwargs={"relay_binding_id": binding.id})) + + self.assertFalse(RelayGroupBinding.objects.exists()) + self.assertManagerReloaded(times=2) + + def test_add_relay_modal_only_offers_accepted_panels(self): + self.make_panel(self.room, "Pending panel", mac="AA:BB:CC:DD:EE:02") + + response = self.client.get(reverse("htmx_modal_add_relay_to_relay_group", kwargs={"relay_group_id": self.group.id})) + + self.assertContains(response, "Hall panel") + self.assertNotContains(response, "Pending panel") + + def test_delete_group(self): + self.bind(self.panel, 1) + + self.client.delete(reverse("htmx_delete_relay_group", kwargs={"relay_group_id": self.group.id})) + + self.assertFalse(RelayGroup.objects.exists()) + self.assertFalse(RelayGroupBinding.objects.exists()) + + def test_save_and_delete_group_reload_manager(self): + self.client.post(reverse("htmx_save_relay_group"), {"name": "Porch"}) + self.client.delete(reverse("htmx_delete_relay_group", kwargs={"relay_group_id": self.group.id})) + + self.assertManagerReloaded(times=2) + + +class RelayGroupRESTTests(RelayGroupTestCase): + def test_list_relay_groups(self): + self.bind(self.panel, 1) + self.bind(self.other_panel, 2) + + response = self.client.get(reverse("rest_relay_groups")) + + self.assertEqual(response.status_code, 200) + group = response.json()["relay_groups"][0] + self.assertEqual((group["relay_group_id"], group["name"]), (self.group.id, "Hall lights")) + self.assertCountEqual(group["relays"], [{"nspanel_id": self.panel.id, "relay_num": 1}, {"nspanel_id": self.other_panel.id, "relay_num": 2}]) + + def test_relay_groups_endpoint_is_read_only(self): + self.assertEqual(self.client.post(reverse("rest_relay_groups")).status_code, 405) diff --git a/docker/web/nspanelmanager/web/tests/test_rooms.py b/docker/web/nspanelmanager/web/tests/test_rooms.py new file mode 100644 index 000000000..84b90124f --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/test_rooms.py @@ -0,0 +1,263 @@ +import json + +from django.test import override_settings +from django.urls import reverse + +from web.models import Entity, Room, RoomEntitiesPage, Scene + +from .harness import NSPMTestCase + + +class RoomUITests(NSPMTestCase): + """Rooms created/edited/deleted through the web UI views.""" + + def test_create_room(self): + response = self.client.post(reverse("save_new_room"), {"friendly_name": "Kitchen"}) + + room = Room.objects.get(friendly_name="Kitchen") + self.assertRedirects(response, reverse("edit_room", kwargs={"room_id": room.id}), fetch_redirect_response=False) + # A new room always gets one entities page and one scenes page. + self.assertEqual(RoomEntitiesPage.objects.filter(room=room, is_scenes_page=False).count(), 1) + self.assertEqual(RoomEntitiesPage.objects.filter(room=room, is_scenes_page=True).count(), 1) + self.assertManagerReloaded(times=1) + + def test_create_room_without_name_is_rejected(self): + response = self.client.post(reverse("save_new_room"), {}) + + self.assertRejected(response) + self.assertFalse(Room.objects.exists()) + self.assertManagerNotReloaded() + + def test_rename_room(self): + room = self.make_room("Kitchen") + + response = self.client.post( + reverse("update_room_form", kwargs={"room_id": room.id}), + {"friendly_name": "Galley", "room_temp_provider": "home_assistant", "room_temp_sensor": "sensor.galley_temp"}, + ) + + self.assertEqual(response.status_code, 302) + room.refresh_from_db() + self.assertEqual(room.friendly_name, "Galley") + self.assertEqual(room.room_temp_sensor, "sensor.galley_temp") + self.assertManagerReloaded(times=1) + + def test_delete_room_cascades_and_rehomes_panels(self): + kitchen = self.make_room("Kitchen") + lounge = self.make_room("Lounge") + panel = self.make_panel(kitchen) + self.make_light(kitchen) + self.make_scene(kitchen) + + self.client.get(reverse("delete_room", kwargs={"room_id": kitchen.id})) + + self.assertFalse(Room.objects.filter(id=kitchen.id).exists()) + self.assertFalse(Entity.objects.exists()) + self.assertFalse(Scene.objects.exists()) + self.assertFalse(RoomEntitiesPage.objects.filter(room_id=kitchen.id).exists()) + # The panel is not deleted with its room; it moves to another room. + panel.refresh_from_db() + self.assertEqual(panel.room, lounge) + self.assertManagerReloaded(times=1) + + def test_last_room_cannot_be_deleted(self): + room = self.make_room("Only room") + + self.client.get(reverse("delete_room", kwargs={"room_id": room.id})) + + self.assertTrue(Room.objects.filter(id=room.id).exists()) + self.assertManagerNotReloaded() + + def test_reorder_rooms(self): + a, b, c = self.make_room("A"), self.make_room("B"), self.make_room("C") + + response = self.client.post(reverse("htmx_partial_reorder_rooms"), {"htmx_form_save_rooms_order_field": json.dumps({"rooms": [c.id, a.id, b.id]})}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(list(Room.objects.order_by("displayOrder").values_list("friendly_name", flat=True)), ["C", "A", "B"]) + self.assertManagerReloaded(times=1) + + def test_reorder_rooms_with_missing_or_empty_payload_is_rejected(self): + self.make_room("A") + for payload in ({}, {"htmx_form_save_rooms_order_field": json.dumps({})}, {"htmx_form_save_rooms_order_field": json.dumps({"rooms": []})}): + with self.subTest(payload=payload): + self.assertRejected(self.client.post(reverse("htmx_partial_reorder_rooms"), payload)) + self.assertManagerNotReloaded() + + +class RoomRESTTests(NSPMTestCase): + """The /rest/rooms JSON API used by external tools.""" + + def test_create_room(self): + response = self.post_json(reverse("rest_rooms_create"), {"name": "Office"}) + + self.assertEqual(response.status_code, 200) + room_id = response.json()["room_id"] + self.assertEqual(Room.objects.get(id=room_id).friendly_name, "Office") + + def test_create_room_rejects_bad_payloads(self): + for body in ({}, {"friendly_name": "wrong key"}, {"name": ""}, "not json"): + with self.subTest(body=body): + response = self.post_json(reverse("rest_rooms_create"), body) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["status"], "error") + self.assertTrue(response.json()["message"]) + self.assertFalse(Room.objects.exists()) + self.assertManagerNotReloaded() + + def test_rooms_endpoint_rejects_unsupported_methods(self): + self.assertEqual(self.client.delete(reverse("rest_rooms_create")).status_code, 405) + room = self.make_room() + self.assertEqual(self.client.get(reverse("rest_room_delete", kwargs={"room_id": room.id})).status_code, 405) + + def test_delete_room(self): + room = self.make_room("Office") + self.make_light(room) + + response = self.client.delete(reverse("rest_room_delete", kwargs={"room_id": room.id})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"status": "ok", "room_id": room.id}) + self.assertFalse(Room.objects.exists()) + self.assertFalse(Entity.objects.exists()) + + def test_delete_unknown_room_is_rejected(self): + room = self.make_room("Office") + + response = self.client.delete(reverse("rest_room_delete", kwargs={"room_id": room.id + 100})) + + self.assertRejected(response) + self.assertTrue(Room.objects.filter(id=room.id).exists()) + + def test_list_rooms(self): + office = self.make_room("Office") + lounge = self.make_room("Lounge") + + response = self.client.get(reverse("rest_rooms_create")) + + self.assertEqual(response.status_code, 200) + self.assertEqual([(r["id"], r["friendly_name"]) for r in response.json()["rooms"]], [(office.id, "Office"), (lounge.id, "Lounge")]) + self.assertEqual(set(response.json()["rooms"][0]), {"id", "friendly_name", "display_order", "room_temp_provider", "room_temp_sensor"}) + + def test_get_single_room(self): + self.make_room("Office") + lounge = self.make_room("Lounge") + + response = self.client.get(reverse("rest_rooms_create"), {"id": lounge.id}) + + self.assertEqual([(r["id"], r["friendly_name"]) for r in response.json()["rooms"]], [(lounge.id, "Lounge")]) + + def test_update_room(self): + room = self.make_room("Office") + + response = self.put_json(reverse("rest_rooms_create"), {"id": room.id, "friendly_name": "Study", "room_temp_provider": "home_assistant", "room_temp_sensor": "sensor.study"}) + + self.assertEqual(response.status_code, 200) + room.refresh_from_db() + self.assertEqual((room.friendly_name, room.room_temp_provider, room.room_temp_sensor), ("Study", "home_assistant", "sensor.study")) + self.assertManagerReloaded(times=1) + + def test_update_room_display_order(self): + room = self.make_room("Office") + + response = self.put_json(reverse("rest_rooms_create"), {"id": room.id, "display_order": 7}) + + self.assertEqual(response.status_code, 200) + room.refresh_from_db() + self.assertEqual(room.displayOrder, 7) + + @override_settings(REST_REJECT_UNKNOWN_FIELDS=False) + def test_update_room_ignores_unknown_fields_with_a_warning(self): + # Production behaviour: unknown fields are logged and ignored rather than rejected, for + # compatibility between UI and server versions (see WarnOnUnknownFieldsMixin). + # "displayOrder" was the original room_put bug. + room = self.make_room("Office") + display_order = room.displayOrder + + with self.assertLogs("web.serializers", level="WARNING") as logs: + response = self.put_json(reverse("rest_rooms_create"), {"id": room.id, "friendly_name": "Study", "displayOrder": 7}) + + self.assertEqual(response.status_code, 200) + self.assertIn("RoomUpdateSerializer: ignoring unknown field(s): displayOrder", logs.output[0]) + room.refresh_from_db() + self.assertEqual((room.friendly_name, room.displayOrder), ("Study", display_order)) + + def test_update_room_rejects_unknown_fields_under_test_settings(self): + room = self.make_room("Office") + + response = self.put_json(reverse("rest_rooms_create"), {"id": room.id, "friendly_name": "Study", "displayOrder": 7}) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["errors"], {"displayOrder": ["Unknown field."]}) + room.refresh_from_db() + self.assertEqual(room.friendly_name, "Office") + self.assertManagerNotReloaded() + + def test_update_room_without_changes_does_not_reload_manager(self): + room = self.make_room("Office") + unchanged = {"friendly_name": room.friendly_name, "display_order": room.displayOrder, "room_temp_provider": room.room_temp_provider, "room_temp_sensor": room.room_temp_sensor} + + for body in ({"id": room.id}, {"id": room.id, **unchanged}): + with self.subTest(body=body): + response = self.put_json(reverse("rest_rooms_create"), body) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"status": "ok", "room_id": room.id}) + self.assertManagerNotReloaded() + + def test_update_room_rejects_bad_values(self): + room = self.make_room("Office") + for body in ({"friendly_name": "Study"}, {"id": room.id, "friendly_name": ""}, {"id": room.id, "friendly_name": "x" * 31}, {"id": room.id, "display_order": "first"}): + with self.subTest(body=body): + response = self.put_json(reverse("rest_rooms_create"), body) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["status"], "error") + room.refresh_from_db() + self.assertEqual(room.friendly_name, "Office") + self.assertManagerNotReloaded() + + def test_update_room_can_clear_temperature_sensor(self): + room = self.make_room("Office") + Room.objects.filter(id=room.id).update(room_temp_provider="home_assistant", room_temp_sensor="sensor.office") + + response = self.put_json(reverse("rest_rooms_create"), {"id": room.id, "room_temp_provider": "", "room_temp_sensor": ""}) + + self.assertEqual(response.status_code, 200) + room.refresh_from_db() + self.assertEqual((room.room_temp_provider, room.room_temp_sensor), ("", "")) + + def test_update_unknown_room_is_rejected(self): + response = self.put_json(reverse("rest_rooms_create"), {"id": 999, "friendly_name": "Study"}) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["status"], "error") + self.assertManagerNotReloaded() + + def test_delete_room_reloads_manager(self): + room = self.make_room("Office") + self.make_room("Lounge") + + self.client.delete(reverse("rest_room_delete", kwargs={"room_id": room.id})) + + self.assertManagerReloaded(times=1) + + def test_delete_room_rehomes_panels(self): + office = self.make_room("Office") + lounge = self.make_room("Lounge") + panel = self.make_panel(office) + + self.client.delete(reverse("rest_room_delete", kwargs={"room_id": office.id})) + + panel.refresh_from_db() + self.assertEqual(panel.room, lounge) + + def test_created_room_has_entity_pages(self): + response = self.post_json(reverse("rest_rooms_create"), {"name": "Office"}) + + room_id = response.json()["room_id"] + self.assertEqual(RoomEntitiesPage.objects.filter(room_id=room_id).count(), 2) + + def test_create_room_rejects_overlong_name(self): + response = self.post_json(reverse("rest_rooms_create"), {"name": "x" * 100}) + + self.assertRejected(response) + self.assertFalse(Room.objects.exists()) diff --git a/docker/web/nspanelmanager/web/tests/test_settings.py b/docker/web/nspanelmanager/web/tests/test_settings.py new file mode 100644 index 000000000..e9899e013 --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/test_settings.py @@ -0,0 +1,311 @@ +""" +Global settings: the settings page form, weather & time, theme, the first-run setup +wizard, the settings REST endpoints and web.settings_helper. +""" + +from django.urls import reverse + +from web.models import NSPanelSettings, Settings +from web.settings_helper import ( + delete_nspanel_setting, + does_setting_exist, + get_nspanel_setting_with_default, + get_setting_with_default, + set_nspanel_setting_value, + set_setting_value, +) + +from .harness import NSPMTestCase + +# Every field the settings page form submits. +SETTINGS_FORM = { + "mqtt_server": "mqtt.local", + "mqtt_port": "1883", + "mqtt_username": "nspm", + "mqtt_password": "", + "home_assistant_address": "http://ha.local:8123", + "home_assistant_token": "", + "openhab_address": "", + "openhab_token": "", + "raise_to_100_light_level": "95", + "default_nspanel_type": "eu", + "color_temp_min": "2000", + "color_temp_max": "6000", + "reverse_color_temp": "False", + "min_button_push_time": "50", + "button_long_press_time": "300", + "special_mode_trigger_time": "300", + "special_mode_release_time": "5000", + "screensaver_activation_timeout": "30000", + "screen_dim_level": "100", + "screensaver_dim_level": "0", + "screensaver_mode": "with_background", + "show_screensaver_inside_temperature": "True", + "show_screensaver_outside_temperature": "True", + "turn_on_behavior": "color_temp", + "max_live_log_messages": "10", + "max_log_buffer_size": "10", + "mqttmanager_log_level": "info", + "manager_address": "192.168.1.10", + "manager_port": "8000", + "optimistic_mode": "optimistic", + "all_rooms_status_backoff_time": "250", + "room_status_backoff_time": "250", + "light_turn_on_brightness": "50", +} + + +def setting(name): + return get_setting_with_default(name) + + +class SettingsPageTests(NSPMTestCase): + def save(self, **overrides): + return self.client.post(reverse("save_settings"), SETTINGS_FORM | overrides) + + def test_save_settings(self): + response = self.save() + + self.assertRedirects(response, reverse("settings"), fetch_redirect_response=False) + for name in ("mqtt_server", "mqtt_port", "color_temp_min", "screensaver_mode", "manager_address", "light_turn_on_brightness"): + with self.subTest(name=name): + self.assertEqual(setting(name), SETTINGS_FORM[name]) + self.assertEqual(setting("optimistic_mode"), "True") + self.assertManagerReloaded(times=1) + + def test_trailing_slash_is_stripped_from_addresses(self): + self.save(home_assistant_address="http://ha.local:8123/", openhab_address="http://openhab.local/") + + self.assertEqual(setting("home_assistant_address"), "http://ha.local:8123") + self.assertEqual(setting("openhab_address"), "http://openhab.local") + + def test_blank_secret_fields_keep_the_stored_secret(self): + # The form never echoes secrets back, so a blank field means "unchanged". + set_setting_value("mqtt_password", "hunter2") + set_setting_value("home_assistant_token", "ha-token") + set_setting_value("openhab_token", "oh-token") + + self.save() + + self.assertEqual((setting("mqtt_password"), setting("home_assistant_token"), setting("openhab_token")), ("hunter2", "ha-token", "oh-token")) + + def test_new_secrets_replace_the_stored_ones(self): + set_setting_value("mqtt_password", "hunter2") + + self.save(mqtt_password="correct horse", home_assistant_token="new-token") + + self.assertEqual((setting("mqtt_password"), setting("home_assistant_token")), ("correct horse", "new-token")) + + def test_clear_checkboxes_remove_secrets(self): + set_setting_value("mqtt_password", "hunter2") + set_setting_value("openhab_token", "oh-token") + + self.save(clear_mqtt_password="on", clear_home_assistant_token="on", clear_openhab_token="on", mqtt_password="ignored") + + self.assertEqual((setting("mqtt_password"), setting("home_assistant_token"), setting("openhab_token")), ("", "", "")) + + def test_optional_fields_fall_back_to_defaults(self): + form = {k: v for k, v in SETTINGS_FORM.items() if k not in ("show_screensaver_inside_temperature", "turn_on_behavior", "optimistic_mode")} + set_setting_value("optimistic_mode", "False") + + self.client.post(reverse("save_settings"), form) + + self.assertEqual(setting("show_screensaver_inside_temperature"), "True") + self.assertEqual(setting("turn_on_behavior"), "color_temp") + self.assertEqual(setting("optimistic_mode"), "False") # left alone when not submitted + + def test_missing_required_field_is_rejected(self): + form = {k: v for k, v in SETTINGS_FORM.items() if k != "manager_port"} + + self.assertRejected(self.client.post(reverse("save_settings"), form)) + self.assertManagerNotReloaded() + + def test_settings_page_shows_saved_values_but_not_secrets(self): + self.save(mqtt_password="hunter2", home_assistant_token="ha-secret-token") + + response = self.client.get(reverse("settings")) + + self.assertContains(response, "mqtt.local") + self.assertNotContains(response, "hunter2") + self.assertNotContains(response, "ha-secret-token") + + +WEATHER_FORM = { + "location_latitude": "-36.85", + "location_longitude": "174.76", + "wind_speed_format": "kmh", + "precipitation_format": "mm", + "outside_temp_provider": "home_assistant", + "outside_temp_sensor": "sensor.outside", + "weather_update_interval": "10", + "date_format": "%d/%m/%Y", + "clock_us_style": "False", + "use_fahrenheit": "True", +} + + +class WeatherAndThemeTests(NSPMTestCase): + def test_save_weather_and_time(self): + response = self.client.post(reverse("weather_and_time"), WEATHER_FORM) + + self.assertRedirects(response, reverse("weather_and_time"), fetch_redirect_response=False) + self.assertEqual(setting("location_latitude"), "-36.85") + self.assertEqual(setting("weather_wind_speed_format"), "kmh") + self.assertEqual(setting("outside_temp_sensor_entity_id"), "sensor.outside") + self.assertEqual(setting("use_fahrenheit"), "True") + self.assertManagerReloaded(times=1) + + def test_weather_form_missing_field_is_rejected(self): + form = {k: v for k, v in WEATHER_FORM.items() if k != "use_fahrenheit"} + + self.assertRejected(self.client.post(reverse("weather_and_time"), form)) + self.assertManagerNotReloaded() + + def test_interface_theme(self): + response = self.client.post(reverse("htmx_interface_theme"), {"theme-dropdown": "dracula"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(setting("theme"), "dracula") + self.assertContains(self.client.get(reverse("index")), "dracula") + + +# Everything the React first-run wizard submits. +WIZARD_SETTINGS = { + "manager_address": "192.168.1.10", + "manager_port": "8000", + "mqtt_server": "mqtt.local", + "mqtt_port": "1883", + "mqtt_username": "u", + "mqtt_password": "p", + "home_assistant_address": "http://ha.local", + "home_assistant_token": "t", + "openhab_address": "", + "openhab_token": "", +} + + +class InitialSetupTests(NSPMTestCase): + """The first-run wizard (React InitialSetup) saves everything in one POST /rest/settings.""" + + def test_wizard_saves_all_settings(self): + response = self.post_json(reverse("rest_settings"), {"settings": WIZARD_SETTINGS}) + + self.assertEqual(response.status_code, 200) + for name, value in WIZARD_SETTINGS.items(): + self.assertEqual(setting(name), value) + self.assertManagerReloaded(times=1) + + def test_only_submitted_settings_change(self): + set_setting_value("mqtt_password", "keep-me") + + self.post_json(reverse("rest_settings"), {"settings": {"mqtt_server": "mqtt.local"}}) + + self.assertEqual(setting("mqtt_server"), "mqtt.local") + self.assertEqual(setting("mqtt_password"), "keep-me") + + def test_bad_payloads_are_rejected(self): + before = list(Settings.objects.values_list("name", "value")) + for body in ({}, {"settings": ["mqtt_server"]}, {"settings": "mqtt_server"}): + with self.subTest(body=body): + self.assertEqual(self.post_json(reverse("rest_settings"), body).status_code, 400) + self.assertEqual(list(Settings.objects.values_list("name", "value")), before) + self.assertManagerNotReloaded() + + +class SettingsRESTTests(NSPMTestCase): + def configure_secrets(self): + set_setting_value("mqtt_password", "hunter2") + set_setting_value("home_assistant_token", "ha-secret") + set_setting_value("openhab_token", "") + + def test_settings_endpoint_masks_secrets(self): + self.configure_secrets() + set_setting_value("mqtt_server", "mqtt.local") + + response = self.client.get(reverse("rest_settings")) + + self.assertEqual(response.status_code, 200) + settings = response.json()["settings"] + self.assertEqual(settings["mqtt_server"], "mqtt.local") + for secret in ("mqtt_password", "home_assistant_token", "openhab_token"): + self.assertNotIn(secret, settings) + self.assertEqual((settings["mqtt_password_set"], settings["home_assistant_token_set"], settings["openhab_token_set"]), (True, True, False)) + self.assertNotIn("hunter2", response.content.decode()) + + def test_settings_endpoint_rejects_unsupported_methods(self): + self.assertEqual(self.client.put(reverse("rest_settings")).status_code, 405) + + def test_settings_endpoint_works_before_all_secrets_are_set(self): + set_setting_value("mqtt_password", "hunter2") + + response = self.client.get(reverse("rest_settings")) + + self.assertEqual(response.status_code, 200) + settings = response.json()["settings"] + self.assertEqual((settings["mqtt_password_set"], settings["home_assistant_token_set"], settings["openhab_token_set"]), (True, True, False)) + self.assertNotIn("mqtt_password", settings) + self.assertNotIn("home_assistant_token", settings) + + def test_mqttmanager_can_read_a_setting(self): + set_setting_value("color_temp_min", "2000") + + response = self.client.get(reverse("rest_mqttmanager_get_setting", kwargs={"setting_key": "color_temp_min"})) + + self.assertEqual(response.json(), {"status": "ok", "settings": {"color_temp_min": "2000"}}) + + def test_mqttmanager_settings_endpoint_checks_method(self): + self.assertEqual(self.client.post(reverse("rest_mqttmanager_get_setting", kwargs={"setting_key": "x"})).status_code, 405) + + def test_mqttmanager_settings_endpoint_refuses_banned_keys_as_listed(self): + for key in ("MQTT_PASSWORD", "HOME_ASSISTANT_TOKEN"): + with self.subTest(key=key): + self.assertEqual(self.client.get(reverse("rest_mqttmanager_get_setting", kwargs={"setting_key": key})).status_code, 403) + + def test_mqttmanager_settings_endpoint_refuses_secrets(self): + # banned_setting_keys is upper case but settings are stored lower case, so the check + # must be case-insensitive or the secrets it protects are returned. + self.configure_secrets() + + for key in ("mqtt_password", "home_assistant_token", "openhab_token"): + with self.subTest(key=key): + self.assertEqual(self.client.get(reverse("rest_mqttmanager_get_setting", kwargs={"setting_key": key})).status_code, 403) + + +class SettingsHelperTests(NSPMTestCase): + def test_global_settings(self): + self.assertEqual(get_setting_with_default("not_set"), "") + self.assertFalse(does_setting_exist("not_set")) + + set_setting_value("colour", "blue") + set_setting_value("colour", "green") # update, not duplicate + + self.assertEqual(get_setting_with_default("colour"), "green") + self.assertTrue(does_setting_exist("colour")) + self.assertEqual(Settings.objects.filter(name="colour").count(), 1) + + def test_values_are_returned_as_strings(self): + set_setting_value("level", 42) + + self.assertEqual(get_setting_with_default("level"), "42") + + def test_panel_settings(self): + room = self.make_room() + panel = self.make_panel(room) + other = self.make_panel(room, mac="AA:BB:CC:DD:EE:01") + + self.assertEqual(get_nspanel_setting_with_default(panel.id, "dim", "default"), "default") + set_nspanel_setting_value(panel.id, "dim", "50") + set_nspanel_setting_value(panel.id, "dim", "60") + + self.assertEqual(get_nspanel_setting_with_default(panel.id, "dim", "default"), "60") + self.assertEqual(get_nspanel_setting_with_default(other.id, "dim", "default"), "default") + self.assertEqual(NSPanelSettings.objects.filter(nspanel=panel, name="dim").count(), 1) + + delete_nspanel_setting(panel.id, "dim") + self.assertEqual(get_nspanel_setting_with_default(panel.id, "dim", "default"), "default") + + def test_panel_settings_for_unknown_panel(self): + self.assertEqual(get_nspanel_setting_with_default(999, "dim", "default"), "default") + set_nspanel_setting_value(999, "dim", "50") # silently ignored + delete_nspanel_setting(999, "dim") + self.assertFalse(NSPanelSettings.objects.exists()) diff --git a/docker/web/nspanelmanager/web/urls.py b/docker/web/nspanelmanager/web/urls.py index 4d38d8f31..cbbdcb14d 100644 --- a/docker/web/nspanelmanager/web/urls.py +++ b/docker/web/nspanelmanager/web/urls.py @@ -1,4 +1,5 @@ from django.urls import path +from drf_spectacular.views import SpectacularAPIView from . import api, htmx, rest, views @@ -69,12 +70,13 @@ path("rest/global/entities_pages", rest.global_entities_pages, name="rest_global_entities_pages"), path("rest/entities_pages/order", rest.room_entities_pages_order, name="rest_room_entities_pages_order"), # Room URLs - path("rest/rooms/", rest.room_delete, name="rest_room_delete"), + path("rest/rooms/", rest.RoomView.as_view(), name="rest_room_delete"), path("rest/rooms//entities", rest.room_entities, name="rest_room_entities"), path("rest/rooms//entities/order", rest.put_room_entities_order, name="rest_put_room_entities_order"), path("rest/rooms//entities_pages", rest.room_entities_pages, name="rest_room_entities_pages"), path("rest/entities_pages/", rest.room_entities_page, name="rest_room_entities_page"), - path("rest/rooms", rest.rooms, name="rest_rooms_create"), + path("rest/rooms", rest.RoomsView.as_view(), name="rest_rooms_create"), + path("rest/schema", SpectacularAPIView.as_view(), name="rest_schema"), # Settings URLs path("rest/settings", rest.settings, name="rest_settings"), # Scene URLs @@ -112,8 +114,6 @@ path("htmx/partial/edit_light_entity_in_room/", htmx.partial_entity_edit_light_entity, name="htmx_partial_edit_light_entity"), path("htmx/partial/edit_scene_entity/", htmx.partial_entity_edit_scene_entity, name="htmx_partial_edit_scene_entity"), path("htmx/partial/remove_entity_from_page_slot//", htmx.partial_remove_entity_from_page_slot, name="htmx_partial_remove_entity_from_page_slot"), - path("htmx/partial/edit_entities_page/", htmx.partial_edit_entities_page, name="htmx_partial_edit_entities_page"), - path("htmx/partial/edit_entities_page//save/", htmx.partial_save_edit_entities_page, name="htmx_partial_save_edit_entities_page"), path("htmx/partial/move_entity", htmx.partial_move_entity, name="htmx_partial_move_entity"), path("htmx/partial/reorder_entity_pages", htmx.partial_move_entities_pages, name="htmx_partial_move_entities_pages"), path("htmx/partial/delete_entities_page/", htmx.partial_delete_entities_page, name="htmx_partial_delete_entities_page"), diff --git a/docker/web/nspanelmanager/web/views.py b/docker/web/nspanelmanager/web/views.py index 8ede62f8f..88dc89ecd 100755 --- a/docker/web/nspanelmanager/web/views.py +++ b/docker/web/nspanelmanager/web/views.py @@ -697,6 +697,13 @@ def download_tft(request, panel_id): return HttpResponse(fs.open(tft_file).read(), content_type="application/octet-stream") +def checksum_response(md5sum): + # A panel compares the body against its stored checksum, so never send "None" as if it were one. + if md5sum is None: + return HttpResponse("", status=404) + return HttpResponse(md5sum) + + def checksum_firmware(request): if "model" in request.GET: model = request.GET["model"] @@ -706,7 +713,7 @@ def checksum_firmware(request): # As model was not specified, default to sonoff as that was the original model model = "sonoff" - return HttpResponse(get_file_md5sum(f"firmware/{model}/firmware.bin")) + return checksum_response(get_file_md5sum(f"firmware/{model}/firmware.bin")) def checksum_data_file(request): @@ -718,7 +725,7 @@ def checksum_data_file(request): # As model was not specified, default to sonoff as that was the original model model = "sonoff" - return HttpResponse(get_file_md5sum(f"firmware/{model}/data_file.bin")) + return checksum_response(get_file_md5sum(f"firmware/{model}/data_file.bin")) def checksum_tft_file(request, panel_id): @@ -739,7 +746,7 @@ def checksum_tft_file(request, panel_id): else: print(f"ERROR! Could not determine TFT file for NSPanel with ID {panel_id}") - return HttpResponse(get_file_md5sum(tft_file)) + return checksum_response(get_file_md5sum(tft_file)) def get_manual(request): diff --git a/docker/web/openapi.yaml b/docker/web/openapi.yaml new file mode 100644 index 000000000..bb2e6a969 --- /dev/null +++ b/docker/web/openapi.yaml @@ -0,0 +1,218 @@ +openapi: 3.0.3 +info: + title: NSPanelManager REST API + version: '1' + description: The /rest/* API used by the web UI. Generated by drf-spectacular; see + docker/web/generate_api.sh. +paths: + /rest/rooms: + get: + operationId: rooms_retrieve + parameters: + - in: query + name: id + schema: + type: integer + description: Only return this room. + tags: + - rooms + security: + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/RoomListResponse' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: '' + post: + operationId: rooms_create + tags: + - rooms + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RoomCreateRequest' + required: true + security: + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/RoomIdResponse' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: '' + put: + operationId: rooms_update + tags: + - rooms + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RoomUpdateRequest' + required: true + security: + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/RoomIdResponse' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: '' + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: '' + /rest/rooms/{room_id}: + delete: + operationId: rooms_destroy + parameters: + - in: path + name: room_id + schema: + type: integer + required: true + tags: + - rooms + security: + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/RoomIdResponse' + description: '' + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: '' +components: + schemas: + ErrorResponse: + type: object + properties: + status: + $ref: '#/components/schemas/ErrorStatusEnum' + message: + type: string + errors: + type: object + additionalProperties: + type: array + items: + type: string + description: Validation errors by field name. + required: + - message + - status + ErrorStatusEnum: + enum: + - error + type: string + description: '* `error` - error' + OkStatusEnum: + enum: + - ok + type: string + description: '* `ok` - ok' + Room: + type: object + properties: + id: + type: integer + readOnly: true + friendly_name: + type: string + maxLength: 30 + display_order: + type: integer + room_temp_provider: + type: string + maxLength: 255 + room_temp_sensor: + type: string + maxLength: 255 + required: + - display_order + - friendly_name + - id + - room_temp_provider + - room_temp_sensor + RoomCreateRequest: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 30 + required: + - name + RoomIdResponse: + type: object + properties: + status: + $ref: '#/components/schemas/OkStatusEnum' + room_id: + type: integer + required: + - room_id + - status + RoomListResponse: + type: object + properties: + status: + $ref: '#/components/schemas/OkStatusEnum' + rooms: + type: array + items: + $ref: '#/components/schemas/Room' + required: + - rooms + - status + RoomUpdateRequest: + type: object + description: 'PUT /rest/rooms: the room''s id, plus only the fields to change.' + properties: + id: + type: integer + friendly_name: + type: string + minLength: 1 + maxLength: 30 + display_order: + type: integer + room_temp_provider: + type: string + maxLength: 255 + room_temp_sensor: + type: string + maxLength: 255 + required: + - id diff --git a/docker/web/requirements.txt b/docker/web/requirements.txt index b91d9e818..1ad3dd9cd 100755 --- a/docker/web/requirements.txt +++ b/docker/web/requirements.txt @@ -1,4 +1,5 @@ asgiref==3.8.1 +attrs==26.1.0 certifi==2024.8.30 charset-normalizer==3.3.2 colorama==0.4.6 @@ -7,19 +8,28 @@ distro==1.8.0 Django==5.1.1 django-environ==0.11.2 django_components==0.95 +djangorestframework==3.17.1 +drf-spectacular==0.30.0 fasteners==0.19 idna==3.8 +inflection==0.5.1 Jinja2==3.1.4 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 MarkupSafe==2.1.5 patch-ng==1.18.0 protobuf==5.27.0 psutil==6.0.0 python-dateutil==2.9.0.post0 PyYAML==6.0.2 +referencing==0.37.0 requests==2.32.3 +rpds-py==2026.9.1 setuptools==72.1.0 six==1.16.0 sqlparse==0.5.1 +typing_extensions==4.16.0 +uritemplate==4.2.0 urllib3==1.26.20 uWSGI==2.0.31 wheel==0.44.0 diff --git a/docker/web/run_tests.sh b/docker/web/run_tests.sh new file mode 100755 index 000000000..b922c44fc --- /dev/null +++ b/docker/web/run_tests.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# Run the Django test suite. Extra arguments are passed to "manage.py test", e.g. +# ./run_tests.sh web.tests.test_entities +# ./run_tests.sh -v 2 +cd "$(dirname "$0")/nspanelmanager" || exit 1 +exec python manage.py test --settings=nspanelmanager.test_settings "$@"