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)