456 files

This commit is contained in:
Home Assistant Version Control
2026-10-01 05:38:21 +00:00
parent 4192ecbaae
commit 5d6d845ebf
456 changed files with 64951 additions and 3828 deletions
+555 -3
View File
@@ -5,9 +5,11 @@ from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from typing import Any
from .const import AGE_GROUPS, AUCTION_STATUSES, BOUNTY_STATUSES, MAX_CHORE_TAGS, TAG_ID_MAX_LENGTH
_LOGGER = logging.getLogger(__name__)
@@ -54,6 +56,17 @@ def parse_datetime(value: str | datetime | None) -> datetime | None:
return None
def parse_quality_rating(value: Any) -> int:
"""Parse a stored 1-3 star quality rating (#927); anything else is 0 (unrated)."""
if value is None or isinstance(value, bool):
return 0
try:
rating = int(value)
except (ValueError, TypeError, OverflowError):
return 0
return rating if 1 <= rating <= 3 else 0
def parse_optional_points(value: Any) -> int | None:
"""Parse a stored points figure, or None for "not recorded".
@@ -88,6 +101,24 @@ def format_datetime(dt: datetime | None) -> str | None:
return utc_dt.isoformat().replace("+00:00", "Z")
def normalize_tag_ids(raw: Any) -> list[str]:
"""Clean a chore's linked HA tag ids (#923).
Ids are compared exactly against ``tag_scanned`` events, so surrounding
whitespace (easy to paste in with a free-text id) would silently never
match. Blanks and duplicates are dropped, order is kept, and the list is
capped so a crafted import can't bloat the store.
"""
if not isinstance(raw, (list, tuple)):
return []
out: list[str] = []
for item in raw:
text = str(item or "").strip()[:TAG_ID_MAX_LENGTH]
if text and text not in out:
out.append(text)
return out[:MAX_CHORE_TAGS]
def optional_float(value: Any) -> float | None:
"""Coerce a stored/user value to a float, or None when it isn't set.
@@ -166,6 +197,11 @@ class BonusSubTask:
}
def normalize_age_group(value: Any) -> str:
"""A stored age group (#980): one of AGE_GROUPS, anything else is none ("")."""
return value if isinstance(value, str) and value in AGE_GROUPS else ""
@dataclass
class Child:
"""Represents a child."""
@@ -182,6 +218,14 @@ class Child:
last_completion_date: str | None = None # ISO date string of last chore completion (for streak tracking)
streak_paused: bool = False # True if streak is paused due to missed day (pause mode)
streak_milestones_achieved: list[int] = field(default_factory=list)
# Streak freeze tokens (#925): each one covers one missed day so the streak
# survives it. `streak_freeze_dates` are the ISO days a token has covered
# (only the ones after the last completion matter, so it stays short), and
# `streak_freeze_earned_at` is the streak length that last earned a token,
# so undoing that day's completion can take the token back.
streak_freezes: int = 0
streak_freeze_dates: list[str] = field(default_factory=list)
streak_freeze_earned_at: int = 0
awarded_perfect_weeks: list[str] = field(default_factory=list)
availability_entity: str = "" # HA entity id; empty = always available
availability_inverted: bool = False # When True, _AVAILABLE_STATES means UNAVAILABLE
@@ -193,11 +237,17 @@ class Child:
linked_user_id: str = "" # HA user id; when set, only that user (or an admin) may self-serve as this child
quiet_hours_start: str = "" # "HH:MM" — start of do-not-disturb window; empty = no quiet hours
quiet_hours_end: str = "" # "HH:MM" — end of do-not-disturb window; start>end means overnight
presence_entity: str = "" # person./device_tracker.; while not home, reminders wait for arrival (#926)
level: int = 1 # cached XP level (derived from total_points_earned)
# Guest profiles (#690): a visiting cousin gets a temporary child that
# expires on its own and stays out of the family leaderboard.
is_guest: bool = False
guest_expires_on: str = "" # ISO date; profile auto-archives the day after
# Birthday mode (#924): "YYYY-MM-DD", or "MM-DD" when no age should show.
birthday: str = ""
# Setup wizard age group (#980): one of AGE_GROUPS, or "" when none was
# picked (every child that predates the wizard). Only steers suggestions.
age_group: str = ""
id: str = field(default_factory=generate_id)
@classmethod
@@ -216,9 +266,14 @@ class Child:
last_completion_date=data.get("last_completion_date"),
streak_paused=data.get("streak_paused", False),
streak_milestones_achieved=list(data.get("streak_milestones_achieved", [])),
streak_freezes=max(0, int(data.get("streak_freezes", 0) or 0)),
streak_freeze_dates=[str(d) for d in (data.get("streak_freeze_dates") or []) if d],
streak_freeze_earned_at=max(0, int(data.get("streak_freeze_earned_at", 0) or 0)),
awarded_perfect_weeks=list(data.get("awarded_perfect_weeks", [])),
is_guest=bool(data.get("is_guest", False)),
guest_expires_on=str(data.get("guest_expires_on", "") or ""),
birthday=str(data.get("birthday", "") or ""),
age_group=normalize_age_group(data.get("age_group")),
availability_entity=data.get("availability_entity", ""),
availability_inverted=data.get("availability_inverted", False),
unavailability_entity=data.get("unavailability_entity", ""),
@@ -229,6 +284,7 @@ class Child:
linked_user_id=data.get("linked_user_id", ""),
quiet_hours_start=data.get("quiet_hours_start", ""),
quiet_hours_end=data.get("quiet_hours_end", ""),
presence_entity=str(data.get("presence_entity", "") or ""),
level=int(data.get("level", 1) or 1),
id=data.get("id") or generate_id(),
)
@@ -248,9 +304,14 @@ class Child:
"last_completion_date": self.last_completion_date,
"streak_paused": self.streak_paused,
"streak_milestones_achieved": self.streak_milestones_achieved,
"streak_freezes": self.streak_freezes,
"streak_freeze_dates": list(self.streak_freeze_dates),
"streak_freeze_earned_at": self.streak_freeze_earned_at,
"awarded_perfect_weeks": self.awarded_perfect_weeks,
"is_guest": self.is_guest,
"guest_expires_on": self.guest_expires_on,
"birthday": self.birthday,
"age_group": self.age_group,
"availability_entity": self.availability_entity,
"availability_inverted": self.availability_inverted,
"unavailability_entity": self.unavailability_entity,
@@ -261,6 +322,7 @@ class Child:
"linked_user_id": self.linked_user_id,
"quiet_hours_start": self.quiet_hours_start,
"quiet_hours_end": self.quiet_hours_end,
"presence_entity": self.presence_entity,
"level": self.level,
"id": self.id,
}
@@ -348,6 +410,15 @@ class Chore:
# the real points when approving. Forces parent approval — a child types
# the suggestion, so it must never self-award.
open_ended: bool = False
# Teamwork chores (#928): the chore only counts once team_size different
# children have joined the day's occurrence; then it submits for all of
# them at once. 0 = an ordinary chore. team_points_mode "each" pays every
# participant the full points, "split" divides them evenly (rounded down);
# team_bonus is added per participant either way. Who has joined so far
# is runtime state, kept in storage["team_joins"], not on the chore.
team_size: int = 0
team_points_mode: str = "each" # each | split
team_bonus: int = 0
# Dynamic assignment (sibling rotation)
assignment_mode: str = "everyone" # everyone | alternating | random
assignment_rotation_anchor: str = "" # ISO date; day-0 of the rotation for alternating
@@ -371,6 +442,11 @@ class Chore:
# idempotence (don't re-publish the same day) and projection cleanup
# (entries < today are pruned before each publish pass).
publish_calendar_published_dates: list[str] = field(default_factory=list)
# Per-occurrence calendar edits (#977): {original ISO date: new ISO date}.
# An empty value means that one occurrence was removed. Written by the
# two-way calendar; every due-date computation consults it through
# ``occurrence_override`` so a moved chore is due on its new day only.
moved_occurrences: dict[str, str] = field(default_factory=dict)
# Bonus sub-tasks: optional extra-credit tasks that unlock after the parent chore is completed
bonus_subtasks: list[BonusSubTask] = field(default_factory=list)
# Timed task fields
@@ -378,6 +454,9 @@ class Chore:
timed_rate_points: int = 10 # points awarded per rate window
timed_rate_minutes: int = 5 # rate window size in minutes
timed_max_daily_minutes: int = 0 # 0 = unlimited; caps total daily duration
# NFC / QR tag completion (#923): HA tag ids that complete this chore when
# scanned in the companion app. Empty = no tag linked.
tag_ids: list[str] = field(default_factory=list)
id: str = field(default_factory=generate_id)
@classmethod
@@ -432,6 +511,9 @@ class Chore:
mandatory_penalty_points=max(0, int(data.get("mandatory_penalty_points", 0) or 0)),
require_photo=data.get("require_photo", False),
open_ended=data.get("open_ended", False),
team_size=max(0, int(data.get("team_size", 0) or 0)),
team_points_mode="split" if data.get("team_points_mode") == "split" else "each",
team_bonus=max(0, int(data.get("team_bonus", 0) or 0)),
assignment_mode=data.get("assignment_mode", "everyone"),
assignment_rotation_anchor=data.get("assignment_rotation_anchor", ""),
assignment_current_child_id=data.get("assignment_current_child_id", ""),
@@ -448,11 +530,13 @@ class Chore:
list(data.get("publish_calendar_published_dates", []))
or ([data["publish_calendar_last_date"]] if data.get("publish_calendar_last_date") else [])
),
moved_occurrences=clean_moved_occurrences(data.get("moved_occurrences")),
bonus_subtasks=[BonusSubTask.from_dict(b) for b in data.get("bonus_subtasks", [])],
task_type=data.get("task_type", "standard"),
timed_rate_points=data.get("timed_rate_points", 10),
timed_rate_minutes=max(1, int(data.get("timed_rate_minutes", 5) or 5)),
timed_max_daily_minutes=max(0, int(data.get("timed_max_daily_minutes", 0) or 0)),
tag_ids=normalize_tag_ids(data.get("tag_ids", [])),
id=data.get("id") or generate_id(),
)
@@ -500,6 +584,9 @@ class Chore:
"mandatory_penalty_points": self.mandatory_penalty_points,
"require_photo": self.require_photo,
"open_ended": self.open_ended,
"team_size": self.team_size,
"team_points_mode": self.team_points_mode,
"team_bonus": self.team_bonus,
"assignment_mode": self.assignment_mode,
"assignment_rotation_anchor": self.assignment_rotation_anchor,
"assignment_current_child_id": self.assignment_current_child_id,
@@ -510,15 +597,41 @@ class Chore:
"assignment_swap_date": self.assignment_swap_date,
"publish_calendar_entities": self.publish_calendar_entities,
"publish_calendar_published_dates": self.publish_calendar_published_dates,
"moved_occurrences": self.moved_occurrences,
"bonus_subtasks": [b.to_dict() for b in self.bonus_subtasks],
"task_type": self.task_type,
"timed_rate_points": self.timed_rate_points,
"timed_rate_minutes": self.timed_rate_minutes,
"timed_max_daily_minutes": self.timed_max_daily_minutes,
"tag_ids": self.tag_ids,
"id": self.id,
}
def clean_moved_occurrences(raw: Any) -> dict[str, str]:
"""Coerce a stored ``moved_occurrences`` map to {ISO date: ISO date or ""}.
Anything unparseable is dropped rather than raised, so a hand-edited or
older store can never stop the integration loading (#977).
"""
if not isinstance(raw, dict):
return {}
out: dict[str, str] = {}
for key, value in raw.items():
try:
src = date.fromisoformat(str(key)).isoformat()
except ValueError:
continue
if value in ("", None):
out[src] = ""
continue
try:
out[src] = date.fromisoformat(str(value)).isoformat()
except ValueError:
continue
return out
def _clean_weekdays(raw: Any) -> list[int]:
"""Coerce stored weekday numbers to a sorted, de-duplicated 0-6 list."""
if not isinstance(raw, (list, tuple, set)):
@@ -566,9 +679,16 @@ class Reward:
available_days: list[int] = field(default_factory=list)
available_from: str = ""
available_until: str = ""
# Streak freeze (#925): approving a claim hands the child one streak-freeze
# token instead of anything physical. Always a fixed cost like any reward.
streak_freeze: bool = False
id: str = field(default_factory=generate_id)
def __post_init__(self) -> None:
# A streak freeze belongs to the one child who bought it, so it can
# never be a shared jackpot.
if self.streak_freeze:
self.is_jackpot = False
# Jackpots are inherently pooled (#552): everyone deposits into the shared
# jar, so pool mode is always on. Enforced here so every construction path
# — WS add, service add, and storage load (legacy migration) — stays
@@ -603,6 +723,7 @@ class Reward:
available_days=_clean_weekdays(data.get("available_days")),
available_from=str(data.get("available_from", "") or ""),
available_until=str(data.get("available_until", "") or ""),
streak_freeze=bool(data.get("streak_freeze", False)),
id=data.get("id") or generate_id(),
)
@@ -628,6 +749,7 @@ class Reward:
"available_days": list(self.available_days),
"available_from": self.available_from,
"available_until": self.available_until,
"streak_freeze": self.streak_freeze,
"id": self.id,
}
@@ -731,6 +853,228 @@ class Challenge:
}
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _bounty_claim_count(data: dict[str, Any]) -> int:
"""The stored claim count, or the least a record from before it was kept
(#961) proves: every lapse was a claim, and a bounty that is or was held
was claimed at least once."""
count = max(0, _safe_int(data.get("claim_count", 0)))
count = max(count, _safe_int(data.get("lapse_count", 0)))
if data.get("status") in ("claimed", "pending", "completed"):
count = max(count, 1)
return count
@dataclass
class Bounty:
"""A one-off job on the bounty board (#931).
A parent posts it for a fixed number of points; any eligible child may
claim it, which locks it to them until ``claim_until``. Done goes through
the normal approval queue as a ``ChoreCompletion`` carrying ``bounty_id``,
so points, streaks, badges and undo behave exactly as for a chore. A claim
that runs out puts the bounty back on the board; an unclaimed bounty
expires at ``expires_at``. Completed and expired bounties stay as history
until the completion history is pruned.
"""
title: str
points: int = 0
description: str = ""
icon: str = "mdi:flag-outline"
expires_at: datetime | None = None # None = no expiry
eligible_child_ids: list[str] = field(default_factory=list) # empty = every child
claim_hours: int = 2
require_photo: bool = False
notify_children: bool = True
status: str = "open" # open | claimed | pending | completed | expired
# The claimer. Kept once the bounty is completed, as who did it.
claimed_by: str = ""
claimed_at: datetime | None = None
claim_until: datetime | None = None
lapse_warned: bool = False # the "about to lapse" push went out for this claim
completion_id: str = "" # the pending / approved ChoreCompletion
points_awarded: int = 0
lapse_count: int = 0 # how many claims ran out before someone finished it
# How many times a child claimed it off the board (#961). A rejection
# hands the same claim back, so it doesn't count again.
claim_count: int = 0
created_at: datetime | None = None
closed_at: datetime | None = None # when it was completed or expired
id: str = field(default_factory=generate_id)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Bounty:
return cls(
title=str(data.get("title", "") or ""),
points=max(0, _safe_int(data.get("points", 0))),
description=str(data.get("description", "") or ""),
icon=str(data.get("icon", "") or "mdi:flag-outline"),
expires_at=parse_datetime(data.get("expires_at")),
eligible_child_ids=[str(c) for c in (data.get("eligible_child_ids") or []) if c],
claim_hours=max(1, _safe_int(data.get("claim_hours", 2), 2)),
require_photo=data.get("require_photo") is True,
notify_children=data.get("notify_children", True) is not False,
status=data.get("status") if data.get("status") in BOUNTY_STATUSES else "open",
claimed_by=str(data.get("claimed_by", "") or ""),
claimed_at=parse_datetime(data.get("claimed_at")),
claim_until=parse_datetime(data.get("claim_until")),
lapse_warned=data.get("lapse_warned") is True,
completion_id=str(data.get("completion_id", "") or ""),
points_awarded=max(0, _safe_int(data.get("points_awarded", 0))),
lapse_count=max(0, _safe_int(data.get("lapse_count", 0))),
claim_count=_bounty_claim_count(data),
created_at=parse_datetime(data.get("created_at")),
closed_at=parse_datetime(data.get("closed_at")),
id=data.get("id") or generate_id(),
)
def to_dict(self) -> dict[str, Any]:
return {
"title": self.title,
"points": self.points,
"description": self.description,
"icon": self.icon,
"expires_at": format_datetime(self.expires_at),
"eligible_child_ids": list(self.eligible_child_ids),
"claim_hours": self.claim_hours,
"require_photo": self.require_photo,
"notify_children": self.notify_children,
"status": self.status,
"claimed_by": self.claimed_by,
"claimed_at": format_datetime(self.claimed_at),
"claim_until": format_datetime(self.claim_until),
"lapse_warned": self.lapse_warned,
"completion_id": self.completion_id,
"points_awarded": self.points_awarded,
"lapse_count": self.lapse_count,
"claim_count": self.claim_count,
"created_at": format_datetime(self.created_at),
"closed_at": format_datetime(self.closed_at),
"id": self.id,
}
def is_eligible(self, child_id: str) -> bool:
return not self.eligible_child_ids or child_id in self.eligible_child_ids
def clear_claim(self) -> None:
self.claimed_by = ""
self.claimed_at = None
self.claim_until = None
self.lapse_warned = False
self.completion_id = ""
def _clean_auction_bids(raw: Any) -> dict[str, dict[str, Any]]:
"""Coerce stored bids to {child_id: {"points": int >= 1, "at": ISO}}."""
out: dict[str, dict[str, Any]] = {}
if not isinstance(raw, dict):
return out
for child_id, bid in raw.items():
if not child_id or not isinstance(bid, dict):
continue
points = _safe_int(bid.get("points", 0))
at = parse_datetime(bid.get("at"))
if points < 1 or at is None:
continue
out[str(child_id)] = {"points": points, "at": format_datetime(at)}
return out
@dataclass
class Auction:
"""A reverse auction on one occurrence of a chore (#982).
A parent opens it on a scheduled date of an existing chore with a maximum
price and a closing time. Eligible children place sealed bids — the fewest
points they would do it for. At closing the lowest bid wins (the earliest
bid on a tie; changing a bid re-times it) and the winner is assigned that
one occurrence at their price: ``winner_id`` / ``price`` are what the
assignment and completion paths read. No bids leaves the chore on its
normal assignment. Bids are secret: only a parent ever sees ``bids``.
"""
chore_id: str
occurrence: str # ISO date of the auctioned occurrence
max_points: int = 1
min_points: int = 1
closes_at: datetime | None = None
eligible_child_ids: list[str] = field(default_factory=list)
notify_children: bool = True
status: str = "open" # open | closed | cancelled
bids: dict[str, dict[str, Any]] = field(default_factory=dict)
winner_id: str = ""
price: int = 0
reminder_sent: bool = False # the "closes within the hour" push went out
# What the chore was called and worth when the auction opened, so the
# history still reads right after the chore is renamed or deleted.
chore_name: str = ""
chore_points: int = 0
created_at: datetime | None = None
closed_at: datetime | None = None
id: str = field(default_factory=generate_id)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Auction:
max_points = max(1, _safe_int(data.get("max_points", 1), 1))
return cls(
chore_id=str(data.get("chore_id", "") or ""),
occurrence=str(data.get("occurrence", "") or ""),
max_points=max_points,
min_points=min(max_points, max(1, _safe_int(data.get("min_points", 1), 1))),
closes_at=parse_datetime(data.get("closes_at")),
eligible_child_ids=[str(c) for c in (data.get("eligible_child_ids") or []) if c],
notify_children=data.get("notify_children", True) is not False,
status=data.get("status") if data.get("status") in AUCTION_STATUSES else "open",
bids=_clean_auction_bids(data.get("bids")),
winner_id=str(data.get("winner_id", "") or ""),
price=max(0, _safe_int(data.get("price", 0))),
reminder_sent=data.get("reminder_sent") is True,
chore_name=str(data.get("chore_name", "") or ""),
chore_points=max(0, _safe_int(data.get("chore_points", 0))),
created_at=parse_datetime(data.get("created_at")),
closed_at=parse_datetime(data.get("closed_at")),
id=data.get("id") or generate_id(),
)
def to_dict(self) -> dict[str, Any]:
return {
"chore_id": self.chore_id,
"occurrence": self.occurrence,
"max_points": self.max_points,
"min_points": self.min_points,
"closes_at": format_datetime(self.closes_at),
"eligible_child_ids": list(self.eligible_child_ids),
"notify_children": self.notify_children,
"status": self.status,
"bids": {k: dict(v) for k, v in self.bids.items()},
"winner_id": self.winner_id,
"price": self.price,
"reminder_sent": self.reminder_sent,
"chore_name": self.chore_name,
"chore_points": self.chore_points,
"created_at": format_datetime(self.created_at),
"closed_at": format_datetime(self.closed_at),
"id": self.id,
}
def ranked_bids(self) -> list[tuple[str, int, datetime]]:
"""(child_id, points, placed_at), winner first: lowest, then earliest."""
ranked = []
for child_id, bid in self.bids.items():
at = parse_datetime(bid.get("at"))
if at is not None:
ranked.append((child_id, int(bid.get("points", 0)), at))
ranked.sort(key=lambda b: (b[1], b[2]))
return ranked
@dataclass
class ChoreCompletion:
"""Represents a chore completion record."""
@@ -751,6 +1095,16 @@ class ChoreCompletion:
# and weekend multipliers that _award_points adds on top. None on records
# written before the field existed.
submitted_points: int | None = None
# Parent's 1-3 star quality rating given at approval (#927); 0 = unrated.
quality_rating: int = 0
# True only on a completion a child submitted themselves, so the child
# may take it back inside the global undo window (#918). A parent's
# review clears it for good, and records written before the field existed
# load as False: they stay parent-only.
child_undo_allowed: bool = False
# Set when this completion is a bounty's (#931). chore_id then carries the
# bounty id too, so nothing that groups completions by chore mixes the two.
bounty_id: str = ""
@classmethod
def from_dict(cls, data: dict[str, Any]) -> ChoreCompletion:
@@ -772,11 +1126,14 @@ class ChoreCompletion:
suggested_points=int(data.get("suggested_points", 0) or 0),
id=data.get("id") or generate_id(),
submitted_points=parse_optional_points(data.get("submitted_points")),
quality_rating=parse_quality_rating(data.get("quality_rating")),
child_undo_allowed=data.get("child_undo_allowed") is True,
bounty_id=str(data.get("bounty_id", "") or ""),
)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
return {
data = {
"chore_id": self.chore_id,
"child_id": self.child_id,
"completed_at": format_datetime(self.completed_at),
@@ -790,7 +1147,15 @@ class ChoreCompletion:
"suggested_points": self.suggested_points,
"id": self.id,
"submitted_points": self.submitted_points,
"quality_rating": self.quality_rating,
}
# Written only when set: every completion ever made is stored, and the
# flag is False on nearly all of them.
if self.child_undo_allowed:
data["child_undo_allowed"] = True
if self.bounty_id:
data["bounty_id"] = self.bounty_id
return data
@dataclass
@@ -855,6 +1220,9 @@ class RewardClaim:
# What this purchase actually cost, recorded when it was approved. None on
# a pending claim, and on claims approved before the field existed.
approved_cost: int | None = None
# Set when the claim redeems a wishlist wish (#932) rather than a reward.
# reward_id then holds the wish id too, and no Reward record exists.
wish_id: str = ""
@classmethod
def from_dict(cls, data: dict[str, Any]) -> RewardClaim:
@@ -870,11 +1238,12 @@ class RewardClaim:
approved_at=approved_at,
id=data.get("id") or generate_id(),
approved_cost=parse_optional_points(data.get("approved_cost")),
wish_id=str(data.get("wish_id", "") or ""),
)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
return {
out = {
"reward_id": self.reward_id,
"child_id": self.child_id,
"claimed_at": format_datetime(self.claimed_at),
@@ -883,6 +1252,10 @@ class RewardClaim:
"id": self.id,
"approved_cost": self.approved_cost,
}
# Only a wishlist redemption carries it, so ordinary claims keep their shape.
if self.wish_id:
out["wish_id"] = self.wish_id
return out
@dataclass
@@ -1396,3 +1769,182 @@ class CustomSound:
"file": self.file,
"created_at": self.created_at,
}
# ── Wishlist with pledges (#932) ─────────────────────────────────────────
WISH_NAME_MAX = 60
WISH_LINK_MAX = 500
WISH_DECLINE_REASON_MAX = 200
# Why a parent rejected a chore or reward claim (#976) — same cap as a wish's
# decline reason, which it mirrors.
REJECT_REASON_MAX = WISH_DECLINE_REASON_MAX
PLEDGE_NAME_MAX = 40
PLEDGE_MESSAGE_MAX = 140
WISH_STATUSES = ("pending", "active", "redeem_requested", "redeemed", "declined")
def clean_label(value: Any, limit: int) -> str:
"""Single-line, trimmed, length-capped text for a user-supplied label."""
text = " ".join(str(value or "").split())
return text[:limit]
def clean_wish_link(value: Any) -> str:
"""Return ``value`` if it is a plain http(s) URL, else "".
A wish link is typed by a child and rendered as a clickable link, so
anything that is not an absolute http(s) URL — ``javascript:``, ``data:``,
a relative path, embedded whitespace or control characters — is dropped
rather than stored.
"""
from urllib.parse import urlsplit
text = str(value or "").strip()
if not text or len(text) > WISH_LINK_MAX:
return ""
if any(ch.isspace() or ord(ch) < 32 or ord(ch) == 127 for ch in text):
return ""
try:
parts = urlsplit(text)
except ValueError:
return ""
if parts.scheme.lower() not in ("http", "https") or not parts.netloc:
return ""
return text
def _clean_wish_image(value: Any) -> str:
"""Keep only one of TaskMate's own stored-image URLs (bare, unsigned)."""
from .images import normalize_taskmate_image_url
return normalize_taskmate_image_url(str(value or "")) or ""
def _points(value: Any) -> int:
try:
number = int(value)
except (TypeError, ValueError):
return 0
return max(0, number)
@dataclass
class WishPledge:
"""Points a relative chipped in towards a wish (#932).
Pledged points are a gift attached to the wish: they are never added to
the child's balance, so the only way they turn into anything is by the
wish being redeemed. ``name`` is free text typed by a parent ("Grandma").
"""
name: str
points: int
message: str = ""
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
id: str = field(default_factory=generate_id)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> WishPledge:
return cls(
name=clean_label(data.get("name"), PLEDGE_NAME_MAX),
points=_points(data.get("points")),
message=clean_label(data.get("message"), PLEDGE_MESSAGE_MAX),
created_at=parse_datetime(data.get("created_at")) or datetime.now(timezone.utc),
id=data.get("id") or generate_id(),
)
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"points": self.points,
"message": self.message,
"created_at": format_datetime(self.created_at),
"id": self.id,
}
@dataclass
class Wish:
"""Something a child is saving towards (#932).
Lifecycle: ``pending`` (added by the child, waiting for a parent) →
``active`` (approved; the child can move points in and relatives can
pledge) → ``redeem_requested`` (funded, a reward claim is waiting) →
``redeemed``. A parent can ``decline`` it instead, with a reason the child
sees. ``saved`` are the child's own points moved in — already taken out of
``Child.points``, exactly like a savings-jar allocation — and are the only
part a child can take back out.
"""
child_id: str
name: str
target: int
status: str = "pending"
suggested_target: int = 0
link: str = ""
image_url: str = ""
saved: int = 0
pledges: list[WishPledge] = field(default_factory=list)
decline_reason: str = ""
claim_id: str = ""
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
approved_at: datetime | None = None
declined_at: datetime | None = None
redeemed_at: datetime | None = None
id: str = field(default_factory=generate_id)
@property
def pledged(self) -> int:
return sum(p.points for p in self.pledges)
@property
def remaining(self) -> int:
"""Points still needed to reach the target (never negative)."""
return max(0, self.target - self.saved - self.pledged)
@property
def funded(self) -> bool:
return self.target > 0 and self.saved + self.pledged >= self.target
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Wish:
status = data.get("status", "pending")
target = _points(data.get("target"))
return cls(
child_id=str(data.get("child_id", "") or ""),
name=clean_label(data.get("name"), WISH_NAME_MAX),
target=target,
status=status if status in WISH_STATUSES else "pending",
suggested_target=_points(data.get("suggested_target")) or target,
link=clean_wish_link(data.get("link")),
image_url=_clean_wish_image(data.get("image_url")),
saved=_points(data.get("saved")),
pledges=[WishPledge.from_dict(p) for p in data.get("pledges", []) or [] if isinstance(p, dict)],
decline_reason=clean_label(data.get("decline_reason"), WISH_DECLINE_REASON_MAX),
claim_id=str(data.get("claim_id", "") or ""),
created_at=parse_datetime(data.get("created_at")) or datetime.now(timezone.utc),
approved_at=parse_datetime(data.get("approved_at")),
declined_at=parse_datetime(data.get("declined_at")),
redeemed_at=parse_datetime(data.get("redeemed_at")),
id=data.get("id") or generate_id(),
)
def to_dict(self) -> dict[str, Any]:
return {
"child_id": self.child_id,
"name": self.name,
"target": self.target,
"status": self.status,
"suggested_target": self.suggested_target,
"link": self.link,
"image_url": self.image_url,
"saved": self.saved,
"pledges": [p.to_dict() for p in self.pledges],
"decline_reason": self.decline_reason,
"claim_id": self.claim_id,
"created_at": format_datetime(self.created_at),
"approved_at": format_datetime(self.approved_at),
"declined_at": format_datetime(self.declined_at),
"redeemed_at": format_datetime(self.redeemed_at),
"id": self.id,
}