From cae3a0e4aa82f396421595a966d4e9cd11851687 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Sun, 10 May 2026 13:28:37 +0000 Subject: [PATCH 1/4] feat(notifications): add notify_service field to Child --- custom_components/taskmate/models.py | 3 +++ tests/test_models.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/custom_components/taskmate/models.py b/custom_components/taskmate/models.py index cea7238..1f37e12 100644 --- a/custom_components/taskmate/models.py +++ b/custom_components/taskmate/models.py @@ -143,6 +143,7 @@ class Child: unavailability_entity: str = "" # Second HA entity; _AVAILABLE_STATES = child is busy career_score: int = 0 total_penalties_received: int = 0 + notify_service: str | None = None id: str = field(default_factory=generate_id) @classmethod @@ -167,6 +168,7 @@ def from_dict(cls, data: dict[str, Any]) -> Child: unavailability_entity=data.get("unavailability_entity", ""), career_score=data.get("career_score", 0), total_penalties_received=data.get("total_penalties_received", 0), + notify_service=data.get("notify_service", None), id=data.get("id", generate_id()), ) @@ -191,6 +193,7 @@ def to_dict(self) -> dict[str, Any]: "unavailability_entity": self.unavailability_entity, "career_score": self.career_score, "total_penalties_received": self.total_penalties_received, + "notify_service": self.notify_service, "id": self.id, } diff --git a/tests/test_models.py b/tests/test_models.py index ea2fccd..59f7dec 100755 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -573,3 +573,22 @@ def test_legacy_missing_fields(self): pa = PoolAllocation.from_dict(legacy) assert pa.allocated_points == 0 assert pa.id == "legacy" + + +# --------------------------------------------------------------------------- +# Child notify_service field +# --------------------------------------------------------------------------- + + +def test_child_notify_service_round_trip(): + c = Child(name="Maria", notify_service="notify.mobile_app_marias_tablet") + assert c.notify_service == "notify.mobile_app_marias_tablet" + restored = Child.from_dict(c.to_dict()) + assert restored.notify_service == "notify.mobile_app_marias_tablet" + + +def test_child_notify_service_defaults_none(): + c = Child(name="Maria") + assert c.notify_service is None + restored = Child.from_dict(c.to_dict()) + assert restored.notify_service is None From db8470396d7cb6efd57410da0d1f4bdcb8cb8204 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Sun, 10 May 2026 13:31:24 +0000 Subject: [PATCH 2/4] feat(notifications): add notification dataclasses --- custom_components/taskmate/models.py | 113 +++++++++++++++++++++++++++ tests/test_models.py | 53 +++++++++++++ 2 files changed, 166 insertions(+) diff --git a/custom_components/taskmate/models.py b/custom_components/taskmate/models.py index 1f37e12..2259952 100644 --- a/custom_components/taskmate/models.py +++ b/custom_components/taskmate/models.py @@ -761,3 +761,116 @@ def to_dict(self) -> dict[str, Any]: "chore_ids": self.chore_ids, "id": self.id, } + + +@dataclass +class ParentRecipient: + """A configured parent notification target.""" + + name: str + notify_service: str + enabled: bool = True + id: str = field(default_factory=lambda: f"parent:{generate_id()}") + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ParentRecipient: + rid = data.get("id") or f"parent:{generate_id()}" + return cls( + name=data.get("name", ""), + notify_service=data.get("notify_service", ""), + enabled=bool(data.get("enabled", True)), + id=rid, + ) + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "notify_service": self.notify_service, + "enabled": self.enabled, + } + + +@dataclass +class NotificationRoute: + """Per-recipient notification settings inside a NotificationConfig.""" + + enabled: bool = False + time: str | None = None # "HH:MM" — only meaningful for per-recipient-time types + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> NotificationRoute: + return cls( + enabled=bool(data.get("enabled", False)), + time=data.get("time"), + ) + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"enabled": self.enabled} + if self.time is not None: + d["time"] = self.time + return d + + +@dataclass +class NotificationConfig: + """Stored configuration for one notification type.""" + + type_id: str + master_enabled: bool = False + routes: dict[str, NotificationRoute] = field(default_factory=dict) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> NotificationConfig: + raw_routes = data.get("routes", {}) or {} + return cls( + type_id=data.get("type_id", ""), + master_enabled=bool(data.get("master_enabled", False)), + routes={ + rid: NotificationRoute.from_dict(rdata) + for rid, rdata in raw_routes.items() + }, + ) + + def to_dict(self) -> dict[str, Any]: + return { + "type_id": self.type_id, + "master_enabled": self.master_enabled, + "routes": {rid: r.to_dict() for rid, r in self.routes.items()}, + } + + +@dataclass +class CustomNotification: + """Parent-defined scheduled reminder.""" + + name: str + message_template: str + time: str # "HH:MM" + day_mask: int = 0b1111111 # bit0=Mon … bit6=Sun + recipient_ids: list[str] = field(default_factory=list) + enabled: bool = True + id: str = field(default_factory=generate_id) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> CustomNotification: + return cls( + name=data.get("name", ""), + message_template=data.get("message_template", ""), + time=data.get("time", "20:00"), + day_mask=int(data.get("day_mask", 0b1111111)), + recipient_ids=list(data.get("recipient_ids", [])), + enabled=bool(data.get("enabled", True)), + id=data.get("id") or generate_id(), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "message_template": self.message_template, + "time": self.time, + "day_mask": self.day_mask, + "recipient_ids": list(self.recipient_ids), + "enabled": self.enabled, + } diff --git a/tests/test_models.py b/tests/test_models.py index 59f7dec..f584463 100755 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -592,3 +592,56 @@ def test_child_notify_service_defaults_none(): assert c.notify_service is None restored = Child.from_dict(c.to_dict()) assert restored.notify_service is None + + +# --------------------------------------------------------------------------- +# Notification dataclasses +# --------------------------------------------------------------------------- + + +def test_parent_recipient_round_trip(): + from custom_components.taskmate.models import ParentRecipient + p = ParentRecipient(name="John", notify_service="notify.mobile_app_johns_iphone") + assert p.id.startswith("parent:") + assert p.enabled is True + restored = ParentRecipient.from_dict(p.to_dict()) + assert restored.id == p.id + assert restored.name == "John" + assert restored.notify_service == "notify.mobile_app_johns_iphone" + + +def test_notification_route_round_trip(): + from custom_components.taskmate.models import NotificationRoute + r = NotificationRoute(enabled=True, time="19:30") + d = r.to_dict() + assert d == {"enabled": True, "time": "19:30"} + assert NotificationRoute.from_dict(d) == r + assert NotificationRoute.from_dict({"enabled": False}).time is None + + +def test_notification_config_round_trip(): + from custom_components.taskmate.models import NotificationConfig, NotificationRoute + cfg = NotificationConfig( + type_id="bedtime_reminder", + master_enabled=True, + routes={"child:abc": NotificationRoute(enabled=True, time="19:30")}, + ) + restored = NotificationConfig.from_dict(cfg.to_dict()) + assert restored.master_enabled is True + assert restored.routes["child:abc"].time == "19:30" + + +def test_custom_notification_round_trip(): + from custom_components.taskmate.models import CustomNotification + n = CustomNotification( + name="Brush teeth", + message_template="Time to brush, {child_name}!", + time="20:30", + day_mask=0b1111111, + recipient_ids=["child:abc", "parent:xyz"], + ) + assert n.id # uuid + restored = CustomNotification.from_dict(n.to_dict()) + assert restored.id == n.id + assert restored.day_mask == 0b1111111 + assert restored.recipient_ids == ["child:abc", "parent:xyz"] From 5656db7610eb6884633385728498638fba1f7391 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Sun, 10 May 2026 13:36:13 +0000 Subject: [PATCH 3/4] feat(notifications): storage CRUD + first-run migration --- custom_components/taskmate/storage.py | 125 +++++++++++++++++++++++++- tests/test_notifications_storage.py | 99 ++++++++++++++++++++ 2 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 tests/test_notifications_storage.py diff --git a/custom_components/taskmate/storage.py b/custom_components/taskmate/storage.py index aab1c7f..156252d 100644 --- a/custom_components/taskmate/storage.py +++ b/custom_components/taskmate/storage.py @@ -9,7 +9,12 @@ from homeassistant.helpers.storage import Store from .const import DOMAIN -from .models import Badge, AwardedBadge, Bonus, Child, Chore, ChoreCompletion, Penalty, PoolAllocation, Reward, RewardClaim, PointsTransaction, TaskGroup, TimedSession +from .models import ( + Badge, AwardedBadge, Bonus, Child, Chore, ChoreCompletion, + CustomNotification, NotificationConfig, NotificationRoute, ParentRecipient, + Penalty, PoolAllocation, Reward, RewardClaim, PointsTransaction, + TaskGroup, TimedSession, +) _LOGGER = logging.getLogger(__name__) @@ -85,6 +90,17 @@ async def async_load(self) -> dict[str, Any]: if "chore_display_order" not in self._data: self._data["chore_display_order"] = [] + # Notifications overhaul (v3.9.0) + if "parent_recipients" not in self._data: + self._data["parent_recipients"] = [] + if "notification_config" not in self._data: + self._data["notification_config"] = {} + if "custom_notifications" not in self._data: + self._data["custom_notifications"] = [] + if "notifications_migration_done" not in self._data: + self._run_notifications_migration() + self._data["notifications_migration_done"] = True + # Badge migration / seeding self._seed_builtin_badges(is_fresh=is_fresh) @@ -561,6 +577,113 @@ def _seed_builtin_badges(self, *, is_fresh: bool) -> None: if is_fresh: self._data["badges_backfill_pending"] = True + def _run_notifications_migration(self) -> None: + """Seed parent_recipients + notification_config from legacy notify_service. + + Idempotent: a guard flag is written by the caller in async_load. + """ + legacy = (self._data.get("settings", {}) or {}).get("notify_service", "") + parents: list[dict] = [] + existing_parents = self._data.setdefault("parent_recipients", []) + existing_services = {r.get("notify_service") for r in existing_parents} + if legacy and legacy not in existing_services: + seeded = ParentRecipient(name="Parent", notify_service=legacy) + parents.append(seeded.to_dict()) + existing_parents.extend(parents) + + # Defaults: previously-active types ON (preserves existing behaviour); + # new types OFF (no surprise pings on upgrade). + defaults_on = {"pending_chore_approval", "pending_reward_claim", "badge_earned"} + defaults_off = {"bedtime_reminder", "streak_at_risk", "all_chores_done"} + nc = self._data.setdefault("notification_config", {}) + seeded_parent_id = parents[0]["id"] if parents else None + + for tid in defaults_on: + cfg = NotificationConfig( + type_id=tid, + master_enabled=True, + routes={ + seeded_parent_id: NotificationRoute(enabled=True) + } if seeded_parent_id else {}, + ) + nc[tid] = cfg.to_dict() + + for tid in defaults_off: + nc[tid] = NotificationConfig(type_id=tid, master_enabled=False).to_dict() + + # --- parent recipients --- + def get_parent_recipients(self) -> list[ParentRecipient]: + return [ParentRecipient.from_dict(d) for d in self._data.get("parent_recipients", [])] + + def upsert_parent_recipient(self, p: ParentRecipient) -> None: + rows = self._data.setdefault("parent_recipients", []) + for i, row in enumerate(rows): + if row.get("id") == p.id: + rows[i] = p.to_dict() + return + rows.append(p.to_dict()) + + def delete_parent_recipient(self, parent_id: str) -> None: + self._data["parent_recipients"] = [ + r for r in self._data.get("parent_recipients", []) + if r.get("id") != parent_id + ] + + # --- notification config --- + def get_notification_config(self, type_id: str) -> NotificationConfig: + raw = (self._data.get("notification_config", {}) or {}).get(type_id) + if not raw: + return NotificationConfig(type_id=type_id) + return NotificationConfig.from_dict(raw) + + def set_notification_master(self, type_id: str, enabled: bool) -> None: + cfg = self.get_notification_config(type_id) + cfg.master_enabled = enabled + self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict() + + def set_notification_route( + self, type_id: str, recipient_id: str, route: NotificationRoute + ) -> None: + cfg = self.get_notification_config(type_id) + cfg.routes[recipient_id] = route + self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict() + + def get_all_notification_configs(self) -> dict[str, NotificationConfig]: + return { + tid: NotificationConfig.from_dict(raw) + for tid, raw in (self._data.get("notification_config", {}) or {}).items() + } + + # --- custom notifications --- + def get_custom_notifications(self) -> list[CustomNotification]: + return [ + CustomNotification.from_dict(d) + for d in self._data.get("custom_notifications", []) + ] + + def upsert_custom_notification(self, n: CustomNotification) -> None: + rows = self._data.setdefault("custom_notifications", []) + for i, row in enumerate(rows): + if row.get("id") == n.id: + rows[i] = n.to_dict() + return + rows.append(n.to_dict()) + + def delete_custom_notification(self, custom_id: str) -> None: + self._data["custom_notifications"] = [ + r for r in self._data.get("custom_notifications", []) + if r.get("id") != custom_id + ] + + # --- streak-at-risk cutoff --- + def get_streak_at_risk_cutoff(self) -> str: + return (self._data.get("settings", {}) or {}).get( + "streak_at_risk_cutoff_time", "20:00" + ) + + def set_streak_at_risk_cutoff(self, hhmm: str) -> None: + self._data.setdefault("settings", {})["streak_at_risk_cutoff_time"] = hhmm + # Task groups management def get_task_groups(self) -> list[TaskGroup]: """Get all task groups.""" diff --git a/tests/test_notifications_storage.py b/tests/test_notifications_storage.py new file mode 100644 index 0000000..6371d23 --- /dev/null +++ b/tests/test_notifications_storage.py @@ -0,0 +1,99 @@ +"""Tests for notification storage migration + CRUD.""" +from __future__ import annotations + +import pytest + +from custom_components.taskmate.models import ( + CustomNotification, NotificationConfig, NotificationRoute, ParentRecipient, +) +from custom_components.taskmate.storage import TaskMateStorage + + +@pytest.fixture +async def storage(hass): + s = TaskMateStorage(hass, "test") + await s.async_load() + return s + + +@pytest.mark.asyncio +async def test_fresh_install_seeds_empty_lists(storage): + assert storage.get_parent_recipients() == [] + assert storage.get_custom_notifications() == [] + # No legacy notify_service: defaults all-off for unmigrated types + cfg = storage.get_notification_config("bedtime_reminder") + assert cfg.master_enabled is False + assert cfg.routes == {} + + +@pytest.mark.asyncio +async def test_migration_seeds_parent_from_legacy_notify_service(hass): + s = TaskMateStorage(hass, "legacy") + s._data = {"settings": {"notify_service": "notify.mobile_app_johns_iphone"}} + s._run_notifications_migration() + parents = s.get_parent_recipients() + assert len(parents) == 1 + assert parents[0].notify_service == "notify.mobile_app_johns_iphone" + assert parents[0].name == "Parent" + + # Pending-approval / reward-claim / badge defaults: master_enabled=True, parent enabled + pid = parents[0].id + for tid in ("pending_chore_approval", "pending_reward_claim", "badge_earned"): + cfg = s.get_notification_config(tid) + assert cfg.master_enabled is True, tid + assert cfg.routes[pid].enabled is True, tid + + # New types stay off + for tid in ("bedtime_reminder", "streak_at_risk", "all_chores_done"): + cfg = s.get_notification_config(tid) + assert cfg.master_enabled is False, tid + + +@pytest.mark.asyncio +async def test_migration_idempotent(hass): + s = TaskMateStorage(hass, "idempotent") + s._data = {"settings": {"notify_service": "notify.foo"}} + s._run_notifications_migration() + s._run_notifications_migration() # second run no-op + assert len(s.get_parent_recipients()) == 1 + + +@pytest.mark.asyncio +async def test_parent_crud(storage): + p = ParentRecipient(name="Lisa", notify_service="notify.lisas_phone") + storage.upsert_parent_recipient(p) + assert storage.get_parent_recipients()[0].name == "Lisa" + + p.name = "Lisa Mac" + storage.upsert_parent_recipient(p) + assert storage.get_parent_recipients()[0].name == "Lisa Mac" + + storage.delete_parent_recipient(p.id) + assert storage.get_parent_recipients() == [] + + +@pytest.mark.asyncio +async def test_notification_config_set_route(storage): + storage.set_notification_route( + "bedtime_reminder", + "child:abc", + NotificationRoute(enabled=True, time="19:30"), + ) + cfg = storage.get_notification_config("bedtime_reminder") + assert cfg.routes["child:abc"].time == "19:30" + assert cfg.routes["child:abc"].enabled is True + + +@pytest.mark.asyncio +async def test_custom_notification_crud(storage): + n = CustomNotification( + name="Brush teeth", + message_template="Brush, {child_name}!", + time="20:30", + recipient_ids=["child:abc"], + ) + storage.upsert_custom_notification(n) + assert len(storage.get_custom_notifications()) == 1 + + storage.delete_custom_notification(n.id) + assert storage.get_custom_notifications() == [] From d0a97efff6062bbff45a4a1f9ccda0199d56d97a Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Sun, 10 May 2026 13:42:37 +0000 Subject: [PATCH 4/4] feat(notifications): NotificationCoordinator with dispatch + bus events --- custom_components/taskmate/const.py | 8 + .../taskmate/coord_notifications.py | 164 ++++++++++++++++++ custom_components/taskmate/coordinator.py | 2 + tests/test_notifications_dispatch.py | 106 +++++++++++ 4 files changed, 280 insertions(+) create mode 100644 custom_components/taskmate/coord_notifications.py create mode 100644 tests/test_notifications_dispatch.py diff --git a/custom_components/taskmate/const.py b/custom_components/taskmate/const.py index 797b5bd..21fc8f0 100644 --- a/custom_components/taskmate/const.py +++ b/custom_components/taskmate/const.py @@ -334,3 +334,11 @@ # Chore keys (sound-related) CONF_CHORE_COMPLETION_SOUND: Final = "completion_sound" + +# --- Notification type IDs (v3.9.0) --- +NOTIF_TYPE_BEDTIME_REMINDER: Final = "bedtime_reminder" +NOTIF_TYPE_STREAK_AT_RISK: Final = "streak_at_risk" +NOTIF_TYPE_ALL_CHORES_DONE: Final = "all_chores_done" +NOTIF_TYPE_BADGE_EARNED: Final = "badge_earned" +NOTIF_TYPE_PENDING_CHORE_APPROVAL: Final = "pending_chore_approval" +NOTIF_TYPE_PENDING_REWARD_CLAIM: Final = "pending_reward_claim" diff --git a/custom_components/taskmate/coord_notifications.py b/custom_components/taskmate/coord_notifications.py new file mode 100644 index 0000000..2a48854 --- /dev/null +++ b/custom_components/taskmate/coord_notifications.py @@ -0,0 +1,164 @@ +"""Central notification dispatcher and scheduler. + +All TaskMate notifications flow through this module. It owns: + * The static NOTIFICATION_TYPES registry (built-in metadata) + * `fire(type_id, context)` — the public dispatch entry point + * Scheduled callbacks for time-gated types (bedtime, streak-at-risk, custom) + * The mobile_app_notification_action listener for tap-to-approve + +Other coordinators MUST NOT call notify.* / persistent_notification directly +once this module is in place. They call self.notifications.fire(...). +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +from homeassistant.core import HomeAssistant + +from .const import ( + NOTIF_TYPE_ALL_CHORES_DONE, + NOTIF_TYPE_BADGE_EARNED, + NOTIF_TYPE_BEDTIME_REMINDER, + NOTIF_TYPE_PENDING_CHORE_APPROVAL, + NOTIF_TYPE_PENDING_REWARD_CLAIM, + NOTIF_TYPE_STREAK_AT_RISK, +) +from .models import NotificationRoute + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class NotificationTypeMeta: + id: str + audience: str # "child" | "parent" | "both" + time_gated: bool # has its own scheduled callback + per_recipient_time: bool # if True, route.time controls the schedule per recipient + actionable: bool # carries Approve/Reject mobile actions + default_enabled: bool # default master_enabled state at install + + +NOTIFICATION_TYPES: list[NotificationTypeMeta] = [ + NotificationTypeMeta(NOTIF_TYPE_BEDTIME_REMINDER, "child", True, True, False, False), + NotificationTypeMeta(NOTIF_TYPE_STREAK_AT_RISK, "child", True, False, False, False), + NotificationTypeMeta(NOTIF_TYPE_ALL_CHORES_DONE, "both", False, False, False, False), + NotificationTypeMeta(NOTIF_TYPE_BADGE_EARNED, "both", False, False, False, True), + NotificationTypeMeta(NOTIF_TYPE_PENDING_CHORE_APPROVAL, "parent", False, False, True, True), + NotificationTypeMeta(NOTIF_TYPE_PENDING_REWARD_CLAIM, "parent", False, False, True, True), +] + +NOTIFICATION_TYPES_BY_ID: dict[str, NotificationTypeMeta] = { + t.id: t for t in NOTIFICATION_TYPES +} + + +class _SafeDict(dict): + """str.format_map dict that leaves missing keys as `{key}` literal.""" + def __missing__(self, key: str) -> str: + return "{" + key + "}" + + +class NotificationCoordinator: + """Single dispatcher for all TaskMate notifications.""" + + def __init__(self, hass: HomeAssistant, storage) -> None: + self.hass = hass + self.storage = storage + self._scheduled_unsubs: list = [] # cancellation handles for time triggers + + async def fire(self, type_id: str, context: dict[str, Any]) -> None: + """Dispatch a notification of the given type with the given context.""" + meta = NOTIFICATION_TYPES_BY_ID.get(type_id) + if meta is None: + _LOGGER.warning("Unknown notification type %s", type_id) + return + + cfg = self.storage.get_notification_config(type_id) + if not cfg.master_enabled: + self._fire_bus_event(type_id, context, recipients=[]) + return + + recipients_fired: list[str] = [] + message = self._render_template(meta, context) + + for recipient_id, route in cfg.routes.items(): + if not route.enabled: + continue + notify_service = self._resolve_notify_service(recipient_id) + if not notify_service: + continue + await self._send_to(notify_service, message, meta, context) + recipients_fired.append(recipient_id) + + await self._fire_persistent_notification(type_id, message) + self._fire_bus_event(type_id, context, recipients_fired) + + def _resolve_notify_service(self, recipient_id: str) -> str: + if recipient_id.startswith("child:"): + child_id = recipient_id.split(":", 1)[1] + child = self.storage.get_child(child_id) + return child.notify_service or "" if child else "" + if recipient_id.startswith("parent:"): + for p in self.storage.get_parent_recipients(): + if p.id == recipient_id and p.enabled: + return p.notify_service + return "" + + def _render_template(self, meta: "NotificationTypeMeta", context: dict[str, Any]) -> str: + # Built-in types use a baked-in default; will be replaced by translations + # in a later task. For now use a safe English fallback so dispatch works. + templates = { + NOTIF_TYPE_BEDTIME_REMINDER: "{child_name}, you still have chores to do before bedtime.", + NOTIF_TYPE_STREAK_AT_RISK: "{child_name}, complete a chore today to keep your {streak}-day streak!", + NOTIF_TYPE_ALL_CHORES_DONE: "{child_name} finished every chore today!", + NOTIF_TYPE_BADGE_EARNED: "{child_name} earned the {badge_name} badge!", + NOTIF_TYPE_PENDING_CHORE_APPROVAL: "{child_name} completed '{chore_name}' (+{points} {points_name}) — awaiting approval.", + NOTIF_TYPE_PENDING_REWARD_CLAIM: "{child_name} claimed '{reward_name}' ({cost} {points_name}) — awaiting approval.", + } + tpl = context.get("message_template") or templates.get(meta.id, "") + return tpl.format_map(_SafeDict(context)) + + async def _send_to( + self, notify_service: str, message: str, + meta: "NotificationTypeMeta", context: dict[str, Any], + ) -> None: + domain, service = ( + notify_service.split(".", 1) if "." in notify_service + else ("notify", notify_service) + ) + if domain != "notify": + _LOGGER.warning("notify_service must be notify.*, got %s", notify_service) + return + + data: dict[str, Any] = {"title": "TaskMate", "message": message} + if meta.actionable: + entry_id = context.get("entry_id") + if entry_id: + data["data"] = { + "actions": [ + {"action": f"TASKMATE_APPROVE_{entry_id}", "title": "Approve"}, + {"action": f"TASKMATE_REJECT_{entry_id}", "title": "Reject"}, + ] + } + try: + await self.hass.services.async_call(domain, service, data, blocking=False) + except Exception as err: # noqa: BLE001 + _LOGGER.warning("notify call failed for %s: %s", notify_service, err) + + async def _fire_persistent_notification(self, type_id: str, message: str) -> None: + await self.hass.services.async_call( + "persistent_notification", "create", + { + "title": "TaskMate", + "message": message, + "notification_id": f"taskmate_{type_id}", + }, + blocking=False, + ) + + def _fire_bus_event(self, type_id: str, context: dict[str, Any], recipients: list[str]) -> None: + payload = dict(context) + payload["recipients"] = recipients + self.hass.bus.async_fire(f"taskmate_{type_id}", payload) diff --git a/custom_components/taskmate/coordinator.py b/custom_components/taskmate/coordinator.py index c71ddf1..1b5c0b1 100644 --- a/custom_components/taskmate/coordinator.py +++ b/custom_components/taskmate/coordinator.py @@ -14,6 +14,7 @@ from .const import DOMAIN from .coord_assignments import AssignmentsMixin from .coord_badges import BadgeCoordinator +from .coord_notifications import NotificationCoordinator from .coord_calendar import CalendarMixin from .coord_chores import ChoresMixin from .coord_points import PointsMixin @@ -48,6 +49,7 @@ def __init__(self, hass: HomeAssistant, entry_id: str) -> None: ) self.storage = TaskMateStorage(hass, entry_id) self.badges = BadgeCoordinator(hass, self.storage, self) + self.notifications = NotificationCoordinator(hass, self.storage) self.entry_id = entry_id self._unsub_midnight: Callable[[], None] | None = None self._unsub_prune: Callable[[], None] | None = None diff --git a/tests/test_notifications_dispatch.py b/tests/test_notifications_dispatch.py new file mode 100644 index 0000000..87a51a7 --- /dev/null +++ b/tests/test_notifications_dispatch.py @@ -0,0 +1,106 @@ +"""Tests for NotificationCoordinator dispatch.""" +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from custom_components.taskmate.coord_notifications import ( + NOTIFICATION_TYPES_BY_ID, NotificationCoordinator, +) +from custom_components.taskmate.models import ( + Child, NotificationRoute, ParentRecipient, +) +from custom_components.taskmate.storage import TaskMateStorage + + +@pytest.fixture +async def coord(hass): + storage = TaskMateStorage(hass, "test") + await storage.async_load() + return NotificationCoordinator(hass, storage) + + +@pytest.mark.asyncio +async def test_fire_skips_when_master_disabled(coord, hass): + hass.services.async_call = AsyncMock() + bus_fire = AsyncMock() + hass.bus.async_fire = bus_fire + coord.storage.set_notification_master("badge_earned", False) + await coord.fire("badge_earned", {"child_name": "Maria", "badge_name": "Star"}) + hass.services.async_call.assert_not_called() + bus_fire.assert_called_once() + args = bus_fire.call_args[0] + assert args[0] == "taskmate_badge_earned" + assert args[1]["recipients"] == [] + + +@pytest.mark.asyncio +async def test_fire_routes_only_to_enabled_recipients(coord, hass): + hass.services.async_call = AsyncMock() + hass.bus.async_fire = AsyncMock() + + p1 = ParentRecipient(name="John", notify_service="notify.john") + p2 = ParentRecipient(name="Lisa", notify_service="notify.lisa") + coord.storage.upsert_parent_recipient(p1) + coord.storage.upsert_parent_recipient(p2) + coord.storage.set_notification_master("badge_earned", True) + coord.storage.set_notification_route("badge_earned", p1.id, NotificationRoute(enabled=True)) + coord.storage.set_notification_route("badge_earned", p2.id, NotificationRoute(enabled=False)) + + await coord.fire("badge_earned", {"child_name": "M", "badge_name": "Star"}) + + notify_calls = [ + c for c in hass.services.async_call.call_args_list + if c[0][0] == "notify" + ] + assert len(notify_calls) == 1 + assert notify_calls[0][0][1] == "john" + + +@pytest.mark.asyncio +async def test_fire_renders_template_safely_with_missing_key(coord, hass): + hass.services.async_call = AsyncMock() + hass.bus.async_fire = AsyncMock() + p = ParentRecipient(name="John", notify_service="notify.john") + coord.storage.upsert_parent_recipient(p) + coord.storage.set_notification_master("badge_earned", True) + coord.storage.set_notification_route("badge_earned", p.id, NotificationRoute(enabled=True)) + + # Missing badge_name key — should not raise; literal "{badge_name}" stays + await coord.fire("badge_earned", {"child_name": "M"}) + + msg = next( + c for c in hass.services.async_call.call_args_list + if c[0][0] == "notify" + )[0][2]["message"] + assert "{badge_name}" in msg + assert "M" in msg + + +@pytest.mark.asyncio +async def test_fire_emits_bus_event_with_recipients(coord, hass): + hass.services.async_call = AsyncMock() + bus_fire = AsyncMock() + hass.bus.async_fire = bus_fire + p = ParentRecipient(name="John", notify_service="notify.john") + coord.storage.upsert_parent_recipient(p) + coord.storage.set_notification_master("badge_earned", True) + coord.storage.set_notification_route("badge_earned", p.id, NotificationRoute(enabled=True)) + + await coord.fire("badge_earned", {"child_name": "M", "badge_name": "Star"}) + + bus_fire.assert_called_once() + name, payload = bus_fire.call_args[0] + assert name == "taskmate_badge_earned" + assert p.id in payload["recipients"] + + +@pytest.mark.asyncio +async def test_unknown_type_is_logged_and_ignored(coord, hass, caplog): + hass.services.async_call = AsyncMock() + hass.bus.async_fire = AsyncMock() + await coord.fire("not_a_real_type", {}) + hass.services.async_call.assert_not_called() + hass.bus.async_fire.assert_not_called() + assert "Unknown notification type" in caplog.text