46 files
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""Assignment operations mixin for TaskMateCoordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -118,9 +119,15 @@ class AssignmentsMixin:
|
||||
await self.storage.async_save()
|
||||
await self.async_refresh()
|
||||
|
||||
_AVAILABLE_STATES: frozenset[str] = frozenset({
|
||||
"on", "home", "available", "present", "true",
|
||||
})
|
||||
_AVAILABLE_STATES: frozenset[str] = frozenset(
|
||||
{
|
||||
"on",
|
||||
"home",
|
||||
"available",
|
||||
"present",
|
||||
"true",
|
||||
}
|
||||
)
|
||||
|
||||
def _is_visibility_entity_active(
|
||||
self, visibility_entity: str, visibility_state: str, visibility_operator: str = "equals"
|
||||
@@ -201,7 +208,7 @@ class AssignmentsMixin:
|
||||
return True
|
||||
|
||||
# Check attributes for a matching value
|
||||
if hasattr(state_obj, 'attributes') and state_obj.attributes:
|
||||
if hasattr(state_obj, "attributes") and state_obj.attributes:
|
||||
for attr_value in state_obj.attributes.values():
|
||||
if str(attr_value).lower() == parsed_state.lower():
|
||||
return True
|
||||
@@ -232,7 +239,8 @@ class AssignmentsMixin:
|
||||
if state_obj is None or state_obj.state in ("unavailable", "unknown", None, ""):
|
||||
_LOGGER.debug(
|
||||
"Weather entity '%s' unavailable, not blocking chore '%s'",
|
||||
entity_id, getattr(chore, "name", ""),
|
||||
entity_id,
|
||||
getattr(chore, "name", ""),
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -352,6 +360,26 @@ class AssignmentsMixin:
|
||||
return cached
|
||||
return self._compute_active_children_uncached(chore, today)
|
||||
|
||||
def _swap_override(self, chore: Chore, today: date | None = None) -> str:
|
||||
"""The child an approved sibling swap moved this chore to for ``today``.
|
||||
|
||||
Returns "" when there is no swap for that date, or when the swapped-to
|
||||
child is no longer in the chore's pool (removed from `assigned_to`, or
|
||||
deleted). Stamped with a date so it expires on its own — a swap is a
|
||||
one-day arrangement, and probes of other days must stay pure rotation.
|
||||
"""
|
||||
swap_date = getattr(chore, "assignment_swap_date", "") or ""
|
||||
swapped_to = getattr(chore, "assignment_swap_child_id", "") or ""
|
||||
if not swap_date or not swapped_to:
|
||||
return ""
|
||||
if today is None:
|
||||
today = dt_util.as_local(dt_util.now()).date()
|
||||
if swap_date != today.isoformat():
|
||||
return ""
|
||||
if swapped_to not in self._chore_assignment_pool(chore):
|
||||
return ""
|
||||
return swapped_to
|
||||
|
||||
def _compute_active_children_uncached(self, chore: Chore, today: date | None = None) -> list[str]:
|
||||
mode = getattr(chore, "assignment_mode", "everyone")
|
||||
require_availability = getattr(chore, "require_availability", False)
|
||||
@@ -359,6 +387,16 @@ class AssignmentsMixin:
|
||||
if mode == "unassigned":
|
||||
return []
|
||||
|
||||
# An approved swap replaces the whole active set for that day, ahead of
|
||||
# every mode's own logic. `require_availability` is deliberately not
|
||||
# re-applied: a parent explicitly approved this child for today, which
|
||||
# outranks an availability entity. "everyone" chores have no single
|
||||
# assignee to move, and async_request_swap already refuses them.
|
||||
if mode != "everyone":
|
||||
swapped_to = self._swap_override(chore, today)
|
||||
if swapped_to:
|
||||
return [swapped_to]
|
||||
|
||||
if mode == "first_come":
|
||||
# Competitive: every child in the resolved pool sees it until the
|
||||
# first completion fills the shared quota (see _is_rotation_done_today).
|
||||
@@ -458,9 +496,7 @@ class AssignmentsMixin:
|
||||
|
||||
return result
|
||||
|
||||
def _apply_sticky_policy(
|
||||
self, group, chore_by_id: dict[str, Chore], result: dict[str, str]
|
||||
) -> None:
|
||||
def _apply_sticky_policy(self, group, chore_by_id: dict[str, Chore], result: dict[str, str]) -> None:
|
||||
"""Force followers onto the leader chore's assignee (when in pool)."""
|
||||
leader_id = group.chore_ids[0]
|
||||
leader_child = result.get(leader_id)
|
||||
@@ -478,12 +514,12 @@ class AssignmentsMixin:
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"STICKY fallback: leader %s assigned to %s not in follower %s pool",
|
||||
leader_id, leader_child, follower_id,
|
||||
leader_id,
|
||||
leader_child,
|
||||
follower_id,
|
||||
)
|
||||
|
||||
def _apply_spread_policy(
|
||||
self, group, chore_by_id: dict[str, Chore], result: dict[str, str]
|
||||
) -> None:
|
||||
def _apply_spread_policy(self, group, chore_by_id: dict[str, Chore], result: dict[str, str]) -> None:
|
||||
"""Assign group members to distinct children; wraps when pool < group size."""
|
||||
used: set[str] = set()
|
||||
for chore_id in group.chore_ids:
|
||||
@@ -529,17 +565,18 @@ class AssignmentsMixin:
|
||||
size = len(pool)
|
||||
# Cache per-call so the same child isn't queried twice in a scan.
|
||||
cache: dict[str, bool] = {}
|
||||
|
||||
def available(cid: str) -> bool:
|
||||
if cid not in cache:
|
||||
cache[cid] = self._is_child_available(cid)
|
||||
return cache[cid]
|
||||
|
||||
for step in range(size):
|
||||
cid = pool[(start_idx + step) % size]
|
||||
if available(cid):
|
||||
return cid
|
||||
_LOGGER.debug(
|
||||
"Availability skip: no available child in pool %s for chore, "
|
||||
"hiding chore (all children unavailable)",
|
||||
"Availability skip: no available child in pool %s for chore, hiding chore (all children unavailable)",
|
||||
pool,
|
||||
)
|
||||
return ""
|
||||
@@ -557,7 +594,7 @@ class AssignmentsMixin:
|
||||
active child still has uncompleted bonus sub-tasks for today, keep
|
||||
the chore visible (return False) so they remain reachable.
|
||||
"""
|
||||
if getattr(chore, 'assignment_mode', 'everyone') == 'everyone':
|
||||
if getattr(chore, "assignment_mode", "everyone") == "everyone":
|
||||
return False
|
||||
# PERF-1: result depends only on the chore; memoize per availability build.
|
||||
cache = getattr(self, "_avail_cache", None)
|
||||
@@ -573,7 +610,7 @@ class AssignmentsMixin:
|
||||
if not pool:
|
||||
return False
|
||||
today = dt_util.as_local(dt_util.now()).date()
|
||||
active_child_id = getattr(chore, 'assignment_current_child_id', '') or ''
|
||||
active_child_id = getattr(chore, "assignment_current_child_id", "") or ""
|
||||
completions_today = 0
|
||||
completed_bonus_ids_today: set[str] = set()
|
||||
for comp in self._cached_completions():
|
||||
@@ -581,14 +618,14 @@ class AssignmentsMixin:
|
||||
continue
|
||||
comp_dt = comp.completed_at
|
||||
try:
|
||||
if hasattr(comp_dt, 'astimezone'):
|
||||
if hasattr(comp_dt, "astimezone"):
|
||||
comp_dt = dt_util.as_local(comp_dt)
|
||||
comp_date = comp_dt.date() if hasattr(comp_dt, 'date') else None
|
||||
comp_date = comp_dt.date() if hasattr(comp_dt, "date") else None
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
continue
|
||||
if comp_date != today:
|
||||
continue
|
||||
bonus_id = getattr(comp, 'bonus_subtask_id', None)
|
||||
bonus_id = getattr(comp, "bonus_subtask_id", None)
|
||||
if bonus_id:
|
||||
# Bonus completions don't count toward the parent's daily
|
||||
# quota; track them only to decide whether the active child
|
||||
@@ -601,16 +638,16 @@ class AssignmentsMixin:
|
||||
if comp.child_id in pool or comp.child_id == "__parent__":
|
||||
completions_today += 1
|
||||
# first_come is a single-winner race: clamp any mis-configured quota to 1.
|
||||
if getattr(chore, 'assignment_mode', 'everyone') == 'first_come':
|
||||
if getattr(chore, "assignment_mode", "everyone") == "first_come":
|
||||
daily_limit = 1
|
||||
else:
|
||||
daily_limit = getattr(chore, 'daily_limit', 1) or 1
|
||||
daily_limit = getattr(chore, "daily_limit", 1) or 1
|
||||
if completions_today < daily_limit:
|
||||
return False
|
||||
bonus_subtasks = getattr(chore, 'bonus_subtasks', None) or []
|
||||
bonus_subtasks = getattr(chore, "bonus_subtasks", None) or []
|
||||
if bonus_subtasks and active_child_id:
|
||||
for bst in bonus_subtasks:
|
||||
bst_id = getattr(bst, 'id', None)
|
||||
bst_id = getattr(bst, "id", None)
|
||||
if bst_id and bst_id not in completed_bonus_ids_today:
|
||||
return False
|
||||
return True
|
||||
@@ -621,8 +658,8 @@ class AssignmentsMixin:
|
||||
Runs at midnight. All chores are processed concurrently so the runtime
|
||||
is bounded by the slowest single publish, not the sum across chores.
|
||||
|
||||
Also clears stale skip state (skip_date != today) so yesterday's skip
|
||||
doesn't bleed into the new day.
|
||||
Also clears stale skip and swap state (dated != today) so yesterday's
|
||||
skip or approved sibling swap doesn't bleed into the new day.
|
||||
"""
|
||||
today = dt_util.as_local(dt_util.now()).date()
|
||||
today_iso = today.isoformat()
|
||||
@@ -630,11 +667,16 @@ class AssignmentsMixin:
|
||||
if not chores:
|
||||
return
|
||||
|
||||
# Clear stale skip state in-memory (persisted via update_chore below).
|
||||
# Clear stale skip/swap state in-memory (persisted via update_chore
|
||||
# below). Both are read-time-guarded by their date too, so this is
|
||||
# housekeeping rather than a correctness requirement.
|
||||
for chore in chores:
|
||||
if getattr(chore, "skip_date", "") and chore.skip_date != today_iso:
|
||||
chore.skip_date = ""
|
||||
chore.skip_count = 0
|
||||
if getattr(chore, "assignment_swap_date", "") and chore.assignment_swap_date != today_iso:
|
||||
chore.assignment_swap_date = ""
|
||||
chore.assignment_swap_child_id = ""
|
||||
|
||||
# Group-aware daily assignment map.
|
||||
daily = self._compute_daily_assignments(today)
|
||||
@@ -650,11 +692,15 @@ class AssignmentsMixin:
|
||||
await self._publish_chore_to_calendars(chore, today)
|
||||
if list(getattr(chore, "publish_calendar_published_dates", []) or []) != before:
|
||||
dirty = True
|
||||
# Always persist if skip state was cleared above.
|
||||
# Always persist if skip/swap state was cleared above.
|
||||
if getattr(chore, "skip_date", "") == "" and getattr(chore, "skip_count", 0) == 0:
|
||||
stored = self.storage.get_chore(chore.id)
|
||||
if stored and (stored.skip_date or stored.skip_count):
|
||||
dirty = True
|
||||
if getattr(chore, "assignment_swap_date", "") == "":
|
||||
stored = self.storage.get_chore(chore.id)
|
||||
if stored and getattr(stored, "assignment_swap_date", ""):
|
||||
dirty = True
|
||||
if dirty:
|
||||
self.storage.update_chore(chore)
|
||||
return dirty
|
||||
|
||||
Reference in New Issue
Block a user