46 files
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""Storage management for TaskMate integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -120,6 +121,15 @@ class TaskMateStorage:
|
||||
if "scheduled_changes" not in self._data:
|
||||
self._data["scheduled_changes"] = []
|
||||
|
||||
# Drop settled swap requests (#783) and ones pointing at a chore or child
|
||||
# that no longer exists (#785). Approval used to only flip a status flag,
|
||||
# and deletes did not cascade, so existing installs carry both kinds —
|
||||
# the orphans surface in the parent's approval queue as an unclearable
|
||||
# "? wants to swap ?" row. Runs on every load rather than behind a
|
||||
# one-shot flag: it is a cheap list filter, and it also sweeps up records
|
||||
# left by a downgrade to a version without the cascades.
|
||||
self._drop_dead_swap_requests()
|
||||
|
||||
# Notifications overhaul (v3.9.0)
|
||||
if "parent_recipients" not in self._data:
|
||||
self._data["parent_recipients"] = []
|
||||
@@ -182,8 +192,8 @@ class TaskMateStorage:
|
||||
self._data["_pool_semantics_version"] = 2
|
||||
if adjusted:
|
||||
_LOGGER.info(
|
||||
"TaskMate: migrated %d pool allocation(s) to beta2 semantics "
|
||||
"(points now deducted at allocation time)", adjusted
|
||||
"TaskMate: migrated %d pool allocation(s) to beta2 semantics (points now deducted at allocation time)",
|
||||
adjusted,
|
||||
)
|
||||
await self.async_save()
|
||||
|
||||
@@ -232,15 +242,13 @@ class TaskMateStorage:
|
||||
"Migrating chore '%s' assigned_to: '%s' -> '%s' (name to ID)",
|
||||
chore.get("name", "unknown"),
|
||||
assignment,
|
||||
name_to_id[assignment]
|
||||
name_to_id[assignment],
|
||||
)
|
||||
else:
|
||||
# Unknown value, keep it but log a warning
|
||||
new_assigned_to.append(assignment)
|
||||
_LOGGER.warning(
|
||||
"Chore '%s' has unknown assigned_to value: '%s'",
|
||||
chore.get("name", "unknown"),
|
||||
assignment
|
||||
"Chore '%s' has unknown assigned_to value: '%s'", chore.get("name", "unknown"), assignment
|
||||
)
|
||||
|
||||
if chore_modified:
|
||||
@@ -269,10 +277,7 @@ class TaskMateStorage:
|
||||
|
||||
self._data["_career_score_initialized"] = True
|
||||
if children:
|
||||
_LOGGER.info(
|
||||
"TaskMate: initialized career_score for %d child(ren) "
|
||||
"from total_points_earned", len(children)
|
||||
)
|
||||
_LOGGER.info("TaskMate: initialized career_score for %d child(ren) from total_points_earned", len(children))
|
||||
await self.async_save()
|
||||
|
||||
async def async_save(self) -> None:
|
||||
@@ -336,9 +341,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_child(self, child_id: str) -> None:
|
||||
"""Remove a child and cascade-delete their awarded badges."""
|
||||
self._data["children"] = [
|
||||
c for c in self._data.get("children", []) if c.get("id") != child_id
|
||||
]
|
||||
self._data["children"] = [c for c in self._data.get("children", []) if c.get("id") != child_id]
|
||||
self.remove_awards_for_child(child_id)
|
||||
|
||||
# Chores management
|
||||
@@ -370,9 +373,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_chore(self, chore_id: str) -> None:
|
||||
"""Remove a chore."""
|
||||
self._data["chores"] = [
|
||||
c for c in self._data.get("chores", []) if c.get("id") != chore_id
|
||||
]
|
||||
self._data["chores"] = [c for c in self._data.get("chores", []) if c.get("id") != chore_id]
|
||||
order = self._data.get("chore_display_order", [])
|
||||
if chore_id in order:
|
||||
order.remove(chore_id)
|
||||
@@ -414,9 +415,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_reward(self, reward_id: str) -> None:
|
||||
"""Remove a reward."""
|
||||
self._data["rewards"] = [
|
||||
r for r in self._data.get("rewards", []) if r.get("id") != reward_id
|
||||
]
|
||||
self._data["rewards"] = [r for r in self._data.get("rewards", []) if r.get("id") != reward_id]
|
||||
|
||||
# Completions management
|
||||
def get_completions(self) -> list[ChoreCompletion]:
|
||||
@@ -447,9 +446,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_completion(self, completion_id: str) -> None:
|
||||
"""Remove a completion record."""
|
||||
self._data["completions"] = [
|
||||
c for c in self._data.get("completions", []) if c.get("id") != completion_id
|
||||
]
|
||||
self._data["completions"] = [c for c in self._data.get("completions", []) if c.get("id") != completion_id]
|
||||
|
||||
# Mandatory-miss management (#532)
|
||||
def get_mandatory_misses(self) -> list[MandatoryMiss]:
|
||||
@@ -470,9 +467,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_mandatory_miss(self, miss_id: str) -> None:
|
||||
"""Remove a mandatory-miss item by id."""
|
||||
self._data["mandatory_misses"] = [
|
||||
m for m in self._data.get("mandatory_misses", []) if m.get("id") != miss_id
|
||||
]
|
||||
self._data["mandatory_misses"] = [m for m in self._data.get("mandatory_misses", []) if m.get("id") != miss_id]
|
||||
|
||||
def replace_mandatory_misses(self, misses: list[MandatoryMiss]) -> None:
|
||||
"""Replace the whole mandatory-miss collection."""
|
||||
@@ -507,9 +502,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_reward_claim(self, claim_id: str) -> None:
|
||||
"""Remove a reward claim."""
|
||||
self._data["reward_claims"] = [
|
||||
c for c in self._data.get("reward_claims", []) if c.get("id") != claim_id
|
||||
]
|
||||
self._data["reward_claims"] = [c for c in self._data.get("reward_claims", []) if c.get("id") != claim_id]
|
||||
|
||||
# Penalties management
|
||||
def get_penalties(self) -> list[Penalty]:
|
||||
@@ -538,9 +531,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_penalty(self, penalty_id: str) -> None:
|
||||
"""Remove a penalty."""
|
||||
self._data["penalties"] = [
|
||||
p for p in self._data.get("penalties", []) if p.get("id") != penalty_id
|
||||
]
|
||||
self._data["penalties"] = [p for p in self._data.get("penalties", []) if p.get("id") != penalty_id]
|
||||
|
||||
# Bonuses management
|
||||
def get_bonuses(self) -> list[Bonus]:
|
||||
@@ -569,9 +560,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_bonus(self, bonus_id: str) -> None:
|
||||
"""Remove a bonus."""
|
||||
self._data["bonuses"] = [
|
||||
b for b in self._data.get("bonuses", []) if b.get("id") != bonus_id
|
||||
]
|
||||
self._data["bonuses"] = [b for b in self._data.get("bonuses", []) if b.get("id") != bonus_id]
|
||||
|
||||
# Badges management
|
||||
def get_badges(self) -> list[Badge]:
|
||||
@@ -600,9 +589,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_badge(self, badge_id: str) -> None:
|
||||
"""Remove a badge and cascade-delete its awards."""
|
||||
self._data["badges"] = [
|
||||
b for b in self._data.get("badges", []) if b.get("id") != badge_id
|
||||
]
|
||||
self._data["badges"] = [b for b in self._data.get("badges", []) if b.get("id") != badge_id]
|
||||
self.remove_awards_for_badge(badge_id)
|
||||
|
||||
# Awarded badges management
|
||||
@@ -620,9 +607,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_awarded_badge(self, awarded_id: str) -> None:
|
||||
"""Remove an awarded-badge record by id."""
|
||||
self._data["awarded_badges"] = [
|
||||
a for a in self._data.get("awarded_badges", []) if a.get("id") != awarded_id
|
||||
]
|
||||
self._data["awarded_badges"] = [a for a in self._data.get("awarded_badges", []) if a.get("id") != awarded_id]
|
||||
|
||||
def remove_awards_for_badge(self, badge_id: str) -> None:
|
||||
"""Cascade-delete all awards referencing a badge id."""
|
||||
@@ -705,9 +690,7 @@ class TaskMateStorage:
|
||||
cfg = NotificationConfig(
|
||||
type_id=tid,
|
||||
master_enabled=True,
|
||||
routes={
|
||||
seeded_parent_id: NotificationRoute(enabled=True)
|
||||
} if seeded_parent_id else {},
|
||||
routes={seeded_parent_id: NotificationRoute(enabled=True)} if seeded_parent_id else {},
|
||||
)
|
||||
nc[tid] = cfg.to_dict()
|
||||
|
||||
@@ -728,8 +711,7 @@ class TaskMateStorage:
|
||||
|
||||
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
|
||||
r for r in self._data.get("parent_recipients", []) if r.get("id") != parent_id
|
||||
]
|
||||
|
||||
# --- notification config ---
|
||||
@@ -749,9 +731,7 @@ class TaskMateStorage:
|
||||
cfg.nav_url = nav_url
|
||||
self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict()
|
||||
|
||||
def set_notification_route(
|
||||
self, type_id: str, recipient_id: str, route: NotificationRoute
|
||||
) -> None:
|
||||
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()
|
||||
@@ -764,10 +744,7 @@ class TaskMateStorage:
|
||||
|
||||
# --- custom notifications ---
|
||||
def get_custom_notifications(self) -> list[CustomNotification]:
|
||||
return [
|
||||
CustomNotification.from_dict(d)
|
||||
for d in self._data.get("custom_notifications", [])
|
||||
]
|
||||
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", [])
|
||||
@@ -779,15 +756,12 @@ class TaskMateStorage:
|
||||
|
||||
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
|
||||
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"
|
||||
)
|
||||
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
|
||||
@@ -811,16 +785,14 @@ class TaskMateStorage:
|
||||
def get_escalation_reminder_minutes(self) -> int:
|
||||
"""Minutes after a mandatory miss before the child reminder escalates."""
|
||||
try:
|
||||
return max(1, int((self._data.get("settings", {}) or {}).get(
|
||||
"mandatory_escalation_reminder_minutes", 30)))
|
||||
return max(1, int((self._data.get("settings", {}) or {}).get("mandatory_escalation_reminder_minutes", 30)))
|
||||
except (TypeError, ValueError):
|
||||
return 30
|
||||
|
||||
def get_escalation_parent_minutes(self) -> int:
|
||||
"""Minutes after a mandatory miss before the parent alert escalates."""
|
||||
try:
|
||||
return max(1, int((self._data.get("settings", {}) or {}).get(
|
||||
"mandatory_escalation_parent_minutes", 120)))
|
||||
return max(1, int((self._data.get("settings", {}) or {}).get("mandatory_escalation_parent_minutes", 120)))
|
||||
except (TypeError, ValueError):
|
||||
return 120
|
||||
|
||||
@@ -863,9 +835,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_task_group(self, group_id: str) -> None:
|
||||
"""Remove a task group."""
|
||||
self._data["task_groups"] = [
|
||||
g for g in self._data.get("task_groups", []) if g.get("id") != group_id
|
||||
]
|
||||
self._data["task_groups"] = [g for g in self._data.get("task_groups", []) if g.get("id") != group_id]
|
||||
|
||||
def remove_chore_from_task_groups(self, chore_id: str) -> None:
|
||||
"""Strip a chore ID from every group (used on chore delete)."""
|
||||
@@ -921,9 +891,7 @@ class TaskMateStorage:
|
||||
# the single choke point all awards flow through — the rolling 200-cap on
|
||||
# transactions makes them unreliable for a monthly total (FEAT-2).
|
||||
if transaction.points > 0:
|
||||
self.record_season_points(
|
||||
transaction.child_id, transaction.points, transaction.created_at
|
||||
)
|
||||
self.record_season_points(transaction.child_id, transaction.points, transaction.created_at)
|
||||
|
||||
# Keep only the last 200 transactions to avoid unbounded storage growth
|
||||
if len(self._data["points_transactions"]) > 200:
|
||||
@@ -1019,6 +987,57 @@ class TaskMateStorage:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _drop_dead_swap_requests(self) -> None:
|
||||
"""Prune swap requests that are settled or dangling (#783, #785).
|
||||
|
||||
Called from ``async_load``. A request survives only when it is still
|
||||
pending *and* every id it names still resolves — the chore, the
|
||||
requester, and ``from_child_id`` when set (it is "" for a chore with no
|
||||
cached assignee yet, which is a normal request, not a dead reference).
|
||||
"""
|
||||
swap_requests = self._data.get("swap_requests")
|
||||
if not swap_requests:
|
||||
return
|
||||
|
||||
chore_ids = {c.get("id") for c in self._data.get("chores", [])}
|
||||
child_ids = {c.get("id") for c in self._data.get("children", [])}
|
||||
|
||||
def _alive(req: dict) -> bool:
|
||||
if req.get("status") != "pending":
|
||||
return False
|
||||
if req.get("chore_id") not in chore_ids:
|
||||
return False
|
||||
if req.get("requester_id") not in child_ids:
|
||||
return False
|
||||
from_id = req.get("from_child_id") or ""
|
||||
return not from_id or from_id in child_ids
|
||||
|
||||
kept = [r for r in swap_requests if _alive(r)]
|
||||
if len(kept) != len(swap_requests):
|
||||
_LOGGER.debug(
|
||||
"Dropped %d settled or orphaned swap request(s) from storage",
|
||||
len(swap_requests) - len(kept),
|
||||
)
|
||||
self._data["swap_requests"] = kept
|
||||
|
||||
def remove_swap_requests_for_chore(self, chore_id: str) -> None:
|
||||
"""Drop a deleted chore's swap requests so they can't linger in the
|
||||
approval queue with nothing to approve."""
|
||||
self._data["swap_requests"] = [r for r in self._data.get("swap_requests", []) if r.get("chore_id") != chore_id]
|
||||
|
||||
def remove_swap_requests_for_child(self, child_id: str) -> None:
|
||||
"""Drop swap requests a deleted child is either side of.
|
||||
|
||||
Both ends matter: the requester is who the handover goes *to*, and
|
||||
`from_child_id` is who it comes from and is rendered in the queue.
|
||||
Either being gone makes the request undeliverable.
|
||||
"""
|
||||
self._data["swap_requests"] = [
|
||||
r
|
||||
for r in self._data.get("swap_requests", [])
|
||||
if r.get("requester_id") != child_id and r.get("from_child_id") != child_id
|
||||
]
|
||||
|
||||
# ── Quests (chore chains) ────────────────────────────────────────────
|
||||
def get_quests(self) -> list[Quest]:
|
||||
return [Quest.from_dict(q) for q in self._data.get("quests", [])]
|
||||
@@ -1041,9 +1060,7 @@ class TaskMateStorage:
|
||||
self.add_quest(quest)
|
||||
|
||||
def remove_quest(self, quest_id: str) -> None:
|
||||
self._data["quests"] = [
|
||||
q for q in self._data.get("quests", []) if q.get("id") != quest_id
|
||||
]
|
||||
self._data["quests"] = [q for q in self._data.get("quests", []) if q.get("id") != quest_id]
|
||||
# Drop any progress tracked for this quest
|
||||
prog = self._data.get("quest_progress", {})
|
||||
prog.pop(quest_id, None)
|
||||
@@ -1084,9 +1101,7 @@ class TaskMateStorage:
|
||||
self.add_challenge(challenge)
|
||||
|
||||
def remove_challenge(self, challenge_id: str) -> None:
|
||||
self._data["challenges"] = [
|
||||
c for c in self._data.get("challenges", []) if c.get("id") != challenge_id
|
||||
]
|
||||
self._data["challenges"] = [c for c in self._data.get("challenges", []) if c.get("id") != challenge_id]
|
||||
self._data.get("challenge_progress", {}).pop(challenge_id, None)
|
||||
|
||||
def get_challenge_progress(self) -> dict:
|
||||
@@ -1107,6 +1122,7 @@ class TaskMateStorage:
|
||||
def export_data(self) -> dict:
|
||||
"""Return a deep copy of the full stored data (for backup/export)."""
|
||||
import copy
|
||||
|
||||
return copy.deepcopy(self._data)
|
||||
|
||||
def import_data(self, data: dict) -> None:
|
||||
@@ -1116,14 +1132,29 @@ class TaskMateStorage:
|
||||
a partial import.
|
||||
"""
|
||||
import copy
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("import data must be an object")
|
||||
self._data = copy.deepcopy(data)
|
||||
list_keys = (
|
||||
"children", "chores", "rewards", "penalties", "bonuses",
|
||||
"task_groups", "completions", "mandatory_misses", "reward_claims", "points_transactions",
|
||||
"pool_allocations", "badges", "awarded_badges", "parent_recipients",
|
||||
"audit_log", "timed_sessions", "quests", "challenges",
|
||||
"children",
|
||||
"chores",
|
||||
"rewards",
|
||||
"penalties",
|
||||
"bonuses",
|
||||
"task_groups",
|
||||
"completions",
|
||||
"mandatory_misses",
|
||||
"reward_claims",
|
||||
"points_transactions",
|
||||
"pool_allocations",
|
||||
"badges",
|
||||
"awarded_badges",
|
||||
"parent_recipients",
|
||||
"audit_log",
|
||||
"timed_sessions",
|
||||
"quests",
|
||||
"challenges",
|
||||
)
|
||||
for k in list_keys:
|
||||
if not isinstance(self._data.get(k), list):
|
||||
@@ -1146,6 +1177,7 @@ class TaskMateStorage:
|
||||
well-formed photo URLs so the panel never renders a foreign/dangerous one.
|
||||
"""
|
||||
from .photos import is_taskmate_photo_url
|
||||
|
||||
for comp in self._data.get("completions", []):
|
||||
if not isinstance(comp, dict):
|
||||
continue
|
||||
@@ -1163,21 +1195,15 @@ class TaskMateStorage:
|
||||
|
||||
def remove_completions_for_child(self, child_id: str) -> None:
|
||||
"""Remove all completions for a given child."""
|
||||
self._data["completions"] = [
|
||||
c for c in self._data.get("completions", []) if c.get("child_id") != child_id
|
||||
]
|
||||
self._data["completions"] = [c for c in self._data.get("completions", []) if c.get("child_id") != child_id]
|
||||
|
||||
def remove_completions_for_chore(self, chore_id: str) -> None:
|
||||
"""Remove all completions for a given chore."""
|
||||
self._data["completions"] = [
|
||||
c for c in self._data.get("completions", []) if c.get("chore_id") != chore_id
|
||||
]
|
||||
self._data["completions"] = [c for c in self._data.get("completions", []) if c.get("chore_id") != chore_id]
|
||||
|
||||
def remove_reward_claims_for_child(self, child_id: str) -> None:
|
||||
"""Remove all reward claims for a given child."""
|
||||
self._data["reward_claims"] = [
|
||||
c for c in self._data.get("reward_claims", []) if c.get("child_id") != child_id
|
||||
]
|
||||
self._data["reward_claims"] = [c for c in self._data.get("reward_claims", []) if c.get("child_id") != child_id]
|
||||
|
||||
def remove_reward_claims_for_reward(self, reward_id: str) -> None:
|
||||
"""Remove all reward claims for a given reward."""
|
||||
@@ -1209,7 +1235,8 @@ class TaskMateStorage:
|
||||
def remove_pool_allocation(self, child_id: str, reward_id: str) -> None:
|
||||
"""Remove a pool allocation for a specific (child, reward) pair."""
|
||||
self._data["pool_allocations"] = [
|
||||
a for a in self._data.get("pool_allocations", [])
|
||||
a
|
||||
for a in self._data.get("pool_allocations", [])
|
||||
if not (a.get("child_id") == child_id and a.get("reward_id") == reward_id)
|
||||
]
|
||||
|
||||
@@ -1309,18 +1336,22 @@ class TaskMateStorage:
|
||||
def get_timed_session(self, chore_id: str, child_id: str, session_date: str) -> TimedSession | None:
|
||||
"""Get a timed session for a specific chore/child/date."""
|
||||
for s in self._data.get("timed_sessions", []):
|
||||
if (s.get("chore_id") == chore_id
|
||||
and s.get("child_id") == child_id
|
||||
and s.get("session_date") == session_date):
|
||||
if (
|
||||
s.get("chore_id") == chore_id
|
||||
and s.get("child_id") == child_id
|
||||
and s.get("session_date") == session_date
|
||||
):
|
||||
return TimedSession.from_dict(s)
|
||||
return None
|
||||
|
||||
def get_active_timed_session(self, chore_id: str, child_id: str) -> TimedSession | None:
|
||||
"""Get a running or paused session for a chore/child pair."""
|
||||
for s in self._data.get("timed_sessions", []):
|
||||
if (s.get("chore_id") == chore_id
|
||||
and s.get("child_id") == child_id
|
||||
and s.get("state") in ("running", "paused")):
|
||||
if (
|
||||
s.get("chore_id") == chore_id
|
||||
and s.get("child_id") == child_id
|
||||
and s.get("state") in ("running", "paused")
|
||||
):
|
||||
return TimedSession.from_dict(s)
|
||||
return None
|
||||
|
||||
@@ -1335,10 +1366,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_timed_session(self, session_id: str) -> None:
|
||||
"""Remove a timed session."""
|
||||
self._data["timed_sessions"] = [
|
||||
s for s in self._data.get("timed_sessions", [])
|
||||
if s.get("id") != session_id
|
||||
]
|
||||
self._data["timed_sessions"] = [s for s in self._data.get("timed_sessions", []) if s.get("id") != session_id]
|
||||
|
||||
# Generic settings
|
||||
def get_setting(self, key: str, default: Any = "") -> Any:
|
||||
|
||||
Reference in New Issue
Block a user