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
+60 -4
View File
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util
from .coord_rejections import clean_reject_reason
from .models import Child, PointsTransaction, PoolAllocation, Reward, RewardClaim
from .timewindow import has_window, is_within_window
@@ -60,6 +61,10 @@ class RewardsMixin:
pool has since dipped below the cost — it is still not the claimer's
wallet that is on the hook for it.
"""
# A wishlist redemption (#932) is paid from the wish's own savings,
# which left the child's balance when they were moved in.
if getattr(claim, "wish_id", ""):
return True
reward = self.get_reward(claim.reward_id)
if not reward:
return False
@@ -94,8 +99,9 @@ class RewardsMixin:
pool_enabled: bool = False,
quantity: int | None = None,
expires_at: str | None = None,
refresh: bool = True,
) -> Reward:
"""Add a new reward."""
"""Add a new reward. ``refresh=False`` defers the refresh to a batch."""
reward = Reward(
name=name,
cost=cost,
@@ -109,7 +115,8 @@ class RewardsMixin:
)
self.storage.add_reward(reward)
await self.storage.async_save()
await self.async_refresh()
if refresh:
await self.async_refresh()
return reward
async def async_update_reward(self, reward: Reward) -> None:
@@ -129,6 +136,9 @@ class RewardsMixin:
has invalidated is cancelled with it.
"""
old = self.get_reward(reward.id)
# A streak freeze is bought by one child for themselves (#925).
if getattr(reward, "streak_freeze", False):
reward.is_jackpot = False
# Jackpots are always pool-mode (#552); keep stored data consistent.
if reward.is_jackpot:
reward.pool_enabled = True
@@ -401,6 +411,8 @@ class RewardsMixin:
blocking = [c for c in own_pending if c.reward_id == reward_id]
if blocking:
raise ValueError(f"A claim for '{reward.name}' is already waiting for approval")
if getattr(reward, "streak_freeze", False):
self._require_streak_freeze_room(child, pending_claims=pending)
if len(own_pending) >= _MAX_PENDING_CLAIMS_PER_CHILD:
raise ValueError("Too many reward claims are already waiting for approval")
@@ -438,6 +450,27 @@ class RewardsMixin:
return reward, child
def _require_streak_freeze_room(self, child: Child, pending_claims: list | None = None) -> None:
"""Refuse a streak-freeze purchase the child could not hold (#925).
Counts tokens already held plus streak-freeze claims still waiting for
approval, so a queue of claims can't push the child past the cap.
Pass ``pending_claims=None`` at approval time: the claim being approved
is itself pending, and only the tokens actually held matter then.
"""
cap = self.streak_freeze_max()
if cap <= 0:
raise ValueError("Streak freezes are turned off")
queued = 0
for claim in pending_claims or []:
if claim.child_id != child.id:
continue
queued_reward = self.get_reward(claim.reward_id)
if queued_reward and getattr(queued_reward, "streak_freeze", False):
queued += 1
if (child.streak_freezes or 0) + queued >= cap:
raise ValueError(f"{child.name} already has the maximum of {cap} streak freezes")
async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim:
"""Child claims a reward — creates a pending claim awaiting parent approval.
@@ -523,6 +556,11 @@ class RewardsMixin:
wallet-mode path deducts directly from child.points.
"""
claims = self.storage.get_reward_claims()
# A funded wish's claim (#932) has no Reward behind it.
wish_claim = next((c for c in claims if c.id == claim_id and c.wish_id and not c.approved), None)
if wish_claim is not None:
await self._async_approve_wish_claim(wish_claim)
return
for claim in claims:
if claim.id == claim_id:
if claim.approved:
@@ -551,6 +589,10 @@ class RewardsMixin:
# Spending cap: block approval if it would exceed the per-period budget.
self._enforce_spend_cap(claim.child_id, effective_cost)
# A streak freeze can't be handed over past the cap, whatever
# has happened since the claim was made (#925).
if getattr(reward, "streak_freeze", False):
self._require_streak_freeze_room(child)
# Detect pool mode: either a direct allocation, or a filled jackpot pool.
pool_alloc = self.storage.get_pool_allocation(claim.child_id, claim.reward_id)
@@ -596,6 +638,10 @@ class RewardsMixin:
# still have earmarked for this reward's pool.
self._refund_all_pool_allocations(reward, "Pool refund (reward sold out)")
if getattr(reward, "streak_freeze", False):
child.streak_freezes = (child.streak_freezes or 0) + 1
self.storage.update_child(child)
claim.approved = True
claim.approved_at = dt_util.now()
claim.approved_cost = effective_cost
@@ -649,7 +695,7 @@ class RewardsMixin:
return
_LOGGER.warning("Reward claim %s not found for approval", claim_id)
async def async_reject_reward(self, claim_id: str) -> None:
async def async_reject_reward(self, claim_id: str, reason: str = "") -> None:
"""Reject a *pending* reward claim — no refund needed as points were never deducted.
That "no refund needed" only holds while the claim is pending. Two
@@ -658,17 +704,25 @@ class RewardsMixin:
longer matches storage. Deleting an approved claim there would erase
the purchase from history while its points, its stock and any timed
unlock stayed spent, so the stale review is refused instead.
``reason`` (#976) is the parent's optional "why", shown to the child.
"""
reason = clean_reject_reason(reason)
claim = next((c for c in self.storage.get_reward_claims() if c.id == claim_id), None)
if claim is not None and claim.approved:
reward = self.get_reward(claim.reward_id)
name = reward.name if reward else "This reward"
raise ValueError(f"'{name}' has already been approved and can no longer be rejected")
if claim is not None and claim.wish_id:
await self._async_reject_wish_claim(claim, reason)
return
self.storage.remove_reward_claim(claim_id)
reward = self.get_reward(claim.reward_id) if claim else None
if claim:
self._record_rejection("reward", claim.child_id, claim.reward_id, getattr(reward, "name", ""), reason)
await self.storage.async_save()
await self.async_refresh()
if claim:
reward = self.get_reward(claim.reward_id)
child = self.get_child(claim.child_id)
self.hass.bus.async_fire(
"taskmate_reward_rejected",
@@ -678,12 +732,14 @@ class RewardsMixin:
"reward_id": claim.reward_id,
"reward_name": getattr(reward, "name", ""),
"claim_id": claim.id,
"reason": reason,
"timestamp": dt_util.now().isoformat(),
},
)
# Dismiss the mobile approval push for this reviewed claim.
if getattr(self, "notifications", None):
await self.notifications.clear_approval("pending_reward_claim", claim_id)
await self._async_notify_rejected("reward", child, getattr(reward, "name", ""), reason)
async def async_allocate_points_to_pool(self, child_id: str, reward_id: str, points: int) -> PoolAllocation:
"""Move `points` from a child's spendable balance into a reward pool.