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
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:
@@ -0,0 +1,2 @@
|
||||
"""Fixture import and normalization."""
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def normalize_fixture(
|
||||
payload: dict[str, object], manufacturer_key: str, fixture_key: str
|
||||
) -> dict[str, object]:
|
||||
modes = payload.get("modes", [])
|
||||
available_channels = payload.get("availableChannels", {})
|
||||
normalized_modes: list[dict[str, object]] = []
|
||||
for mode in modes if isinstance(modes, list) else []:
|
||||
channels = mode.get("channels", []) if isinstance(mode, dict) else []
|
||||
normalized_channels: list[dict[str, object]] = []
|
||||
for index, channel in enumerate(channels, start=1):
|
||||
if isinstance(channel, str):
|
||||
channel_definition = (
|
||||
available_channels.get(channel, {})
|
||||
if isinstance(available_channels, dict)
|
||||
else {}
|
||||
)
|
||||
capability = (
|
||||
channel_definition.get("capability")
|
||||
if isinstance(channel_definition, dict)
|
||||
else None
|
||||
)
|
||||
capabilities = (
|
||||
channel_definition.get("capabilities")
|
||||
if isinstance(channel_definition, dict)
|
||||
else None
|
||||
)
|
||||
channel_capabilities = (
|
||||
capabilities
|
||||
if isinstance(capabilities, list)
|
||||
else [capability]
|
||||
if capability is not None
|
||||
else []
|
||||
)
|
||||
capability_types = [
|
||||
str(item.get("type", "")).lower()
|
||||
for item in channel_capabilities
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
normalized_channels.append(
|
||||
{
|
||||
"index": index,
|
||||
"key": channel,
|
||||
"display_name": channel.replace("-", " ").title(),
|
||||
"precedence": (
|
||||
"htp"
|
||||
if "dim" in channel.lower() or "intensity" in capability_types
|
||||
else "ltp"
|
||||
),
|
||||
"resolution": 8,
|
||||
"capabilities": channel_capabilities,
|
||||
}
|
||||
)
|
||||
elif isinstance(channel, dict):
|
||||
key = str(
|
||||
channel.get("name", channel.get("key", channel.get("display_name", f"channel-{index}")))
|
||||
)
|
||||
capability = channel.get("capability")
|
||||
capabilities = channel.get("capabilities")
|
||||
channel_capabilities = (
|
||||
capabilities
|
||||
if isinstance(capabilities, list)
|
||||
else [capability]
|
||||
if capability is not None
|
||||
else []
|
||||
)
|
||||
normalized_channels.append(
|
||||
{
|
||||
"index": index,
|
||||
"key": key,
|
||||
"display_name": str(channel.get("display_name", key)),
|
||||
"precedence": str(
|
||||
channel.get(
|
||||
"precedence",
|
||||
"htp" if "dim" in key.lower() else "ltp",
|
||||
)
|
||||
).lower(),
|
||||
"resolution": int(channel.get("resolution", 8)),
|
||||
"capabilities": channel_capabilities,
|
||||
}
|
||||
)
|
||||
normalized_modes.append(
|
||||
{
|
||||
"key": (
|
||||
str(mode.get("name", mode.get("key", "default")))
|
||||
if isinstance(mode, dict)
|
||||
else "default"
|
||||
),
|
||||
"channel_count": len(normalized_channels),
|
||||
"channels": normalized_channels,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"manufacturer": manufacturer_key,
|
||||
"model": payload.get("name", fixture_key),
|
||||
"short_name": payload.get("shortName"),
|
||||
"categories": payload.get("categories", []),
|
||||
"modes": normalized_modes,
|
||||
"schema_version": payload.get("$schema", "unknown"),
|
||||
"source": {
|
||||
"manufacturer_key": manufacturer_key,
|
||||
"fixture_key": fixture_key,
|
||||
},
|
||||
"warnings": [],
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class OflClient:
|
||||
search_url = "https://open-fixture-library.org/api/v1/get-search-results"
|
||||
fixture_base = "https://open-fixture-library.org"
|
||||
|
||||
async def search(self, query: str) -> list[dict[str, object]]:
|
||||
fallback = self._search_local(query)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=6.0) as client:
|
||||
response = await client.post(
|
||||
self.search_url,
|
||||
json={
|
||||
"searchQuery": query,
|
||||
"manufacturersQuery": [],
|
||||
"categoriesQuery": [],
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
keys = response.json()
|
||||
return [{"fixture_key": key, "cached": False} for key in keys]
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
async def fetch_fixture(self, manufacturer_key: str, fixture_key: str) -> dict[str, object]:
|
||||
local_path = Path("test-data/fixtures") / f"{manufacturer_key}__{fixture_key}.json"
|
||||
if local_path.exists():
|
||||
return json.loads(local_path.read_text(encoding="utf-8"))
|
||||
|
||||
url = f"{self.fixture_base}/{manufacturer_key}/{fixture_key}.json"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def _search_local(self, query: str) -> list[dict[str, object]]:
|
||||
results: list[dict[str, object]] = []
|
||||
lowered = query.lower()
|
||||
for path in Path("test-data/fixtures").glob("*.json"):
|
||||
if lowered in path.stem.lower():
|
||||
results.append({"fixture_key": path.stem.replace("__", "/"), "cached": True})
|
||||
return results
|
||||
@@ -0,0 +1,289 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.fixtures.normalize import normalize_fixture
|
||||
from app.fixtures.ofl import OflClient
|
||||
from app.models.entities import FixtureDefinition, FixtureInstance, FixtureSource
|
||||
|
||||
|
||||
class FixtureService:
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: async_sessionmaker[AsyncSession] = SessionLocal,
|
||||
client: OflClient | None = None,
|
||||
) -> None:
|
||||
self._session_factory = session_factory
|
||||
self.client = client or OflClient()
|
||||
self.test_data_path = Path("test-data/fixtures")
|
||||
|
||||
async def search_ofl(self, query: str) -> list[dict[str, object]]:
|
||||
if not query.strip():
|
||||
return []
|
||||
return await self.client.search(query)
|
||||
|
||||
async def preview_ofl(self, manufacturer_key: str, fixture_key: str) -> dict[str, object]:
|
||||
fixture = await self.client.fetch_fixture(manufacturer_key, fixture_key)
|
||||
return normalize_fixture(fixture, manufacturer_key, fixture_key)
|
||||
|
||||
async def import_ofl(self, manufacturer_key: str, fixture_key: str) -> dict[str, object]:
|
||||
source_payload = await self.client.fetch_fixture(manufacturer_key, fixture_key)
|
||||
normalized = normalize_fixture(source_payload, manufacturer_key, fixture_key)
|
||||
return await self._upsert_fixture(normalized, source_payload)
|
||||
|
||||
async def import_payload(self, payload: dict[str, object]) -> dict[str, object]:
|
||||
normalized = self._normalize_import_payload(payload)
|
||||
return await self._upsert_fixture(normalized, payload)
|
||||
|
||||
async def list_fixtures(self) -> list[dict[str, object]]:
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(select(FixtureDefinition).order_by(FixtureDefinition.created_at, FixtureDefinition.id))
|
||||
definitions = result.scalars().all()
|
||||
return [self._serialize_definition(definition) for definition in definitions]
|
||||
|
||||
async def get_fixture(self, fixture_id: int) -> dict[str, object]:
|
||||
async with self._session_factory() as session:
|
||||
definition = await session.get(FixtureDefinition, fixture_id)
|
||||
if definition is None:
|
||||
raise LookupError("Fixture not found")
|
||||
return self._serialize_definition(definition)
|
||||
|
||||
async def update_fixture(self, fixture_id: int, payload: dict[str, object]) -> dict[str, object]:
|
||||
async with self._session_factory() as session:
|
||||
definition = await session.get(FixtureDefinition, fixture_id)
|
||||
if definition is None:
|
||||
raise LookupError("Fixture not found")
|
||||
|
||||
existing = dict(definition.normalized_data)
|
||||
merged = dict(existing)
|
||||
for key in ("manufacturer", "model", "short_name", "categories", "modes", "schema_version", "source", "warnings"):
|
||||
if key in payload:
|
||||
merged[key] = payload[key]
|
||||
|
||||
definition.manufacturer = str(merged.get("manufacturer", definition.manufacturer))
|
||||
definition.model = str(merged.get("model", definition.model))
|
||||
definition.short_name = self._optional_str(merged.get("short_name"))
|
||||
definition.categories = self._string_list(merged.get("categories", []))
|
||||
definition.normalized_data = merged
|
||||
await session.commit()
|
||||
await session.refresh(definition)
|
||||
return self._serialize_definition(definition)
|
||||
|
||||
async def delete_fixture(self, fixture_id: int) -> None:
|
||||
async with self._session_factory() as session:
|
||||
definition = await session.get(FixtureDefinition, fixture_id)
|
||||
if definition is None:
|
||||
raise LookupError("Fixture not found")
|
||||
|
||||
await session.execute(delete(FixtureInstance).where(FixtureInstance.definition_id == fixture_id))
|
||||
source_id = definition.source_id
|
||||
await session.delete(definition)
|
||||
await session.flush()
|
||||
if source_id is not None:
|
||||
result = await session.execute(
|
||||
select(FixtureDefinition).where(
|
||||
FixtureDefinition.source_id == source_id,
|
||||
FixtureDefinition.id != fixture_id,
|
||||
).limit(1)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
source = await session.get(FixtureSource, source_id)
|
||||
if source is not None:
|
||||
await session.delete(source)
|
||||
await session.commit()
|
||||
|
||||
async def _upsert_fixture(
|
||||
self,
|
||||
normalized: dict[str, object],
|
||||
source_payload: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
source = normalized.get("source", {})
|
||||
manufacturer_key = str(source.get("manufacturer_key", "custom")) if isinstance(source, dict) else "custom"
|
||||
fixture_key = str(source.get("fixture_key", "custom")) if isinstance(source, dict) else "custom"
|
||||
slug = f"{manufacturer_key}/{fixture_key}"
|
||||
payload_hash = hashlib.sha256(json.dumps(source_payload, sort_keys=True).encode("utf-8")).hexdigest()
|
||||
schema_ref = str(normalized.get("schema_version", "unknown"))
|
||||
source_url = str(source_payload.get("oflURL", "")) if isinstance(source_payload, dict) else ""
|
||||
|
||||
async with self._session_factory() as session:
|
||||
fixture_source = await self._upsert_source(
|
||||
session,
|
||||
manufacturer_key=manufacturer_key,
|
||||
fixture_key=fixture_key,
|
||||
schema_ref=schema_ref,
|
||||
source_url=source_url,
|
||||
payload=source_payload,
|
||||
payload_hash=payload_hash,
|
||||
)
|
||||
|
||||
result = await session.execute(select(FixtureDefinition).where(FixtureDefinition.slug == slug))
|
||||
definition = result.scalar_one_or_none()
|
||||
if definition is None:
|
||||
definition = FixtureDefinition(
|
||||
slug=slug,
|
||||
manufacturer=str(normalized.get("manufacturer", manufacturer_key)),
|
||||
model=str(normalized.get("model", fixture_key)),
|
||||
short_name=self._optional_str(normalized.get("short_name")),
|
||||
categories=self._string_list(normalized.get("categories", [])),
|
||||
normalized_data=normalized,
|
||||
source_id=fixture_source.id,
|
||||
)
|
||||
session.add(definition)
|
||||
else:
|
||||
definition.manufacturer = str(normalized.get("manufacturer", definition.manufacturer))
|
||||
definition.model = str(normalized.get("model", definition.model))
|
||||
definition.short_name = self._optional_str(normalized.get("short_name"))
|
||||
definition.categories = self._string_list(normalized.get("categories", []))
|
||||
definition.normalized_data = normalized
|
||||
definition.source_id = fixture_source.id
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(definition)
|
||||
return self._serialize_definition(definition)
|
||||
|
||||
async def _upsert_source(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
manufacturer_key: str,
|
||||
fixture_key: str,
|
||||
schema_ref: str,
|
||||
source_url: str,
|
||||
payload: dict[str, object],
|
||||
payload_hash: str,
|
||||
) -> FixtureSource:
|
||||
result = await session.execute(
|
||||
select(FixtureSource).where(
|
||||
FixtureSource.manufacturer_key == manufacturer_key,
|
||||
FixtureSource.fixture_key == fixture_key,
|
||||
)
|
||||
)
|
||||
fixture_source = result.scalar_one_or_none()
|
||||
if fixture_source is None:
|
||||
fixture_source = FixtureSource(
|
||||
manufacturer_key=manufacturer_key,
|
||||
fixture_key=fixture_key,
|
||||
schema_ref=schema_ref,
|
||||
source_url=source_url,
|
||||
payload=payload,
|
||||
payload_hash=payload_hash,
|
||||
)
|
||||
session.add(fixture_source)
|
||||
await session.flush()
|
||||
return fixture_source
|
||||
|
||||
fixture_source.schema_ref = schema_ref
|
||||
fixture_source.source_url = source_url
|
||||
fixture_source.payload = payload
|
||||
fixture_source.payload_hash = payload_hash
|
||||
await session.flush()
|
||||
return fixture_source
|
||||
|
||||
def _serialize_definition(self, definition: FixtureDefinition) -> dict[str, object]:
|
||||
normalized = dict(definition.normalized_data)
|
||||
return {
|
||||
"id": definition.id,
|
||||
"slug": definition.slug,
|
||||
"manufacturer": definition.manufacturer,
|
||||
"model": definition.model,
|
||||
"short_name": definition.short_name,
|
||||
"categories": definition.categories,
|
||||
"modes": normalized.get("modes", []),
|
||||
"schema_version": normalized.get("schema_version", "unknown"),
|
||||
"source": normalized.get("source", {}),
|
||||
"warnings": normalized.get("warnings", []),
|
||||
}
|
||||
|
||||
def _normalize_import_payload(self, payload: dict[str, object]) -> dict[str, object]:
|
||||
if self._looks_like_ofl_fixture(payload):
|
||||
source_payload = payload.get("source", {})
|
||||
source = source_payload if isinstance(source_payload, dict) else {}
|
||||
manufacturer_key = str(source.get("manufacturer_key", payload.get("manufacturer_key", "custom")))
|
||||
fixture_key = str(
|
||||
source.get(
|
||||
"fixture_key",
|
||||
payload.get("fixture_key", str(payload.get("name", "custom-fixture")).lower().replace(" ", "-")),
|
||||
)
|
||||
)
|
||||
normalized = normalize_fixture(payload, manufacturer_key, fixture_key)
|
||||
manufacturer_name = payload.get("manufacturer")
|
||||
if manufacturer_name not in (None, ""):
|
||||
normalized["manufacturer"] = str(manufacturer_name)
|
||||
return normalized
|
||||
return self._normalize_custom_payload(payload)
|
||||
|
||||
def _normalize_custom_payload(self, payload: dict[str, object]) -> dict[str, object]:
|
||||
manufacturer = str(payload.get("manufacturer", "custom"))
|
||||
model = str(payload.get("model", payload.get("name", "Custom fixture")))
|
||||
source_payload = payload.get("source", {})
|
||||
source = source_payload if isinstance(source_payload, dict) else {}
|
||||
manufacturer_key = str(source.get("manufacturer_key", manufacturer.lower().replace(" ", "-")))
|
||||
fixture_key = str(source.get("fixture_key", model.lower().replace(" ", "-")))
|
||||
raw_modes = payload.get("modes", [])
|
||||
modes: list[dict[str, object]] = []
|
||||
for index, mode in enumerate(raw_modes if isinstance(raw_modes, list) else [], start=1):
|
||||
if not isinstance(mode, dict):
|
||||
continue
|
||||
raw_channels = mode.get("channels", [])
|
||||
channels: list[dict[str, object]] = []
|
||||
for channel_index, channel in enumerate(raw_channels if isinstance(raw_channels, list) else [], start=1):
|
||||
if isinstance(channel, dict):
|
||||
channels.append(
|
||||
{
|
||||
"index": int(channel.get("index", channel_index)),
|
||||
"key": str(channel.get("key", channel.get("display_name", f"channel-{channel_index}"))),
|
||||
"display_name": str(channel.get("display_name", channel.get("key", f"Channel {channel_index}"))),
|
||||
"precedence": str(channel.get("precedence", "ltp")).lower(),
|
||||
"resolution": int(channel.get("resolution", 8)),
|
||||
"capabilities": channel.get("capabilities", []),
|
||||
}
|
||||
)
|
||||
elif isinstance(channel, str):
|
||||
channels.append(
|
||||
{
|
||||
"index": channel_index,
|
||||
"key": channel,
|
||||
"display_name": channel,
|
||||
"precedence": "htp" if "dim" in channel.lower() else "ltp",
|
||||
"resolution": 8,
|
||||
"capabilities": [],
|
||||
}
|
||||
)
|
||||
modes.append(
|
||||
{
|
||||
"key": str(mode.get("key", mode.get("name", f"mode-{index}"))),
|
||||
"channel_count": int(mode.get("channel_count", len(channels))),
|
||||
"channels": channels,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"manufacturer": manufacturer,
|
||||
"model": model,
|
||||
"short_name": payload.get("short_name"),
|
||||
"categories": self._string_list(payload.get("categories", [])),
|
||||
"modes": modes,
|
||||
"schema_version": str(payload.get("schema_version", "custom")),
|
||||
"source": {
|
||||
"manufacturer_key": manufacturer_key,
|
||||
"fixture_key": fixture_key,
|
||||
},
|
||||
"warnings": payload.get("warnings", []),
|
||||
}
|
||||
|
||||
def _string_list(self, values: object) -> list[str]:
|
||||
if not isinstance(values, list):
|
||||
return []
|
||||
return [str(value) for value in values]
|
||||
|
||||
def _optional_str(self, value: object) -> str | None:
|
||||
return str(value) if value not in (None, "") else None
|
||||
|
||||
def _looks_like_ofl_fixture(self, payload: dict[str, object]) -> bool:
|
||||
return isinstance(payload.get("availableChannels"), dict) and isinstance(payload.get("modes"), list)
|
||||
Reference in New Issue
Block a user