278 files
This commit is contained in:
@@ -19,6 +19,7 @@ from .models import (
|
||||
Chore,
|
||||
ChoreCompletion,
|
||||
CustomNotification,
|
||||
CustomSound,
|
||||
MandatoryMiss,
|
||||
NotificationConfig,
|
||||
NotificationRoute,
|
||||
@@ -32,10 +33,30 @@ from .models import (
|
||||
ScheduledChange,
|
||||
TaskGroup,
|
||||
TimedSession,
|
||||
parse_datetime,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _finite_number(value: Any, default: int | float) -> int | float:
|
||||
"""Coerce an imported value to a real, finite number.
|
||||
|
||||
Returns ``default`` for anything that isn't usable — a string that doesn't
|
||||
parse, a bool, a dict, or inf/NaN. Booleans are excluded deliberately:
|
||||
``float(True)`` is 1.0, which would silently turn a flag into a score.
|
||||
"""
|
||||
if isinstance(value, bool) or isinstance(value, (dict, list)):
|
||||
return default
|
||||
try:
|
||||
num = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if num != num or num in (float("inf"), float("-inf")):
|
||||
return default
|
||||
return int(num) if float(num).is_integer() else num
|
||||
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = f"{DOMAIN}.storage"
|
||||
|
||||
@@ -117,6 +138,10 @@ class TaskMateStorage:
|
||||
if "chore_display_order" not in self._data:
|
||||
self._data["chore_display_order"] = []
|
||||
|
||||
# Ensure custom_sounds store exists (migration for custom sounds, #856)
|
||||
if "custom_sounds" not in self._data:
|
||||
self._data["custom_sounds"] = []
|
||||
|
||||
# Ensure scheduled_changes store exists (#675)
|
||||
if "scheduled_changes" not in self._data:
|
||||
self._data["scheduled_changes"] = []
|
||||
@@ -558,6 +583,48 @@ class TaskMateStorage:
|
||||
"""Remove a bonus."""
|
||||
self._data["bonuses"] = [b for b in self._data.get("bonuses", []) if b.get("id") != bonus_id]
|
||||
|
||||
# Custom sounds management (#856)
|
||||
def get_custom_sounds(self) -> list[CustomSound]:
|
||||
"""Get all uploaded custom completion sounds."""
|
||||
return [CustomSound.from_dict(s) for s in self._data.get("custom_sounds", [])]
|
||||
|
||||
def get_custom_sound(self, file: str) -> CustomSound | None:
|
||||
"""Get a custom sound by its stored filename."""
|
||||
for s in self._data.get("custom_sounds", []):
|
||||
if s.get("file") == file:
|
||||
return CustomSound.from_dict(s)
|
||||
return None
|
||||
|
||||
def add_custom_sound(self, sound: CustomSound) -> None:
|
||||
"""Register an uploaded custom sound."""
|
||||
self._data.setdefault("custom_sounds", []).append(sound.to_dict())
|
||||
|
||||
def rename_custom_sound(self, file: str, name: str) -> bool:
|
||||
"""Rename a custom sound. Returns False if it isn't registered."""
|
||||
for s in self._data.get("custom_sounds", []):
|
||||
if s.get("file") == file:
|
||||
s["name"] = name
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_custom_sound(self, file: str) -> None:
|
||||
"""Deregister a custom sound (the file itself is deleted by the caller)."""
|
||||
self._data["custom_sounds"] = [s for s in self._data.get("custom_sounds", []) if s.get("file") != file]
|
||||
|
||||
def reset_chores_using_sound(self, sound_value: str, fallback: str = "coin") -> int:
|
||||
"""Point any chore using ``sound_value`` back at ``fallback``.
|
||||
|
||||
Called when a custom sound is deleted. Without this the chore keeps a
|
||||
dangling ``custom:`` reference and silently plays nothing on completion.
|
||||
Returns the number of chores rewritten.
|
||||
"""
|
||||
changed = 0
|
||||
for c in self._data.get("chores", []):
|
||||
if c.get("completion_sound") == sound_value:
|
||||
c["completion_sound"] = fallback
|
||||
changed += 1
|
||||
return changed
|
||||
|
||||
# Badges management
|
||||
def get_badges(self) -> list[Badge]:
|
||||
"""Get all badges."""
|
||||
@@ -774,6 +841,17 @@ class TaskMateStorage:
|
||||
return []
|
||||
return [x for x in raw if isinstance(x, str) and x]
|
||||
|
||||
def get_require_linked_child(self) -> bool:
|
||||
"""True when acting *as* a child requires that child to be linked.
|
||||
|
||||
Off by default: the shared-tablet/kiosk setup (nobody linked, one
|
||||
household account drives every child) is the common deployment and
|
||||
must keep working on upgrade. Households where each child has their
|
||||
own Home Assistant login can switch this on to stop one child acting
|
||||
through another's profile.
|
||||
"""
|
||||
return bool((self._data.get("settings", {}) or {}).get("require_linked_child", False))
|
||||
|
||||
def set_parent_user_ids(self, ids: list[str]) -> None:
|
||||
"""Replace the parent role list (deduped, order-preserving, strings only)."""
|
||||
seen: list[str] = []
|
||||
@@ -956,15 +1034,30 @@ class TaskMateStorage:
|
||||
return list(reversed(self._data.get("audit_log", [])))
|
||||
|
||||
def add_audit_entry(self, entry: dict) -> None:
|
||||
"""Append an admin audit entry, capping the log at 500 (oldest dropped)."""
|
||||
"""Append an audit entry, capping the log at 500 (oldest dropped).
|
||||
|
||||
Truncation is counted, not silent: without a marker, generating 500
|
||||
routine entries quietly pushes an earlier one out of the log and the
|
||||
reader has no way to tell that anything is missing.
|
||||
"""
|
||||
log = self._data.setdefault("audit_log", [])
|
||||
log.append(entry)
|
||||
if len(log) > 500:
|
||||
dropped = len(log) - 500
|
||||
del log[:-500]
|
||||
self._data["audit_log_dropped"] = int(self._data.get("audit_log_dropped", 0) or 0) + dropped
|
||||
|
||||
def get_audit_dropped_count(self) -> int:
|
||||
"""How many audit entries have aged out of the capped log."""
|
||||
try:
|
||||
return int(self._data.get("audit_log_dropped", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
def clear_audit_log(self) -> None:
|
||||
"""Remove all audit entries."""
|
||||
self._data["audit_log"] = []
|
||||
self._data["audit_log_dropped"] = 0
|
||||
|
||||
# ── Chore swap requests ──────────────────────────────────────────────
|
||||
def get_swap_requests(self) -> list[dict]:
|
||||
@@ -1185,6 +1278,48 @@ class TaskMateStorage:
|
||||
|
||||
_STREAK_MODES = ("reset", "pause")
|
||||
_CARD_DESIGNS = ("classic", "playroom", "console", "cleanpro", "accessible")
|
||||
# Per-record numeric fields. The WebSocket schemas coerce these on every
|
||||
# normal write; import bypasses the schemas entirely, so a crafted
|
||||
# backup could leave a string (or inf/NaN) where the rest of the code —
|
||||
# and the panel's number inputs — expect a number.
|
||||
_NUMERIC_RECORD_FIELDS = {
|
||||
# NB: streak_milestones_achieved is a list[int], not a scalar — it
|
||||
# must not be coerced here.
|
||||
"children": (
|
||||
"points",
|
||||
"total_points_earned",
|
||||
"total_chores_completed",
|
||||
"current_streak",
|
||||
"best_streak",
|
||||
"career_score",
|
||||
"total_penalties_received",
|
||||
),
|
||||
"chores": (
|
||||
"points",
|
||||
"claim_allowance_minutes",
|
||||
"daily_limit",
|
||||
"mandatory_penalty_points",
|
||||
"speed_bonus_points",
|
||||
"skip_count",
|
||||
"timed_rate_points",
|
||||
"timed_rate_minutes",
|
||||
"timed_max_daily_minutes",
|
||||
),
|
||||
"rewards": ("cost", "restock_amount", "unlock_minutes"),
|
||||
"penalties": ("points",),
|
||||
"bonuses": ("points",),
|
||||
"completions": ("points_awarded", "timed_duration_seconds"),
|
||||
"points_transactions": ("points",),
|
||||
"mandatory_misses": ("penalty_points", "postpone_count", "escalation_stage"),
|
||||
"pool_allocations": ("allocated_points",),
|
||||
}
|
||||
_DATETIME_RECORD_FIELDS = {
|
||||
"completions": ("completed_at", "approved_at"),
|
||||
"reward_claims": ("claimed_at", "approved_at"),
|
||||
"points_transactions": ("created_at",),
|
||||
"mandatory_misses": ("created_at",),
|
||||
"allowance_payouts": ("created_at",),
|
||||
}
|
||||
_NUMERIC_SETTINGS = (
|
||||
"history_days",
|
||||
"weekend_multiplier",
|
||||
@@ -1204,6 +1339,45 @@ class TaskMateStorage:
|
||||
"perfect_week_bonus",
|
||||
)
|
||||
|
||||
for bucket, keys in _NUMERIC_RECORD_FIELDS.items():
|
||||
for record in self._data.get(bucket, []) or []:
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
for key in keys:
|
||||
if key in record:
|
||||
record[key] = _finite_number(record[key], 0)
|
||||
for sub in record.get("bonus_subtasks", []) or []:
|
||||
if isinstance(sub, dict) and "points" in sub:
|
||||
sub["points"] = _finite_number(sub["points"], 0)
|
||||
|
||||
# quantity is "unlimited" when absent/None, so it can't share the loop
|
||||
# above — but a string or NaN there breaks the sold-out comparison.
|
||||
for reward in self._data.get("rewards", []) or []:
|
||||
if isinstance(reward, dict) and reward.get("quantity") is not None:
|
||||
reward["quantity"] = _finite_number(reward["quantity"], 0)
|
||||
|
||||
for badge in self._data.get("badges", []) or []:
|
||||
if not isinstance(badge, dict):
|
||||
continue
|
||||
if "points" in badge:
|
||||
badge["points"] = _finite_number(badge["points"], 0)
|
||||
for crit in badge.get("criteria", []) or []:
|
||||
if isinstance(crit, dict) and "value" in crit:
|
||||
crit["value"] = _finite_number(crit["value"], 0)
|
||||
|
||||
# A timestamp that doesn't parse is dropped: models already turn it into
|
||||
# None on load, and the panel renders a blank as an em dash rather than
|
||||
# echoing whatever the string happened to be.
|
||||
for bucket, keys in _DATETIME_RECORD_FIELDS.items():
|
||||
for record in self._data.get(bucket, []) or []:
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
for key in keys:
|
||||
raw = record.get(key)
|
||||
if raw and parse_datetime(raw) is None:
|
||||
_LOGGER.warning("Import: dropped unparseable %s on a %s record", key, bucket)
|
||||
record[key] = ""
|
||||
|
||||
for comp in self._data.get("completions", []):
|
||||
if not isinstance(comp, dict):
|
||||
continue
|
||||
@@ -1230,6 +1404,34 @@ class TaskMateStorage:
|
||||
)
|
||||
chore["image_url"] = ""
|
||||
|
||||
# Custom sounds: `file` is concatenated into a path and handed to HA's
|
||||
# URL signer, and `name` is rendered in the panel and the chore
|
||||
# dropdown. Neither goes through the upload validation on an import.
|
||||
from .sounds import FILENAME_RE as _SOUND_FILENAME_RE
|
||||
from .sounds import clean_sound_name
|
||||
|
||||
sounds = self._data.get("custom_sounds")
|
||||
if isinstance(sounds, list):
|
||||
kept = []
|
||||
for snd in sounds:
|
||||
if not isinstance(snd, dict):
|
||||
continue
|
||||
if not _SOUND_FILENAME_RE.match(str(snd.get("file", "") or "")):
|
||||
_LOGGER.warning("Import: dropped custom sound with an invalid file name")
|
||||
continue
|
||||
snd["name"] = clean_sound_name(snd.get("name"))
|
||||
kept.append(snd)
|
||||
self._data["custom_sounds"] = kept
|
||||
|
||||
# A chore's completion_sound is an enum or a reference to one of those
|
||||
# sounds; anything else would be rendered into the panel as-is.
|
||||
from .const import is_valid_completion_sound
|
||||
|
||||
for chore in self._data.get("chores", []) or []:
|
||||
if isinstance(chore, dict) and "completion_sound" in chore:
|
||||
if not is_valid_completion_sound(str(chore.get("completion_sound") or "")):
|
||||
chore["completion_sound"] = "coin"
|
||||
|
||||
for grp in self._data.get("task_groups", []):
|
||||
if isinstance(grp, dict) and grp.get("policy") not in TASK_GROUP_POLICIES:
|
||||
grp["policy"] = "sticky"
|
||||
@@ -1253,11 +1455,23 @@ class TaskMateStorage:
|
||||
if key not in settings:
|
||||
continue
|
||||
val = settings[key]
|
||||
if isinstance(val, bool) or not isinstance(val, (int, float)):
|
||||
try:
|
||||
settings[key] = float(val)
|
||||
except (TypeError, ValueError):
|
||||
if (
|
||||
isinstance(val, bool)
|
||||
or not isinstance(val, (int, float))
|
||||
or val != val
|
||||
or val
|
||||
in (
|
||||
float("inf"),
|
||||
float("-inf"),
|
||||
)
|
||||
):
|
||||
# inf/NaN parse happily via float() but blow up later in
|
||||
# arithmetic (e.g. a multiplier), so drop them to the default.
|
||||
coerced = _finite_number(val, None)
|
||||
if coerced is None:
|
||||
settings.pop(key, None)
|
||||
else:
|
||||
settings[key] = coerced
|
||||
|
||||
def replace_completions(self, completions: list[ChoreCompletion]) -> None:
|
||||
"""Replace all completions with the given list."""
|
||||
|
||||
Reference in New Issue
Block a user