456 files
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user