590 lines
25 KiB
Python
590 lines
25 KiB
Python
"""Reward operations mixin for TaskMateCoordinator."""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import date
|
||
from typing import TYPE_CHECKING
|
||
|
||
from homeassistant.util import dt as dt_util
|
||
|
||
from .models import PointsTransaction, PoolAllocation, Reward, RewardClaim
|
||
|
||
if TYPE_CHECKING:
|
||
pass
|
||
|
||
_LOGGER = logging.getLogger(__name__)
|
||
|
||
|
||
class RewardsMixin:
|
||
"""Mixin providing reward CRUD, claiming, and pool allocation logic."""
|
||
|
||
def get_reward(self, reward_id: str) -> Reward | None:
|
||
"""Get a reward by ID."""
|
||
return self.storage.get_reward(reward_id)
|
||
|
||
def is_pool_mode_claim(self, claim: RewardClaim) -> bool:
|
||
"""True if the claim is covered by pool allocations (points already deducted).
|
||
|
||
Pool-mode claims must NOT be counted against a child's spendable balance,
|
||
because their cost was already removed from child.points at allocation time.
|
||
"""
|
||
reward = self.get_reward(claim.reward_id)
|
||
if not reward:
|
||
return False
|
||
if getattr(reward, "is_jackpot", False):
|
||
return self.storage.get_total_allocated_for_reward(claim.reward_id) >= reward.cost
|
||
alloc = self.storage.get_pool_allocation(claim.child_id, claim.reward_id)
|
||
return bool(alloc and alloc.allocated_points >= reward.cost)
|
||
|
||
async def async_add_reward(
|
||
self,
|
||
name: str,
|
||
cost: int = 50,
|
||
description: str = "",
|
||
icon: str = "mdi:gift",
|
||
assigned_to: list[str] | None = None,
|
||
is_jackpot: bool = False,
|
||
pool_enabled: bool = False,
|
||
quantity: int | None = None,
|
||
expires_at: str | None = None,
|
||
) -> Reward:
|
||
"""Add a new reward."""
|
||
reward = Reward(
|
||
name=name,
|
||
cost=cost,
|
||
description=description,
|
||
icon=icon,
|
||
assigned_to=assigned_to or [],
|
||
is_jackpot=is_jackpot,
|
||
pool_enabled=pool_enabled, # Reward.__post_init__ forces this on for jackpots (#552)
|
||
quantity=quantity,
|
||
expires_at=expires_at,
|
||
)
|
||
self.storage.add_reward(reward)
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
return reward
|
||
|
||
async def async_update_reward(self, reward: Reward) -> None:
|
||
"""Update a reward.
|
||
|
||
If the cost is reduced below any existing pool allocation, the excess is
|
||
refunded to the contributing children's wallets so over-allocated pools
|
||
can't appear as e.g. 11/10. If the edit makes the reward unavailable
|
||
(quantity set to 0, or expires_at moved into the past) any pool
|
||
allocations on that reward are refunded in full.
|
||
"""
|
||
old = self.get_reward(reward.id)
|
||
# Jackpots are always pool-mode (#552); keep stored data consistent.
|
||
if reward.is_jackpot:
|
||
reward.pool_enabled = True
|
||
self.storage.update_reward(reward)
|
||
if old and reward.cost < old.cost:
|
||
self._refund_pool_excess(reward, "Pool refund (reward cost reduced)")
|
||
became_unavailable = (
|
||
self._reward_is_unavailable(reward)
|
||
and old is not None
|
||
and not self._reward_is_unavailable(old)
|
||
)
|
||
if became_unavailable:
|
||
reason = (
|
||
"Pool refund (reward expired)"
|
||
if self._reward_is_expired(reward)
|
||
else "Pool refund (reward sold out)"
|
||
)
|
||
self._refund_all_pool_allocations(reward, reason)
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
|
||
async def async_remove_reward(self, reward_id: str) -> None:
|
||
"""Remove a reward and clean up any pending claims and pool allocations referencing it."""
|
||
self.storage.remove_reward_claims_for_reward(reward_id)
|
||
# Refund any earmarked pool points back to their contributors before the
|
||
# allocations are dropped — otherwise the points deducted at allocation
|
||
# time would be silently lost (#564). Mirrors the expiry/sold-out paths.
|
||
reward = self.get_reward(reward_id)
|
||
if reward:
|
||
self._refund_all_pool_allocations(reward, "Pool refund (reward deleted)")
|
||
self.storage.remove_pool_allocations_for_reward(reward_id)
|
||
self.storage.remove_reward(reward_id)
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
|
||
@staticmethod
|
||
def _reward_is_sold_out(reward: Reward) -> bool:
|
||
"""True if the reward has a stock count and it's been exhausted."""
|
||
return reward.quantity is not None and reward.quantity <= 0
|
||
|
||
@staticmethod
|
||
def _reward_is_expired(reward: Reward) -> bool:
|
||
"""True if the reward has an expiry date and it's on/before today."""
|
||
if not reward.expires_at:
|
||
return False
|
||
try:
|
||
deadline = date.fromisoformat(reward.expires_at)
|
||
except (TypeError, ValueError):
|
||
return False
|
||
return deadline <= dt_util.now().date()
|
||
|
||
@classmethod
|
||
def _reward_is_unavailable(cls, reward: Reward) -> bool:
|
||
"""True if the reward cannot currently be claimed or allocated to."""
|
||
return cls._reward_is_sold_out(reward) or cls._reward_is_expired(reward)
|
||
|
||
def _refund_all_pool_allocations(self, reward: Reward, reason: str) -> None:
|
||
"""Refund every pool allocation on `reward` back to its contributor.
|
||
|
||
Used when a reward becomes unavailable (sold out or expired) while
|
||
children still have points earmarked for it. Reuses the existing
|
||
per-allocation refund helper so the PointsTransaction audit trail
|
||
stays consistent with cost-reduction refunds.
|
||
"""
|
||
allocations = [
|
||
a for a in self.storage.get_pool_allocations()
|
||
if a.reward_id == reward.id and a.allocated_points > 0
|
||
]
|
||
for alloc in allocations:
|
||
self._apply_pool_refund(alloc, alloc.allocated_points, reward, reason)
|
||
|
||
def _refund_pool_excess(self, reward: Reward, reason: str) -> None:
|
||
"""Trim any pool allocations on `reward` that exceed its cost.
|
||
|
||
Non-jackpot: each allocation is capped at the reward's cost individually.
|
||
Jackpot: allocations are trimmed starting from the newest contributor
|
||
until the combined total matches the cost.
|
||
"""
|
||
allocations = [
|
||
a for a in self.storage.get_pool_allocations()
|
||
if a.reward_id == reward.id and a.allocated_points > 0
|
||
]
|
||
if not allocations:
|
||
return
|
||
|
||
if reward.is_jackpot:
|
||
overshoot = sum(a.allocated_points for a in allocations) - reward.cost
|
||
if overshoot <= 0:
|
||
return
|
||
for alloc in sorted(allocations, key=lambda a: a.id, reverse=True):
|
||
if overshoot <= 0:
|
||
break
|
||
refund = min(alloc.allocated_points, overshoot)
|
||
self._apply_pool_refund(alloc, refund, reward, reason)
|
||
overshoot -= refund
|
||
else:
|
||
for alloc in allocations:
|
||
if alloc.allocated_points > reward.cost:
|
||
self._apply_pool_refund(
|
||
alloc, alloc.allocated_points - reward.cost, reward, reason
|
||
)
|
||
|
||
def _apply_pool_refund(
|
||
self, allocation: PoolAllocation, refund: int, reward: Reward, reason: str
|
||
) -> None:
|
||
"""Refund `refund` points from `allocation` back to the child's wallet.
|
||
|
||
Updates or removes the allocation record and writes an audit transaction.
|
||
"""
|
||
if refund <= 0:
|
||
return
|
||
child = self.get_child(allocation.child_id)
|
||
if not child:
|
||
return
|
||
child.points += refund
|
||
self.storage.update_child(child)
|
||
|
||
remaining = allocation.allocated_points - refund
|
||
if remaining <= 0:
|
||
self.storage.remove_pool_allocation(allocation.child_id, allocation.reward_id)
|
||
else:
|
||
self.storage.upsert_pool_allocation(PoolAllocation(
|
||
child_id=allocation.child_id,
|
||
reward_id=allocation.reward_id,
|
||
allocated_points=remaining,
|
||
id=allocation.id,
|
||
))
|
||
|
||
self.storage.add_points_transaction(PointsTransaction(
|
||
child_id=allocation.child_id,
|
||
points=refund,
|
||
reason=f"{reason}: {reward.name}",
|
||
created_at=dt_util.now(),
|
||
))
|
||
|
||
async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim:
|
||
"""Child claims a reward — creates a pending claim awaiting parent approval.
|
||
|
||
Two modes are supported:
|
||
* Wallet mode (default): requires child.points (minus committed) to cover cost
|
||
* Pool mode: if pool allocations exist for this (child, reward) and they fill the
|
||
reward's cost, the claim is a "redeem" — no wallet check needed. For jackpot
|
||
rewards the pool total across all contributing children must reach the cost.
|
||
"""
|
||
reward = self.get_reward(reward_id)
|
||
if not reward:
|
||
raise ValueError(f"Reward {reward_id} not found")
|
||
|
||
child = self.get_child(child_id)
|
||
if not child:
|
||
raise ValueError(f"Child {child_id} not found")
|
||
|
||
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
|
||
|
||
# Detect pool mode: a filled pool allocation for this (child, reward) is sufficient,
|
||
# or for jackpots the summed pool across all children reaches cost.
|
||
pool_filled = False
|
||
if reward.is_jackpot:
|
||
pool_total = self.storage.get_total_allocated_for_reward(reward_id)
|
||
if pool_total >= effective_cost:
|
||
pool_filled = True
|
||
else:
|
||
allocation = self.storage.get_pool_allocation(child_id, reward_id)
|
||
if allocation and allocation.allocated_points >= effective_cost:
|
||
pool_filled = True
|
||
|
||
if not pool_filled:
|
||
# Wallet mode: verify child has enough uncommitted points.
|
||
# Pool-mode pending claims already had their cost deducted at allocation time,
|
||
# so they are skipped here to avoid double-counting against the wallet.
|
||
pending_claims = self.storage.get_pending_reward_claims()
|
||
committed = 0
|
||
for c in pending_claims:
|
||
if c.child_id == child_id and not self.is_pool_mode_claim(c):
|
||
pending_reward = self.get_reward(c.reward_id)
|
||
if pending_reward:
|
||
committed += pending_reward.cost
|
||
available_points = child.points - committed
|
||
|
||
if available_points < effective_cost:
|
||
raise ValueError(
|
||
f"Not enough points. Need {effective_cost}, have {available_points} available"
|
||
)
|
||
|
||
claim = RewardClaim(
|
||
reward_id=reward_id,
|
||
child_id=child_id,
|
||
claimed_at=dt_util.now(),
|
||
)
|
||
|
||
self.storage.add_reward_claim(claim)
|
||
|
||
self.hass.bus.async_fire(
|
||
"taskmate_reward_claimed",
|
||
{
|
||
"child_id": child.id,
|
||
"reward_id": reward.id,
|
||
"claim_id": claim.id,
|
||
"cost": reward.cost,
|
||
"timestamp": dt_util.now().isoformat(),
|
||
},
|
||
)
|
||
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
await self._async_notify_pending_reward_claim(
|
||
child.name, reward.name, reward.cost, claim_id=claim.id,
|
||
)
|
||
return claim
|
||
|
||
def _spend_period_start(self) -> date:
|
||
today = dt_util.now().date()
|
||
period = self.storage.get_setting("spend_cap_period", "weekly")
|
||
if period == "monthly":
|
||
return today.replace(day=1)
|
||
from datetime import timedelta
|
||
return today - timedelta(days=today.weekday()) # Monday of this week
|
||
|
||
def _spent_in_period(self, child_id: str) -> int:
|
||
"""Total reward cost a child has had approved in the current cap period."""
|
||
start = self._spend_period_start()
|
||
reward_cost = {r.id: r.cost for r in self.storage.get_rewards()}
|
||
total = 0
|
||
for claim in self.storage.get_reward_claims():
|
||
if claim.child_id != child_id or not claim.approved:
|
||
continue
|
||
when = claim.approved_at or claim.claimed_at
|
||
if when and dt_util.as_local(when).date() >= start:
|
||
total += reward_cost.get(claim.reward_id, 0)
|
||
return total
|
||
|
||
def _enforce_spend_cap(self, child_id: str, cost: int) -> None:
|
||
"""Raise if approving a `cost` spend would exceed the per-period cap."""
|
||
enabled = self.storage.get_setting("spend_cap_enabled", False)
|
||
if not (enabled is True or str(enabled).lower() == "true"):
|
||
return
|
||
try:
|
||
cap = int(float(self.storage.get_setting("spend_cap_amount", "0")))
|
||
except (ValueError, TypeError):
|
||
cap = 0
|
||
if cap <= 0:
|
||
return
|
||
if self._spent_in_period(child_id) + cost > cap:
|
||
raise ValueError(
|
||
f"Spending cap reached: {cap} per period already used"
|
||
)
|
||
|
||
async def async_approve_reward(self, claim_id: str) -> None:
|
||
"""Approve a reward claim and deduct points from the child.
|
||
|
||
If a pool allocation exists for this (child, reward) pair with enough points,
|
||
the deduction consumes the pool allocation first (pool mode). Otherwise the
|
||
wallet-mode path deducts directly from child.points.
|
||
"""
|
||
claims = self.storage.get_reward_claims()
|
||
for claim in claims:
|
||
if claim.id == claim_id:
|
||
if claim.approved:
|
||
_LOGGER.warning("Reward claim %s already approved, ignoring", claim_id)
|
||
return
|
||
|
||
reward = self.get_reward(claim.reward_id)
|
||
child = self.get_child(claim.child_id)
|
||
if not reward or not child:
|
||
raise ValueError(f"Reward or child not found for claim {claim_id}")
|
||
|
||
# Cost is always static
|
||
effective_cost = reward.cost
|
||
|
||
# Spending cap: block approval if it would exceed the per-period budget.
|
||
self._enforce_spend_cap(claim.child_id, effective_cost)
|
||
|
||
# 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)
|
||
is_pool_mode = False
|
||
if reward.is_jackpot:
|
||
pool_total = self.storage.get_total_allocated_for_reward(claim.reward_id)
|
||
if pool_total >= effective_cost:
|
||
is_pool_mode = True
|
||
elif pool_alloc and pool_alloc.allocated_points >= effective_cost:
|
||
is_pool_mode = True
|
||
|
||
if is_pool_mode:
|
||
# Pool mode: points were already deducted from child.points at allocation
|
||
# time — approving the redeem just clears the allocation record(s).
|
||
# Refund any over-allocation first (e.g. left over from a prior cost reduction)
|
||
# so the child doesn't lose points beyond the reward's actual cost.
|
||
self._refund_pool_excess(reward, "Pool refund on redeem")
|
||
if reward.is_jackpot:
|
||
jackpot_allocs = [
|
||
a for a in self.storage.get_pool_allocations()
|
||
if a.reward_id == claim.reward_id and a.allocated_points > 0
|
||
]
|
||
for alloc in jackpot_allocs:
|
||
self.storage.remove_pool_allocation(alloc.child_id, alloc.reward_id)
|
||
else:
|
||
self.storage.remove_pool_allocation(claim.child_id, claim.reward_id)
|
||
else:
|
||
# Wallet mode: deduct directly from child.points
|
||
if child.points < effective_cost:
|
||
raise ValueError(
|
||
f"Not enough points to approve. Need {effective_cost}, have {child.points}"
|
||
)
|
||
child.points -= effective_cost
|
||
self.storage.update_child(child)
|
||
|
||
if reward.quantity is not None:
|
||
reward.quantity = max(0, reward.quantity - 1)
|
||
self.storage.update_reward(reward)
|
||
if reward.quantity == 0:
|
||
# Last unit claimed — refund any points other children
|
||
# still have earmarked for this reward's pool.
|
||
self._refund_all_pool_allocations(
|
||
reward, "Pool refund (reward sold out)"
|
||
)
|
||
|
||
claim.approved = True
|
||
claim.approved_at = dt_util.now()
|
||
self.storage.update_reward_claim(claim)
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
|
||
# Dismiss the mobile approval push now this claim is reviewed.
|
||
if getattr(self, "notifications", None):
|
||
await self.notifications.clear_approval(
|
||
"pending_reward_claim", claim_id
|
||
)
|
||
|
||
self.hass.bus.async_fire("taskmate_reward_approved", {
|
||
"child_id": child.id, "child_name": child.name,
|
||
"reward_id": reward.id, "reward_name": reward.name,
|
||
"cost": effective_cost,
|
||
"timestamp": dt_util.now().isoformat(),
|
||
})
|
||
|
||
if getattr(self, "badges", None):
|
||
await self.badges.evaluate_for_child(claim.child_id, "reward_redeemed")
|
||
|
||
return
|
||
_LOGGER.warning("Reward claim %s not found for approval", claim_id)
|
||
|
||
async def async_reject_reward(self, claim_id: str) -> None:
|
||
"""Reject a reward claim — no refund needed as points were never deducted."""
|
||
claim = next((c for c in self.storage.get_reward_claims() if c.id == claim_id), None)
|
||
self.storage.remove_reward_claim(claim_id)
|
||
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", {
|
||
"child_id": claim.child_id,
|
||
"child_name": getattr(child, "name", ""),
|
||
"reward_id": claim.reward_id,
|
||
"reward_name": getattr(reward, "name", ""),
|
||
"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
|
||
)
|
||
|
||
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.
|
||
|
||
Deducts immediately from child.points so the visible balance reflects the
|
||
commitment. The matching PoolAllocation record tracks the earmarked total
|
||
for each (child, reward) pair. Requested points are capped silently at the
|
||
pool's remaining capacity and the child's spendable balance.
|
||
Allocations are locked — there is no matching "withdraw" operation.
|
||
"""
|
||
child = self.get_child(child_id)
|
||
if not child:
|
||
raise ValueError(f"Child {child_id} not found")
|
||
|
||
reward = self.get_reward(reward_id)
|
||
if not reward:
|
||
raise ValueError(f"Reward {reward_id} not found")
|
||
|
||
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 points < 1:
|
||
raise ValueError("Points to allocate must be at least 1")
|
||
|
||
# Spendable balance = child.points − points committed to other pending claims.
|
||
# (Already-allocated points are no longer part of child.points, so we do NOT
|
||
# subtract total_allocated here — they've been deducted at allocation time.)
|
||
# Pool-mode pending claims are also skipped — their cost was already removed
|
||
# from child.points at allocation time, so counting it again would block the
|
||
# child from allocating to any other pool reward while one awaits approval.
|
||
pending_claims = self.storage.get_pending_reward_claims()
|
||
committed = 0
|
||
for c in pending_claims:
|
||
if c.child_id == child_id and not self.is_pool_mode_claim(c):
|
||
pending_reward = self.get_reward(c.reward_id)
|
||
if pending_reward:
|
||
committed += pending_reward.cost
|
||
spendable = child.points - committed
|
||
|
||
if spendable < 1:
|
||
raise ValueError(f"No spendable points available for {child.name}")
|
||
|
||
# Compute remaining pool capacity
|
||
existing = self.storage.get_pool_allocation(child_id, reward_id)
|
||
current_child_allocation = existing.allocated_points if existing else 0
|
||
if reward.is_jackpot:
|
||
room_left = reward.cost - self.storage.get_total_allocated_for_reward(reward_id)
|
||
else:
|
||
room_left = reward.cost - current_child_allocation
|
||
|
||
if room_left <= 0:
|
||
raise ValueError(f"Pool for reward '{reward.name}' is already full")
|
||
|
||
capped_points = min(points, spendable, room_left)
|
||
|
||
# Deduct from the visible balance; the allocation record holds the earmarked points.
|
||
child.points -= capped_points
|
||
self.storage.update_child(child)
|
||
|
||
allocation = PoolAllocation(
|
||
child_id=child_id,
|
||
reward_id=reward_id,
|
||
allocated_points=current_child_allocation + capped_points,
|
||
id=existing.id if existing else PoolAllocation(child_id, reward_id).id,
|
||
)
|
||
self.storage.upsert_pool_allocation(allocation)
|
||
|
||
# Audit trail: negative transaction showing the deduction
|
||
transaction = PointsTransaction(
|
||
child_id=child_id,
|
||
points=-capped_points,
|
||
reason=f"Allocated to pool: {reward.name}",
|
||
created_at=dt_util.now(),
|
||
)
|
||
self.storage.add_points_transaction(transaction)
|
||
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
return allocation
|
||
|
||
async def _async_restock_rewards(self) -> None:
|
||
"""Refill `quantity` to restock_amount on the period boundary.
|
||
|
||
daily → every day; weekly → Mondays; monthly → the 1st. A
|
||
``restock_last`` stamp guards against restocking twice in a day.
|
||
"""
|
||
from homeassistant.util import dt as dt_util
|
||
today = dt_util.now().date()
|
||
today_iso = today.isoformat()
|
||
changed = False
|
||
for reward in self.storage.get_rewards():
|
||
if not getattr(reward, "restock_enabled", False):
|
||
continue
|
||
if int(getattr(reward, "restock_amount", 0) or 0) <= 0:
|
||
continue
|
||
if getattr(reward, "restock_last", "") == today_iso:
|
||
continue
|
||
period = getattr(reward, "restock_period", "weekly")
|
||
due = (
|
||
period == "daily"
|
||
or (period == "weekly" and today.weekday() == 0)
|
||
or (period == "monthly" and today.day == 1)
|
||
)
|
||
if not due:
|
||
continue
|
||
reward.quantity = int(reward.restock_amount)
|
||
reward.restock_last = today_iso
|
||
self.storage.update_reward(reward)
|
||
changed = True
|
||
_LOGGER.info("Restocked reward '%s' to %d", reward.name, reward.quantity)
|
||
if changed:
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
|
||
async def _async_expire_rewards(self) -> None:
|
||
"""Refund pool allocations on any reward whose expires_at is past.
|
||
|
||
The reward row itself is kept in storage so the sensor can surface the
|
||
"Expired" state and existing claim history stays intact.
|
||
"""
|
||
changed = False
|
||
for reward in self.storage.get_rewards():
|
||
if not self._reward_is_expired(reward):
|
||
continue
|
||
allocations_before = [
|
||
a for a in self.storage.get_pool_allocations()
|
||
if a.reward_id == reward.id and a.allocated_points > 0
|
||
]
|
||
if not allocations_before:
|
||
continue
|
||
self._refund_all_pool_allocations(reward, "Pool refund (reward expired)")
|
||
changed = True
|
||
_LOGGER.info(
|
||
"Reward '%s' expired on %s — refunded %d pool allocation(s)",
|
||
reward.name, reward.expires_at, len(allocations_before),
|
||
)
|
||
|
||
if changed:
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|