269 lines
11 KiB
Python
269 lines
11 KiB
Python
"""Template operations mixin for TaskMateCoordinator."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
from .models import Chore, generate_id, optional_float
|
|
from .templates import BUILT_IN_IDS, BUILT_IN_TEMPLATES, TEMPLATE_CHORE_FIELDS
|
|
|
|
if TYPE_CHECKING:
|
|
pass
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
class TemplatesMixin:
|
|
"""Mixin providing template CRUD and application logic."""
|
|
|
|
def get_all_templates(self) -> list[dict]:
|
|
"""Return built-in + custom templates."""
|
|
custom = self.storage.get_custom_templates()
|
|
return list(BUILT_IN_TEMPLATES) + custom
|
|
|
|
def get_template(self, template_id: str) -> dict | None:
|
|
"""Return a single template by ID (built-in or custom)."""
|
|
for tpl in BUILT_IN_TEMPLATES:
|
|
if tpl["id"] == template_id:
|
|
return dict(tpl)
|
|
return self.storage.get_custom_template(template_id)
|
|
|
|
async def async_apply_template(self, chores: list[dict]) -> list[str]:
|
|
"""Create chores from a template's chore definitions. Returns created IDs."""
|
|
if not chores:
|
|
raise ValueError("Cannot apply template with no chores")
|
|
created_ids = []
|
|
for chore_def in chores:
|
|
chore = Chore(
|
|
name=chore_def.get("name", "Unnamed"),
|
|
points=chore_def.get("points", 10),
|
|
description=chore_def.get("description", ""),
|
|
assigned_to=list(chore_def.get("assigned_to", [])),
|
|
requires_approval=chore_def.get("requires_approval", False),
|
|
time_category=chore_def.get("time_category", "anytime"),
|
|
daily_limit=chore_def.get("daily_limit", 1),
|
|
completion_sound=chore_def.get("completion_sound", "coin"),
|
|
schedule_mode=chore_def.get("schedule_mode", "specific_days"),
|
|
due_days=list(chore_def.get("due_days", [])),
|
|
recurrence=chore_def.get("recurrence", "weekly"),
|
|
recurrence_day=chore_def.get("recurrence_day", ""),
|
|
recurrence_start=chore_def.get("recurrence_start", ""),
|
|
first_occurrence_mode=chore_def.get("first_occurrence_mode", "available_immediately"),
|
|
assignment_mode=chore_def.get("assignment_mode", "everyone"),
|
|
require_availability=chore_def.get("require_availability", False),
|
|
visibility_entity=chore_def.get("visibility_entity", ""),
|
|
visibility_state=chore_def.get("visibility_state", "on"),
|
|
visibility_operator=chore_def.get("visibility_operator", "equals"),
|
|
weather_entity=chore_def.get("weather_entity", ""),
|
|
weather_block_conditions=list(chore_def.get("weather_block_conditions", [])),
|
|
weather_temp_min=optional_float(chore_def.get("weather_temp_min")),
|
|
weather_temp_max=optional_float(chore_def.get("weather_temp_max")),
|
|
weather_wind_max=optional_float(chore_def.get("weather_wind_max")),
|
|
task_type=chore_def.get("task_type", "standard"),
|
|
timed_rate_points=chore_def.get("timed_rate_points", 10),
|
|
timed_rate_minutes=chore_def.get("timed_rate_minutes", 5),
|
|
timed_max_daily_minutes=chore_def.get("timed_max_daily_minutes", 0),
|
|
)
|
|
self.storage.add_chore(chore)
|
|
created_ids.append(chore.id)
|
|
await self.storage.async_save()
|
|
await self.async_refresh()
|
|
return created_ids
|
|
|
|
async def async_save_template_from_chores(
|
|
self, chore_ids: list[str], name: str, icon: str
|
|
) -> str:
|
|
"""Save existing chores as a custom template pack."""
|
|
if not chore_ids:
|
|
raise ValueError("At least one chore must be selected")
|
|
chores_data = []
|
|
for cid in chore_ids:
|
|
chore = self.storage.get_chore(cid)
|
|
if chore is None:
|
|
continue
|
|
chore_dict = {k: v for k, v in chore.to_dict().items() if k in TEMPLATE_CHORE_FIELDS}
|
|
chores_data.append(chore_dict)
|
|
if not chores_data:
|
|
raise ValueError("No valid chores found for selected IDs")
|
|
tpl_id = generate_id()
|
|
template = {
|
|
"id": tpl_id,
|
|
"name": name.strip(),
|
|
"icon": icon or "mdi:clipboard-list",
|
|
"builtin": False,
|
|
"chores": chores_data,
|
|
}
|
|
self.storage.add_custom_template(template)
|
|
await self.storage.async_save()
|
|
return tpl_id
|
|
|
|
async def async_create_template(
|
|
self, name: str, icon: str, chores: list[dict]
|
|
) -> str:
|
|
"""Create a new custom template from scratch."""
|
|
if not chores:
|
|
raise ValueError("Template must have at least one chore")
|
|
tpl_id = generate_id()
|
|
template = {
|
|
"id": tpl_id,
|
|
"name": name.strip(),
|
|
"icon": icon or "mdi:clipboard-list",
|
|
"builtin": False,
|
|
"chores": chores,
|
|
}
|
|
self.storage.add_custom_template(template)
|
|
await self.storage.async_save()
|
|
return tpl_id
|
|
|
|
async def async_update_template(
|
|
self, template_id: str, *, name: str | None = None, icon: str | None = None, chores: list[dict] | None = None
|
|
) -> None:
|
|
"""Update a custom template. Raises for built-in templates."""
|
|
if template_id in BUILT_IN_IDS:
|
|
raise ValueError(f"Cannot modify built-in template '{template_id}'")
|
|
updates = {}
|
|
if name is not None:
|
|
updates["name"] = name.strip()
|
|
if icon is not None:
|
|
updates["icon"] = icon
|
|
if chores is not None:
|
|
updates["chores"] = chores
|
|
self.storage.update_custom_template(template_id, updates)
|
|
await self.storage.async_save()
|
|
|
|
async def async_delete_template(self, template_id: str) -> None:
|
|
"""Delete a custom template. Raises for built-in templates."""
|
|
if template_id in BUILT_IN_IDS:
|
|
raise ValueError(f"Cannot delete built-in template '{template_id}'")
|
|
self.storage.remove_custom_template(template_id)
|
|
await self.storage.async_save()
|
|
|
|
# ── Pack import / export (#688) ──────────────────────────────────────
|
|
|
|
PACK_FORMAT = "taskmate.template-pack"
|
|
PACK_VERSION = 1
|
|
MAX_PACK_TEMPLATES = 50
|
|
MAX_PACK_CHORES = 200
|
|
|
|
def export_templates(self, template_ids: list[str] | None = None) -> dict:
|
|
"""Build a shareable pack from custom templates.
|
|
|
|
Built-ins are excluded: they ship with TaskMate, so exporting them
|
|
would just create duplicates on the other end.
|
|
"""
|
|
wanted = set(template_ids or [])
|
|
packed = []
|
|
for tpl in self.storage.get_custom_templates():
|
|
if wanted and tpl.get("id") not in wanted:
|
|
continue
|
|
packed.append({
|
|
"name": tpl.get("name", ""),
|
|
"icon": tpl.get("icon", "mdi:clipboard-list-outline"),
|
|
"chores": [
|
|
{k: v for k, v in chore.items() if k in TEMPLATE_CHORE_FIELDS}
|
|
for chore in tpl.get("chores", [])
|
|
],
|
|
})
|
|
|
|
from homeassistant.util import dt as dt_util
|
|
return {
|
|
"format": self.PACK_FORMAT,
|
|
"version": self.PACK_VERSION,
|
|
"exported_at": dt_util.now().isoformat(),
|
|
"templates": packed,
|
|
}
|
|
|
|
def _validate_pack(self, pack: dict) -> list[dict]:
|
|
"""Validate an untrusted pack, returning clean template dicts.
|
|
|
|
A pack is arbitrary user-supplied JSON, so everything is checked and
|
|
every unknown chore field is dropped rather than trusted — a shared
|
|
pack must not be able to set runtime state or fields the panel
|
|
wouldn't let you set by hand.
|
|
"""
|
|
if not isinstance(pack, dict):
|
|
raise ValueError("That doesn't look like a TaskMate pack")
|
|
if pack.get("format") != self.PACK_FORMAT:
|
|
raise ValueError("Not a TaskMate template pack")
|
|
try:
|
|
version = int(pack.get("version", 0))
|
|
except (TypeError, ValueError) as err:
|
|
raise ValueError("Pack version is not a number") from err
|
|
if version > self.PACK_VERSION:
|
|
raise ValueError(
|
|
f"This pack needs a newer TaskMate (pack version {version}, "
|
|
f"this one understands {self.PACK_VERSION})"
|
|
)
|
|
|
|
templates = pack.get("templates")
|
|
if not isinstance(templates, list) or not templates:
|
|
raise ValueError("Pack contains no templates")
|
|
if len(templates) > self.MAX_PACK_TEMPLATES:
|
|
raise ValueError(f"Pack has too many templates (max {self.MAX_PACK_TEMPLATES})")
|
|
|
|
clean: list[dict] = []
|
|
for entry in templates:
|
|
if not isinstance(entry, dict):
|
|
raise ValueError("Pack contains a malformed template")
|
|
name = str(entry.get("name", "")).strip()
|
|
if not name:
|
|
raise ValueError("A template in the pack has no name")
|
|
|
|
raw_chores = entry.get("chores")
|
|
if not isinstance(raw_chores, list) or not raw_chores:
|
|
raise ValueError(f"Template '{name}' has no chores")
|
|
if len(raw_chores) > self.MAX_PACK_CHORES:
|
|
raise ValueError(f"Template '{name}' has too many chores")
|
|
|
|
chores = []
|
|
for raw in raw_chores:
|
|
if not isinstance(raw, dict):
|
|
raise ValueError(f"Template '{name}' has a malformed chore")
|
|
chore_name = str(raw.get("name", "")).strip()
|
|
if not chore_name:
|
|
raise ValueError(f"A chore in '{name}' has no name")
|
|
# Whitelist: unknown keys are dropped, never carried through.
|
|
cleaned = {k: v for k, v in raw.items() if k in TEMPLATE_CHORE_FIELDS}
|
|
cleaned["name"] = chore_name[:200]
|
|
chores.append(cleaned)
|
|
|
|
clean.append({
|
|
"name": name[:120],
|
|
"icon": str(entry.get("icon", "") or "mdi:clipboard-list-outline"),
|
|
"chores": chores,
|
|
})
|
|
return clean
|
|
|
|
async def async_import_pack(self, pack: dict) -> dict:
|
|
"""Import a validated pack as custom templates. Returns a summary.
|
|
|
|
Names that already exist are suffixed rather than overwritten — an
|
|
import should never silently replace something the family built.
|
|
"""
|
|
clean = self._validate_pack(pack)
|
|
existing = {t.get("name", "") for t in self.storage.get_custom_templates()}
|
|
|
|
created = []
|
|
for tpl in clean:
|
|
name = tpl["name"]
|
|
if name in existing:
|
|
suffix = 2
|
|
while f"{name} ({suffix})" in existing:
|
|
suffix += 1
|
|
name = f"{name} ({suffix})"
|
|
existing.add(name)
|
|
|
|
record = {
|
|
"id": generate_id(),
|
|
"name": name,
|
|
"icon": tpl["icon"],
|
|
"builtin": False,
|
|
"chores": tpl["chores"],
|
|
}
|
|
self.storage.add_custom_template(record)
|
|
created.append({"id": record["id"], "name": name, "chores": len(tpl["chores"])})
|
|
|
|
await self.storage.async_save()
|
|
await self.async_refresh()
|
|
return {"imported": len(created), "templates": created}
|