New apps Added
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
"""Mandatory-chore detection, scheduling, and resolution (#532)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import time as dt_time
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_time_change,
|
||||
async_track_time_interval,
|
||||
)
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
NOTIF_TYPE_MANDATORY_PARENT_ALERT,
|
||||
NOTIF_TYPE_MANDATORY_REMINDER,
|
||||
)
|
||||
from .models import MandatoryMiss, parse_datetime
|
||||
|
||||
# How often the escalation ladder is re-evaluated (FEAT-6).
|
||||
_ESCALATION_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Assignment modes where every assigned child owes the chore. Rotation modes
|
||||
# point to a single current child via assignment_current_child_id.
|
||||
_SHARED_MODES = {"everyone", "first_come", "unassigned"}
|
||||
|
||||
|
||||
class MandatoryMixin:
|
||||
"""Detection + scheduling + resolution for mandatory chores."""
|
||||
|
||||
def _ensure_mandatory_state(self) -> None:
|
||||
if not hasattr(self, "mandatory_postpone"):
|
||||
self.mandatory_postpone = {}
|
||||
|
||||
def _effective_period_for(self, chore, child_id: str, day: date) -> str:
|
||||
"""The chore's period for this child/day, honoring any postpone override."""
|
||||
self._ensure_mandatory_state()
|
||||
key = f"{chore.id}:{child_id}:{day.isoformat()}"
|
||||
return self.mandatory_postpone.get(key) or (chore.time_category or "anytime")
|
||||
|
||||
def _mandatory_owers(self, chore) -> list[str]:
|
||||
"""Child IDs responsible for this chore right now."""
|
||||
mode = getattr(chore, "assignment_mode", "everyone")
|
||||
assigned = list(getattr(chore, "assigned_to", []) or [])
|
||||
if mode in _SHARED_MODES:
|
||||
if assigned:
|
||||
return assigned
|
||||
return [c.id for c in self.storage.get_children()]
|
||||
cur = getattr(chore, "assignment_current_child_id", "") or ""
|
||||
return [cur] if cur else []
|
||||
|
||||
def _child_completed_today(self, chore_id: str, child_id: str, day: date) -> bool:
|
||||
for comp in self.storage.get_completions():
|
||||
if comp.chore_id != chore_id or comp.child_id != child_id:
|
||||
continue
|
||||
if comp.bonus_subtask_id:
|
||||
continue
|
||||
if dt_util.as_local(comp.completed_at).date() == day:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def async_detect_mandatory_misses(self, period_id: str, day: date) -> int:
|
||||
"""Create misses for due+incomplete mandatory chores in `period_id`."""
|
||||
existing = {
|
||||
(m.chore_id, m.child_id, m.due_date)
|
||||
for m in self.storage.get_mandatory_misses()
|
||||
}
|
||||
created = 0
|
||||
for chore in self.storage.get_chores():
|
||||
if not getattr(chore, "mandatory", False):
|
||||
continue
|
||||
if not getattr(chore, "enabled", True):
|
||||
continue
|
||||
if not self._is_chore_scheduled_for_date(chore, day):
|
||||
continue
|
||||
for child_id in self._mandatory_owers(chore):
|
||||
if self._effective_period_for(chore, child_id, day) != period_id:
|
||||
continue
|
||||
if child_id in (getattr(chore, "disabled_for", []) or []):
|
||||
continue
|
||||
if self._child_completed_today(chore.id, child_id, day):
|
||||
continue
|
||||
if (chore.id, child_id, day.isoformat()) in existing:
|
||||
continue
|
||||
miss = MandatoryMiss(
|
||||
chore_id=chore.id,
|
||||
child_id=child_id,
|
||||
due_date=day.isoformat(),
|
||||
period_id=period_id,
|
||||
penalty_points=int(getattr(chore, "mandatory_penalty_points", 0) or 0),
|
||||
)
|
||||
self.storage.add_mandatory_miss(miss)
|
||||
created += 1
|
||||
self.hass.bus.async_fire("taskmate_mandatory_missed", {
|
||||
"miss_id": miss.id, "chore_id": chore.id, "child_id": child_id,
|
||||
"period_id": period_id, "penalty_points": miss.penalty_points,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
if created:
|
||||
await self.storage.async_save()
|
||||
await self.async_refresh()
|
||||
return created
|
||||
|
||||
# ---- resolution actions ------------------------------------------------
|
||||
|
||||
def _get_miss(self, miss_id: str) -> MandatoryMiss | None:
|
||||
for m in self.storage.get_mandatory_misses():
|
||||
if m.id == miss_id:
|
||||
return m
|
||||
return None
|
||||
|
||||
def _next_period_after(self, period_id: str, now: datetime) -> str | None:
|
||||
"""Next period whose end is still in the future today, by start order."""
|
||||
cur_now = now.time()
|
||||
for p in self.get_time_periods():
|
||||
try:
|
||||
eh, em = [int(x) for x in p["end"].split(":")]
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
if dt_time(eh, em) > cur_now and p["id"] != period_id:
|
||||
return p["id"]
|
||||
return None
|
||||
|
||||
async def async_apply_mandatory_penalty(self, miss_id: str) -> None:
|
||||
miss = self._get_miss(miss_id)
|
||||
if miss is None:
|
||||
return
|
||||
chore = next((c for c in self.storage.get_chores() if c.id == miss.chore_id), None)
|
||||
name = getattr(chore, "name", "chore")
|
||||
if miss.penalty_points > 0:
|
||||
await self.async_remove_points(
|
||||
miss.child_id, miss.penalty_points,
|
||||
reason=f"Penalty: {name} (missed mandatory)",
|
||||
)
|
||||
self.storage.remove_mandatory_miss(miss_id)
|
||||
await self.storage.async_save()
|
||||
self.hass.bus.async_fire("taskmate_mandatory_penalty_applied", {
|
||||
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id,
|
||||
"points": miss.penalty_points, "timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
await self.async_refresh()
|
||||
|
||||
async def async_postpone_mandatory_chore(self, miss_id: str) -> None:
|
||||
miss = self._get_miss(miss_id)
|
||||
if miss is None:
|
||||
return
|
||||
self._ensure_mandatory_state()
|
||||
now = dt_util.now()
|
||||
nxt = self._next_period_after(miss.period_id, now)
|
||||
if nxt:
|
||||
key = f"{miss.chore_id}:{miss.child_id}:{miss.due_date}"
|
||||
self.mandatory_postpone[key] = nxt
|
||||
# else: no window left today -> let normal scheduling resurface tomorrow
|
||||
self.storage.remove_mandatory_miss(miss_id)
|
||||
await self.storage.async_save()
|
||||
self.hass.bus.async_fire("taskmate_mandatory_postponed", {
|
||||
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id,
|
||||
"next_period": nxt or "", "timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
await self.async_refresh()
|
||||
|
||||
async def async_dismiss_mandatory_chore(self, miss_id: str) -> None:
|
||||
miss = self._get_miss(miss_id)
|
||||
if miss is None:
|
||||
return
|
||||
self.storage.remove_mandatory_miss(miss_id)
|
||||
await self.storage.async_save()
|
||||
self.hass.bus.async_fire("taskmate_mandatory_dismissed", {
|
||||
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
await self.async_refresh()
|
||||
|
||||
# ---- escalation (FEAT-6) ----------------------------------------------
|
||||
|
||||
async def async_escalate_mandatory_misses(self, now: datetime | None = None) -> int:
|
||||
"""Walk today's open mandatory misses and advance each escalation stage.
|
||||
|
||||
Ladder driven by minutes elapsed since the miss was created:
|
||||
stage 1 (nudge) immediately -> mandatory_reminder (child)
|
||||
stage 2 (reminder) after reminder_minutes -> mandatory_reminder
|
||||
stage 3 (parent alert) after parent_minutes -> mandatory_parent_alert
|
||||
|
||||
A miss whose chore has since been completed by that child is skipped (it
|
||||
will be resolved by the parent / next-day prune). Each notification is
|
||||
still gated by its own master switch + routes inside ``fire()``; stages
|
||||
advance regardless so a disabled type is not retro-fired when re-enabled.
|
||||
Returns the number of misses whose stage advanced.
|
||||
"""
|
||||
now = now or dt_util.now()
|
||||
today_iso = now.date().isoformat()
|
||||
reminder_minutes = self.storage.get_escalation_reminder_minutes()
|
||||
parent_minutes = self.storage.get_escalation_parent_minutes()
|
||||
chores = {c.id: c for c in self.storage.get_chores()}
|
||||
children = {c.id: c for c in self.storage.get_children()}
|
||||
advanced = 0
|
||||
|
||||
for miss in self.storage.get_mandatory_misses():
|
||||
if miss.due_date != today_iso:
|
||||
continue # only escalate same-day misses (skips midnight backfill)
|
||||
if self._child_completed_today(miss.chore_id, miss.child_id, now.date()):
|
||||
continue
|
||||
created = parse_datetime(miss.created_at)
|
||||
if created is None:
|
||||
continue
|
||||
elapsed_min = (now - created).total_seconds() / 60.0
|
||||
target = 1
|
||||
if elapsed_min >= reminder_minutes:
|
||||
target = 2
|
||||
if elapsed_min >= parent_minutes:
|
||||
target = 3
|
||||
if target <= miss.escalation_stage:
|
||||
continue
|
||||
|
||||
chore = chores.get(miss.chore_id)
|
||||
child = children.get(miss.child_id)
|
||||
ctx = {
|
||||
"child_id": miss.child_id,
|
||||
"child_name": getattr(child, "name", ""),
|
||||
"chore_name": getattr(chore, "name", "chore"),
|
||||
}
|
||||
for stage in range(miss.escalation_stage + 1, target + 1):
|
||||
if stage in (1, 2):
|
||||
await self.notifications.fire(
|
||||
NOTIF_TYPE_MANDATORY_REMINDER, ctx,
|
||||
only_recipients={f"child:{miss.child_id}"},
|
||||
)
|
||||
elif stage == 3:
|
||||
await self.notifications.fire(
|
||||
NOTIF_TYPE_MANDATORY_PARENT_ALERT, ctx,
|
||||
)
|
||||
miss.escalation_stage = target
|
||||
self.storage.update_mandatory_miss(miss)
|
||||
advanced += 1
|
||||
|
||||
if advanced:
|
||||
await self.storage.async_save()
|
||||
return advanced
|
||||
|
||||
# ---- scheduling --------------------------------------------------------
|
||||
|
||||
def _mandatory_period_end_times(self) -> list[tuple[int, int, str]]:
|
||||
"""Distinct (hour, minute, period_id) from each period's end time."""
|
||||
out: list[tuple[int, int, str]] = []
|
||||
for p in self.get_time_periods():
|
||||
try:
|
||||
h, m = [int(x) for x in p["end"].split(":")]
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
out.append((h, m, p["id"]))
|
||||
return out
|
||||
|
||||
def arm_mandatory_schedules(self) -> None:
|
||||
"""Register a callback at each period's end time + the escalation tick."""
|
||||
self._ensure_mandatory_state()
|
||||
self._unsub_mandatory = getattr(self, "_unsub_mandatory", [])
|
||||
for hour, minute, period_id in self._mandatory_period_end_times():
|
||||
unsub = async_track_time_change(
|
||||
self.hass,
|
||||
self._make_mandatory_period_cb(period_id),
|
||||
hour=hour, minute=minute, second=10,
|
||||
)
|
||||
self._unsub_mandatory.append(unsub)
|
||||
# Reminder escalation ladder (FEAT-6) — re-evaluate open misses on a tick.
|
||||
self._unsub_mandatory.append(
|
||||
async_track_time_interval(
|
||||
self.hass, self._escalation_tick, _ESCALATION_INTERVAL,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _escalation_tick(self, now: datetime) -> None:
|
||||
self.hass.async_create_task(self.async_escalate_mandatory_misses(now))
|
||||
|
||||
def _make_mandatory_period_cb(self, period_id: str):
|
||||
@callback
|
||||
def _cb(now: datetime) -> None:
|
||||
self.hass.async_create_task(
|
||||
self.async_detect_mandatory_misses(period_id, dt_util.now().date())
|
||||
)
|
||||
return _cb
|
||||
|
||||
def disarm_mandatory_schedules(self) -> None:
|
||||
for unsub in getattr(self, "_unsub_mandatory", []):
|
||||
unsub()
|
||||
self._unsub_mandatory = []
|
||||
|
||||
async def async_rearm_mandatory_schedules(self) -> None:
|
||||
"""Re-arm after the time_periods setting changes."""
|
||||
self.disarm_mandatory_schedules()
|
||||
self.arm_mandatory_schedules()
|
||||
|
||||
async def async_catchup_mandatory_misses(self) -> None:
|
||||
"""On startup, detect misses for any period that already ended today.
|
||||
|
||||
The period-end callbacks only fire going forward, so a restart after a
|
||||
boundary would otherwise miss that period for the rest of the day. The
|
||||
detector's existing-item guard keeps this idempotent.
|
||||
"""
|
||||
now = dt_util.now()
|
||||
today = now.date()
|
||||
cur = now.time()
|
||||
for hour, minute, period_id in self._mandatory_period_end_times():
|
||||
if dt_time(hour, minute) <= cur:
|
||||
await self.async_detect_mandatory_misses(period_id, today)
|
||||
|
||||
async def async_detect_anytime_mandatory_misses(self) -> None:
|
||||
"""Midnight: raise misses for incomplete mandatory 'anytime' chores for the
|
||||
day that just ended, then prune the postpone override map (new day starts clean)."""
|
||||
self._ensure_mandatory_state()
|
||||
yesterday = (dt_util.now() - timedelta(days=1)).date()
|
||||
await self.async_detect_mandatory_misses("anytime", yesterday)
|
||||
self.mandatory_postpone = {}
|
||||
|
||||
# ---- maintenance -------------------------------------------------------
|
||||
|
||||
async def async_prune_orphan_misses(self) -> int:
|
||||
"""Drop misses whose chore is gone, disabled, or no longer mandatory."""
|
||||
chores = {c.id: c for c in self.storage.get_chores()}
|
||||
removed = 0
|
||||
for m in self.storage.get_mandatory_misses():
|
||||
chore = chores.get(m.chore_id)
|
||||
if chore is None or not getattr(chore, "mandatory", False) or not getattr(chore, "enabled", True):
|
||||
self.storage.remove_mandatory_miss(m.id)
|
||||
removed += 1
|
||||
if removed:
|
||||
await self.storage.async_save()
|
||||
await self.async_refresh()
|
||||
return removed
|
||||
|
||||
def mandatory_misses_state(self) -> list[dict]:
|
||||
"""Misses enriched with chore/child display names for the UI."""
|
||||
chores = {c.id: c for c in self.storage.get_chores()}
|
||||
children = {c.id: c for c in self.storage.get_children()}
|
||||
out = []
|
||||
for m in self.storage.get_mandatory_misses():
|
||||
d = m.to_dict()
|
||||
d["chore_name"] = getattr(chores.get(m.chore_id), "name", "")
|
||||
d["child_name"] = getattr(children.get(m.child_id), "name", "")
|
||||
out.append(d)
|
||||
return out
|
||||
Reference in New Issue
Block a user