278 files

This commit is contained in:
Home Assistant Version Control
2026-09-13 20:38:58 +00:00
parent d7a0a08372
commit 1b4f5f68c6
278 changed files with 36892 additions and 7150 deletions
+78 -2
View File
@@ -3,18 +3,46 @@
from __future__ import annotations
import logging
from datetime import date
from datetime import date, datetime
from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util
from .models import PointsTransaction, PoolAllocation, Reward, RewardClaim
from .timewindow import has_window, is_within_window
if TYPE_CHECKING:
pass
_LOGGER = logging.getLogger(__name__)
# Upper bound on a single child's pending reward claims. Each claim writes a
# permanent record and pushes a notification to every routed parent, so an
# unbounded queue is a way to bury the approvals list and spam phones. Mirrors
# the pending-swap-request cap.
_MAX_PENDING_CLAIMS_PER_CHILD = 20
def reward_is_time_locked(reward: Reward, now: datetime | None = None) -> bool:
"""True if a time-locked reward is currently outside its allowed window (#857).
The lock has two independent parts, both optional: a set of weekdays and
a time-of-day window. Whatever is configured must pass. An empty day list
means every day; an unusable time window (blank, malformed, or equal
bounds) means any time. The weekday is read from the local day `now`
falls on, so an overnight window on a day-restricted reward only covers
the part of it before midnight.
"""
if not getattr(reward, "time_lock_enabled", False):
return False
moment = now or dt_util.now()
days = getattr(reward, "available_days", None) or []
if days and moment.weekday() not in days:
return True
start = getattr(reward, "available_from", "") or ""
end = getattr(reward, "available_until", "") or ""
return has_window(start, end) and not is_within_window(start, end, moment)
class RewardsMixin:
"""Mixin providing reward CRUD, claiming, and pool allocation logic."""
@@ -123,9 +151,29 @@ class RewardsMixin:
return False
return deadline <= dt_util.now().date()
# Module-level so sensor.py can reuse the same rule when building state.
_reward_is_time_locked = staticmethod(reward_is_time_locked)
@staticmethod
def _reward_is_for_child(reward: Reward, child_id: str) -> bool:
"""True if ``child_id`` is allowed this reward.
An empty ``assigned_to`` means "everyone", matching how the cards build
their reward list. The cards filter on this, so the coordinator has to
as well — otherwise a direct service call can claim or save towards a
reward meant for a sibling.
"""
assigned = reward.assigned_to if isinstance(reward.assigned_to, list) else []
return not assigned or child_id in assigned
@classmethod
def _reward_is_unavailable(cls, reward: Reward) -> bool:
"""True if the reward cannot currently be claimed or allocated to."""
"""True if the reward is permanently out of reach — sold out or expired.
Deliberately excludes the time lock: this predicate drives pool refunds,
and a time lock is temporary, so folding it in would refund every saver's
points the moment a window closed.
"""
return cls._reward_is_sold_out(reward) or cls._reward_is_expired(reward)
def _refund_all_pool_allocations(self, reward: Reward, reason: str) -> None:
@@ -222,10 +270,24 @@ class RewardsMixin:
if not child:
raise ValueError(f"Child {child_id} not found")
if not self._reward_is_for_child(reward, child_id):
raise ValueError(f"Reward '{reward.name}' is not available to {child.name}")
if self._reward_is_sold_out(reward):
raise ValueError(f"Reward '{reward.name}' is sold out")
if self._reward_is_expired(reward):
raise ValueError(f"Reward '{reward.name}' has expired")
if self._reward_is_time_locked(reward):
raise ValueError(f"Reward '{reward.name}' is not available right now")
# A pool-filled claim skips the wallet check below, and a zero-cost
# reward can never fail it, so without these two guards a child can
# queue unlimited claims — each one a stored record plus a push to
# every parent.
own_pending = [c for c in self.storage.get_pending_reward_claims() if c.child_id == child_id]
if any(c.reward_id == reward_id for c in own_pending):
raise ValueError(f"A claim for '{reward.name}' is already waiting for approval")
if len(own_pending) >= _MAX_PENDING_CLAIMS_PER_CHILD:
raise ValueError("Too many reward claims are already waiting for approval")
# Cost is always static
effective_cost = reward.cost
@@ -342,6 +404,18 @@ class RewardsMixin:
if not reward or not child:
raise ValueError(f"Reward or child not found for claim {claim_id}")
# Stock and expiry are checked when the claim is made, but a
# claim can sit in the queue for days and several can stack up
# against the same item. Re-check here or approving them in
# turn hands out more units than exist (the counter floors at
# zero, so it doesn't even show). The time lock is deliberately
# not re-checked: approval is the parent's call, not the
# child's, so an out-of-hours approval is legitimate.
if self._reward_is_sold_out(reward):
raise ValueError(f"Reward '{reward.name}' is sold out")
if self._reward_is_expired(reward):
raise ValueError(f"Reward '{reward.name}' has expired")
# Cost is always static
effective_cost = reward.cost
@@ -462,6 +536,8 @@ class RewardsMixin:
if not reward:
raise ValueError(f"Reward {reward_id} not found")
if not self._reward_is_for_child(reward, child_id):
raise ValueError(f"Reward '{reward.name}' is not available to {child.name}")
if self._reward_is_sold_out(reward):
raise ValueError(f"Reward '{reward.name}' is sold out")
if self._reward_is_expired(reward):