Update docs and screenshots
CI / backend (pull_request) Canceled after 0s
CI / shell (pull_request) Canceled after 0s
CI / frontend (pull_request) Canceled after 0s
CI / arm64-smoke (pull_request) Canceled after 0s
CI / backend (push) Canceled after 0s
CI / shell (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / arm64-smoke (push) Canceled after 0s

This commit is contained in:
2026-07-25 10:26:29 +02:00
parent d6cda77aaf
commit 1f110866f5
170 changed files with 24657 additions and 3 deletions
+1
View File
@@ -0,0 +1 @@
"""Patch persistence and validation services."""
+236
View File
@@ -0,0 +1,236 @@
from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.core.database import SessionLocal
from app.models.entities import FixtureDefinition, FixtureInstance
from app.models.schemas import PatchPayload, PatchValidationPayload
class PatchService:
def __init__(self, session_factory: async_sessionmaker[AsyncSession] = SessionLocal) -> None:
self._session_factory = session_factory
async def list_instances(self) -> list[dict[str, object]]:
async with self._session_factory() as session:
result = await session.execute(select(FixtureInstance).order_by(FixtureInstance.universe, FixtureInstance.start_address))
instances = result.scalars().all()
fixtures = await self._definitions_by_ids(session, {instance.definition_id for instance in instances})
return [self._serialize_instance(instance, fixtures.get(instance.definition_id)) for instance in instances]
async def validate(self, payload: PatchValidationPayload) -> dict[str, object]:
async with self._session_factory() as session:
definition = await session.get(FixtureDefinition, payload.definition_id)
if definition is None:
raise LookupError("Fixture not found")
mode = self._resolve_mode(definition, payload.mode_key)
if mode is None:
raise LookupError("Fixture mode not found")
channel_count = int(mode["channel_count"])
end_address = payload.start_address + channel_count - 1
conflicts: list[dict[str, object]] = []
if end_address > 512:
conflicts.append(
{
"type": "range",
"message": f"Patch {payload.start_address}-{end_address} overskrider DMX kanal 512.",
}
)
result = await session.execute(
select(FixtureInstance).where(FixtureInstance.universe == payload.universe).order_by(FixtureInstance.start_address)
)
instances = result.scalars().all()
fixtures = await self._definitions_by_ids(session, {instance.definition_id for instance in instances})
for instance in instances:
if payload.exclude_patch_id is not None and instance.id == payload.exclude_patch_id:
continue
instance_end = instance.start_address + instance.channel_count - 1
overlaps = not (instance_end < payload.start_address or instance.start_address > end_address)
if overlaps:
definition_for_instance = fixtures.get(instance.definition_id)
conflicts.append(
{
"type": "overlap",
"patch_id": instance.id,
"name": instance.name,
"range": f"{instance.start_address}-{instance_end}",
"fixture": self._definition_label(definition_for_instance),
"message": f"{instance.name} optager allerede {instance.start_address}-{instance_end}.",
}
)
occupied = sorted(
{
address
for instance in instances
if payload.exclude_patch_id is None or instance.id != payload.exclude_patch_id
for address in range(instance.start_address, instance.start_address + instance.channel_count)
}
)
return {
"valid": not conflicts,
"range": f"{payload.start_address}-{end_address}",
"end_address": end_address,
"channel_count": channel_count,
"occupied_channels": occupied,
"conflicts": conflicts,
}
async def create_instance(self, payload: PatchPayload) -> dict[str, object]:
validation = await self.validate(
PatchValidationPayload(
universe=payload.universe,
definition_id=payload.definition_id,
mode_key=payload.mode_key,
start_address=payload.start_address,
enabled=payload.enabled,
)
)
if not validation["valid"]:
raise ValueError("Patch overlapper eller overskrider kanalrangen")
async with self._session_factory() as session:
definition = await session.get(FixtureDefinition, payload.definition_id)
if definition is None:
raise LookupError("Fixture not found")
mode = self._resolve_mode(definition, payload.mode_key)
if mode is None:
raise LookupError("Fixture mode not found")
instance = FixtureInstance(
universe=payload.universe,
name=payload.name,
definition_id=payload.definition_id,
mode_key=payload.mode_key,
start_address=payload.start_address,
channel_count=int(mode["channel_count"]),
enabled=payload.enabled,
group_names=self._sanitize_groups(payload.group_names),
position=payload.position.model_dump(),
tags=[],
)
session.add(instance)
await session.commit()
await session.refresh(instance)
return self._serialize_instance(instance, definition)
async def update_instance(self, patch_id: int, payload: PatchPayload) -> dict[str, object]:
validation = await self.validate(
PatchValidationPayload(
universe=payload.universe,
definition_id=payload.definition_id,
mode_key=payload.mode_key,
start_address=payload.start_address,
enabled=payload.enabled,
exclude_patch_id=patch_id,
)
)
if not validation["valid"]:
raise ValueError("Patch overlapper eller overskrider kanalrangen")
async with self._session_factory() as session:
instance = await session.get(FixtureInstance, patch_id)
if instance is None:
raise LookupError("Patch not found")
definition = await session.get(FixtureDefinition, payload.definition_id)
if definition is None:
raise LookupError("Fixture not found")
mode = self._resolve_mode(definition, payload.mode_key)
if mode is None:
raise LookupError("Fixture mode not found")
instance.universe = payload.universe
instance.name = payload.name
instance.definition_id = payload.definition_id
instance.mode_key = payload.mode_key
instance.start_address = payload.start_address
instance.channel_count = int(mode["channel_count"])
instance.enabled = payload.enabled
instance.group_names = self._sanitize_groups(payload.group_names)
instance.position = payload.position.model_dump()
await session.commit()
await session.refresh(instance)
return self._serialize_instance(instance, definition)
async def delete_instance(self, patch_id: int) -> dict[str, object]:
async with self._session_factory() as session:
instance = await session.get(FixtureInstance, patch_id)
if instance is None:
raise LookupError("Patch not found")
deleted_name = instance.name
await session.delete(instance)
await session.commit()
return {"deleted": deleted_name, "id": patch_id}
async def _definitions_by_ids(self, session: AsyncSession, ids: set[int]) -> dict[int, FixtureDefinition]:
if not ids:
return {}
result = await session.execute(select(FixtureDefinition).where(FixtureDefinition.id.in_(ids)))
definitions = result.scalars().all()
return {definition.id: definition for definition in definitions}
def _resolve_mode(self, definition: FixtureDefinition, mode_key: str) -> dict[str, Any] | None:
modes = definition.normalized_data.get("modes", [])
if not isinstance(modes, list):
return None
for mode in modes:
if isinstance(mode, dict) and str(mode.get("key")) == mode_key:
return mode
return None
def _serialize_instance(
self,
instance: FixtureInstance,
definition: FixtureDefinition | None,
) -> dict[str, object]:
end_address = instance.start_address + instance.channel_count - 1
mode = self._resolve_mode(definition, instance.mode_key) if definition is not None else None
return {
"id": instance.id,
"universe": instance.universe,
"name": instance.name,
"definition_id": instance.definition_id,
"fixture_slug": definition.slug if definition is not None else None,
"manufacturer": definition.manufacturer if definition is not None else None,
"model": definition.model if definition is not None else None,
"mode_key": instance.mode_key,
"start_address": instance.start_address,
"end_address": end_address,
"channel_count": instance.channel_count,
"enabled": instance.enabled,
"group_names": self._sanitize_groups(instance.group_names),
"position": self._serialize_position(instance.position),
"channels": mode.get("channels", []) if isinstance(mode, dict) else [],
}
def _definition_label(self, definition: FixtureDefinition | None) -> str:
if definition is None:
return "Ukendt fixture"
return f"{definition.manufacturer} {definition.model}"
def _sanitize_groups(self, groups: list[str]) -> list[str]:
unique: list[str] = []
seen: set[str] = set()
for group in groups:
normalized = str(group).strip()
key = normalized.casefold()
if not normalized or key in seen:
continue
unique.append(normalized)
seen.add(key)
return unique
def _serialize_position(self, payload: object) -> dict[str, float]:
if not isinstance(payload, dict):
payload = {}
return {
"x": float(payload.get("x", 50.0)),
"y": float(payload.get("y", 50.0)),
"z": float(payload.get("z", 0.0)),
"rotation": float(payload.get("rotation", 0.0)),
}