Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8d249cc
Add a Django test harness and initial REST API tests
jamesmulcahy Sep 25, 2026
9d3f54f
Expand Django test coverage
jamesmulcahy Sep 25, 2026
024e4df
Make the MQTTManager settings endpoint's banned-key check case-insens…
jamesmulcahy Sep 25, 2026
23e8d28
Tell MQTTManager to reload after deletes and edits that skipped it
jamesmulcahy Sep 25, 2026
833a65a
Make /rest/rooms create and delete match the UI's room views
jamesmulcahy Sep 25, 2026
c9cf90d
Apply PUT /rest/rooms/<id>/entities/order atomically
jamesmulcahy Sep 25, 2026
3bb0972
Validate where entities and scenes are placed on a page
jamesmulcahy Sep 25, 2026
bd6f3ed
Reject entities and scenes MQTTManager cannot load
jamesmulcahy Sep 25, 2026
d3d7952
Only allow 4, 8 and 12 entity pages, and do not strand entities on sh…
jamesmulcahy Sep 25, 2026
cf0ede1
Reject non-PUT requests to the thermostat and scene entity endpoints
jamesmulcahy Sep 25, 2026
a44657a
Fix three small REST bugs: settings 500, scene filter, missing checksums
jamesmulcahy Sep 25, 2026
08c05f6
Fix api/get_nspanel_config and remove two dead htmx partials
jamesmulcahy Sep 25, 2026
f303838
Update tests for the React first page (#391)
jamesmulcahy Sep 26, 2026
328186a
Remove the leftover htmx setup wizard that 500s every page on a fresh…
jamesmulcahy Sep 26, 2026
16ce94c
Make the wizard test settings a module constant (ruff RUF012)
jamesmulcahy Sep 26, 2026
58e7ac8
Report Django test results in the GitHub Actions job summary
jamesmulcahy Sep 26, 2026
6fb9b63
Test adding Home Assistant scripts as scenes
jamesmulcahy Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/django-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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
7 changes: 5 additions & 2 deletions docker/web/nspanelmanager/nspanelmanager/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -107,7 +110,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,
Expand Down
30 changes: 30 additions & 0 deletions docker/web/nspanelmanager/nspanelmanager/test_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""
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")

# 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"
10 changes: 5 additions & 5 deletions docker/web/nspanelmanager/web/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"] = []
Expand Down
46 changes: 26 additions & 20 deletions docker/web/nspanelmanager/web/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
29 changes: 3 additions & 26 deletions docker/web/nspanelmanager/web/htmx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading