feat: add dashboard API with quick links and widgets
Build and Push / build (release) Successful in 1m28s

- Dashboard domain with Quick Links CRUD + reorder endpoints
- Dashboard widgets management endpoints
- Database migrations for quick_links and dashboard_widgets tables
- Static file controller for Organizr widgets
- Default local user when OIDC is disabled
- Domain-based architecture refactor (src/domains/, src/shared/)
- Test suite updated for new structure (285 tests passing)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-01-03 12:27:57 +01:00
co-authored by Claude Opus 4.5
parent e85c9a123d
commit 381d43b60b
51 changed files with 8215 additions and 784 deletions
+37 -37
View File
@@ -89,7 +89,7 @@ def sample_states():
class TestHousekeepingHealth:
"""Test /housekeeping/health endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_health_returns_200(self, mock_get_ha, client):
"""Health endpoint should return 200."""
mock_client = AsyncMock()
@@ -104,7 +104,7 @@ class TestHousekeepingHealth:
response = client.get("/housekeeping/health")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_health_returns_connection_status(self, mock_get_ha, client):
"""Health endpoint should return connection status."""
mock_client = AsyncMock()
@@ -124,7 +124,7 @@ class TestHousekeepingHealth:
assert "platform" in data
assert data["platform"] == "home_assistant"
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_health_returns_unhealthy_when_disconnected(self, mock_get_ha, client):
"""Health should report unhealthy when HA is disconnected."""
mock_client = AsyncMock()
@@ -146,7 +146,7 @@ class TestHousekeepingHealth:
class TestHousekeepingDevices:
"""Test /housekeeping/devices endpoints."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_list_devices_returns_200(self, mock_get_ha, client, sample_states):
"""List devices should return 200."""
mock_client = AsyncMock()
@@ -156,7 +156,7 @@ class TestHousekeepingDevices:
response = client.get("/housekeeping/devices")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_list_devices_returns_controllable_only(self, mock_get_ha, client, sample_states):
"""List devices should filter out non-controllable entities."""
mock_client = AsyncMock()
@@ -173,7 +173,7 @@ class TestHousekeepingDevices:
assert "switch.garage" in entity_ids
assert "sensor.temperature" not in entity_ids
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_list_devices_filter_by_domain(self, mock_get_ha, client, sample_states):
"""List devices should filter by domain parameter."""
mock_client = AsyncMock()
@@ -188,7 +188,7 @@ class TestHousekeepingDevices:
for device in data["devices"]:
assert device["domain"] == "light"
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_list_devices_includes_attributes(self, mock_get_ha, client, sample_states):
"""List devices should include device attributes."""
mock_client = AsyncMock()
@@ -204,7 +204,7 @@ class TestHousekeepingDevices:
assert living_room["state"] == "on"
assert "brightness" in living_room["attributes"]
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_get_device_returns_200(self, mock_get_ha, client):
"""Get device should return 200 for existing device."""
mock_client = AsyncMock()
@@ -222,7 +222,7 @@ class TestHousekeepingDevices:
response = client.get("/housekeeping/devices/light.living_room")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_get_device_returns_404_for_missing(self, mock_get_ha, client):
"""Get device should return 404 for non-existent device."""
mock_client = AsyncMock()
@@ -239,7 +239,7 @@ class TestHousekeepingDevices:
class TestHousekeepingAreas:
"""Test /housekeeping/areas endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_list_areas_returns_200(self, mock_get_ha, client):
"""List areas should return 200."""
mock_client = AsyncMock()
@@ -252,7 +252,7 @@ class TestHousekeepingAreas:
response = client.get("/housekeeping/areas")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_list_areas_returns_area_data(self, mock_get_ha, client):
"""List areas should return area id and name."""
mock_client = AsyncMock()
@@ -274,7 +274,7 @@ class TestHousekeepingAreas:
class TestHousekeepingScenes:
"""Test /housekeeping/scenes endpoints."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_list_scenes_returns_200(self, mock_get_ha, client, sample_states):
"""List scenes should return 200."""
mock_client = AsyncMock()
@@ -284,7 +284,7 @@ class TestHousekeepingScenes:
response = client.get("/housekeeping/scenes")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_list_scenes_returns_only_scenes(self, mock_get_ha, client, sample_states):
"""List scenes should only return scene entities."""
mock_client = AsyncMock()
@@ -303,7 +303,7 @@ class TestHousekeepingScenes:
class TestHousekeepingScripts:
"""Test /housekeeping/scripts endpoints."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_list_scripts_returns_200(self, mock_get_ha, client, sample_states):
"""List scripts should return 200."""
mock_client = AsyncMock()
@@ -313,7 +313,7 @@ class TestHousekeepingScripts:
response = client.get("/housekeeping/scripts")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_list_scripts_returns_only_scripts(self, mock_get_ha, client, sample_states):
"""List scripts should only return script entities."""
mock_client = AsyncMock()
@@ -331,7 +331,7 @@ class TestHousekeepingScripts:
class TestHousekeepingAutomations:
"""Test /housekeeping/automations endpoints."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_list_automations_returns_200(self, mock_get_ha, client, sample_states):
"""List automations should return 200."""
mock_client = AsyncMock()
@@ -341,7 +341,7 @@ class TestHousekeepingAutomations:
response = client.get("/housekeeping/automations")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_list_automations_includes_enabled_status(self, mock_get_ha, client, sample_states):
"""List automations should include enabled status."""
mock_client = AsyncMock()
@@ -360,7 +360,7 @@ class TestHousekeepingAutomations:
class TestHousekeepingHistory:
"""Test /housekeeping/history endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_history_returns_200(self, mock_get_ha, client):
"""History endpoint should return 200."""
mock_client = AsyncMock()
@@ -373,7 +373,7 @@ class TestHousekeepingHistory:
response = client.get("/housekeeping/history?entity_id=light.living_room")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_history_returns_entries(self, mock_get_ha, client):
"""History endpoint should return history entries."""
mock_client = AsyncMock()
@@ -391,13 +391,13 @@ class TestHousekeepingHistory:
assert data["entity_id"] == "light.living_room"
assert len(data["history"]) == 2
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_history_requires_entity_id(self, mock_get_ha, client):
"""History endpoint should require entity_id parameter."""
response = client.get("/housekeeping/history")
assert response.status_code == 422 # Validation error
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_history_validates_hours_range(self, mock_get_ha, client):
"""History endpoint should validate hours range (1-168)."""
mock_client = AsyncMock()
@@ -415,7 +415,7 @@ class TestHousekeepingHistory:
class TestHousekeepingDeviceControl:
"""Test /housekeeping/devices/{entity_id}/control endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_control_device_turn_on(self, mock_get_ha, client):
"""Control should turn on device."""
mock_client = AsyncMock()
@@ -433,7 +433,7 @@ class TestHousekeepingDeviceControl:
)
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_control_device_turn_off(self, mock_get_ha, client):
"""Control should turn off device."""
mock_client = AsyncMock()
@@ -451,7 +451,7 @@ class TestHousekeepingDeviceControl:
)
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_control_device_toggle(self, mock_get_ha, client):
"""Control should toggle device."""
mock_client = AsyncMock()
@@ -469,7 +469,7 @@ class TestHousekeepingDeviceControl:
)
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_control_device_with_brightness(self, mock_get_ha, client):
"""Control should set brightness."""
mock_client = AsyncMock()
@@ -488,7 +488,7 @@ class TestHousekeepingDeviceControl:
assert response.status_code == 200
mock_client.turn_on.assert_called_once()
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_control_device_returns_404_for_missing(self, mock_get_ha, client):
"""Control should return 404 for non-existent device."""
mock_client = AsyncMock()
@@ -501,7 +501,7 @@ class TestHousekeepingDeviceControl:
)
assert response.status_code == 404
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_control_device_returns_error_response(self, mock_get_ha, client):
"""Control should return proper error response."""
mock_client = AsyncMock()
@@ -523,7 +523,7 @@ class TestHousekeepingDeviceControl:
class TestHousekeepingSceneActivation:
"""Test /housekeeping/scenes/{scene_id}/activate endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_activate_scene_returns_200(self, mock_get_ha, client):
"""Activate scene should return 200."""
mock_client = AsyncMock()
@@ -533,7 +533,7 @@ class TestHousekeepingSceneActivation:
response = client.post("/housekeeping/scenes/scene.movie_night/activate")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_activate_scene_returns_success_response(self, mock_get_ha, client):
"""Activate scene should return success response."""
mock_client = AsyncMock()
@@ -546,7 +546,7 @@ class TestHousekeepingSceneActivation:
assert data["success"] is True
assert data["scene_id"] == "scene.movie_night"
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_activate_scene_handles_error(self, mock_get_ha, client):
"""Activate scene should handle errors."""
mock_client = AsyncMock()
@@ -560,7 +560,7 @@ class TestHousekeepingSceneActivation:
class TestHousekeepingScriptRun:
"""Test /housekeeping/scripts/{script_id}/run endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_run_script_returns_200(self, mock_get_ha, client):
"""Run script should return 200."""
mock_client = AsyncMock()
@@ -570,7 +570,7 @@ class TestHousekeepingScriptRun:
response = client.post("/housekeeping/scripts/script.bedtime/run")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_run_script_returns_success_response(self, mock_get_ha, client):
"""Run script should return success response."""
mock_client = AsyncMock()
@@ -583,7 +583,7 @@ class TestHousekeepingScriptRun:
assert data["success"] is True
assert data["script_id"] == "script.bedtime"
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_run_script_handles_error(self, mock_get_ha, client):
"""Run script should handle errors."""
mock_client = AsyncMock()
@@ -597,7 +597,7 @@ class TestHousekeepingScriptRun:
class TestHousekeepingAutomationToggle:
"""Test /housekeeping/automations/{automation_id}/toggle endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_toggle_automation_enable(self, mock_get_ha, client):
"""Toggle automation should enable when requested."""
mock_client = AsyncMock()
@@ -610,7 +610,7 @@ class TestHousekeepingAutomationToggle:
)
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_toggle_automation_disable(self, mock_get_ha, client):
"""Toggle automation should disable when requested."""
mock_client = AsyncMock()
@@ -624,7 +624,7 @@ class TestHousekeepingAutomationToggle:
assert response.status_code == 200
mock_client.disable_automation.assert_called_once()
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_toggle_automation_returns_new_state(self, mock_get_ha, client):
"""Toggle automation should return new enabled state."""
mock_client = AsyncMock()
@@ -641,7 +641,7 @@ class TestHousekeepingAutomationToggle:
assert data["automation_id"] == "automation.motion_lights"
assert data["enabled"] is True
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_toggle_automation_handles_error(self, mock_get_ha, client):
"""Toggle automation should handle errors."""
mock_client = AsyncMock()