125 lines
4.6 KiB
Python
125 lines
4.6 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from threading import RLock
|
|
|
|
from app.automations.models import AutomationProposal, ProposalStatus
|
|
|
|
|
|
class AutomationStore:
|
|
def __init__(self, root: str | Path) -> None:
|
|
self._root = Path(root).resolve()
|
|
self._root.mkdir(parents=True, exist_ok=True)
|
|
self._lock = RLock()
|
|
|
|
def create(self, proposal: AutomationProposal) -> AutomationProposal:
|
|
with self._lock:
|
|
target = self._target(proposal.proposal_id)
|
|
if target.exists():
|
|
raise ValueError("Automation-Vorschlag existiert bereits.")
|
|
self._persist(proposal)
|
|
return proposal
|
|
|
|
def list(self) -> list[AutomationProposal]:
|
|
with self._lock:
|
|
return [self._load(path) for path in sorted(self._root.glob("*.json"))]
|
|
|
|
def get(self, proposal_id: str) -> AutomationProposal:
|
|
with self._lock:
|
|
target = self._target(proposal_id)
|
|
if not target.exists():
|
|
raise KeyError("Automation-Vorschlag nicht gefunden.")
|
|
return self._load(target)
|
|
|
|
def decide(
|
|
self,
|
|
proposal_id: str,
|
|
status: ProposalStatus,
|
|
expected_revision: int,
|
|
) -> AutomationProposal:
|
|
if status is ProposalStatus.DRAFT:
|
|
raise ValueError("Entscheidung darf nicht auf draft gesetzt werden.")
|
|
with self._lock:
|
|
proposal = self.get(proposal_id)
|
|
if proposal.revision != expected_revision:
|
|
raise ValueError("Revision stimmt nicht mit dem aktuellen Vorschlag überein.")
|
|
if proposal.status is not ProposalStatus.DRAFT:
|
|
raise ValueError("Über den Vorschlag wurde bereits entschieden.")
|
|
updated = proposal.model_copy(
|
|
update={
|
|
"status": status,
|
|
"updated_at": datetime.now(timezone.utc),
|
|
"revision": proposal.revision + 1,
|
|
}
|
|
)
|
|
self._persist(updated)
|
|
return updated
|
|
|
|
def export_yaml(self, proposal_id: str) -> str:
|
|
proposal = self.get(proposal_id)
|
|
if proposal.status is not ProposalStatus.APPROVED:
|
|
raise ValueError("Nur freigegebene Vorschläge dürfen exportiert werden.")
|
|
trigger_lines = [
|
|
"trigger:",
|
|
" - platform: numeric_state",
|
|
f" entity_id: {proposal.trigger.entity_id}",
|
|
]
|
|
if proposal.trigger.above is not None:
|
|
trigger_lines.append(f" above: {proposal.trigger.above}")
|
|
if proposal.trigger.below is not None:
|
|
trigger_lines.append(f" below: {proposal.trigger.below}")
|
|
action_lines = [
|
|
"action:",
|
|
f" - service: {proposal.action.service}",
|
|
" target:",
|
|
f" entity_id: {proposal.action.entity_id}",
|
|
]
|
|
if proposal.action.data:
|
|
action_lines.append(" data:")
|
|
action_lines.extend(
|
|
f" {key}: {_yaml_scalar(value)}"
|
|
for key, value in sorted(proposal.action.data.items())
|
|
)
|
|
return "\n".join(
|
|
[
|
|
f"alias: {_yaml_scalar(proposal.alias)}",
|
|
f"description: {_yaml_scalar(proposal.description)}",
|
|
*trigger_lines,
|
|
*action_lines,
|
|
"mode: single",
|
|
"",
|
|
]
|
|
)
|
|
|
|
def _target(self, proposal_id: str) -> Path:
|
|
if len(proposal_id) != 32 or not proposal_id.isalnum():
|
|
raise ValueError("Ungültige proposal_id.")
|
|
return self._root / f"{proposal_id}.json"
|
|
|
|
def _persist(self, proposal: AutomationProposal) -> None:
|
|
target = self._target(proposal.proposal_id)
|
|
temporary = target.with_suffix(".json.tmp")
|
|
temporary.write_text(
|
|
json.dumps(proposal.model_dump(mode="json"), ensure_ascii=True, sort_keys=True) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
os.replace(temporary, target)
|
|
|
|
@staticmethod
|
|
def _load(path: Path) -> AutomationProposal:
|
|
try:
|
|
return AutomationProposal.model_validate_json(path.read_text(encoding="utf-8"))
|
|
except ValueError as exc:
|
|
raise ValueError(f"Ungültiger Automation-Vorschlag: {path.name}") from exc
|
|
|
|
|
|
def _yaml_scalar(value: str | int | float | bool) -> str:
|
|
if isinstance(value, bool):
|
|
return "true" if value else "false"
|
|
if isinstance(value, (int, float)):
|
|
return str(value)
|
|
return json.dumps(value, ensure_ascii=True)
|