46 files
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""Chore operations mixin for TaskMateCoordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -26,10 +27,15 @@ def _add_months(d: date, months: int) -> date:
|
||||
|
||||
|
||||
_DOW_MAP = {
|
||||
'monday': 0, 'tuesday': 1, 'wednesday': 2, 'thursday': 3,
|
||||
'friday': 4, 'saturday': 5, 'sunday': 6,
|
||||
"monday": 0,
|
||||
"tuesday": 1,
|
||||
"wednesday": 2,
|
||||
"thursday": 3,
|
||||
"friday": 4,
|
||||
"saturday": 5,
|
||||
"sunday": 6,
|
||||
}
|
||||
_MONTH_STEPS = {'monthly': 1, 'every_3_months': 3, 'every_6_months': 6}
|
||||
_MONTH_STEPS = {"monthly": 1, "every_3_months": 3, "every_6_months": 6}
|
||||
|
||||
|
||||
class ChoresMixin:
|
||||
@@ -124,7 +130,9 @@ class ChoresMixin:
|
||||
# For random/balanced manual-start, override today's cached child so
|
||||
# the parent sees the chosen child immediately.
|
||||
if manual_start_child_id and resolved_mode in ("random", "balanced"):
|
||||
resolved_pool = self._chore_assignment_pool(chore) if chore.assigned_to else [c.id for c in self.storage.get_children()]
|
||||
resolved_pool = (
|
||||
self._chore_assignment_pool(chore) if chore.assigned_to else [c.id for c in self.storage.get_children()]
|
||||
)
|
||||
if manual_start_child_id in resolved_pool:
|
||||
chore.assignment_current_child_id = manual_start_child_id
|
||||
self.storage.add_chore(chore)
|
||||
@@ -138,6 +146,7 @@ class ChoresMixin:
|
||||
async def async_request_swap(self, chore_id: str, requester_id: str) -> str:
|
||||
"""A child requests to take over today's rotation assignment of a chore."""
|
||||
from .models import generate_id
|
||||
|
||||
chore = self.get_chore(chore_id)
|
||||
if not chore:
|
||||
raise ValueError(f"Chore {chore_id} not found")
|
||||
@@ -164,20 +173,37 @@ class ChoresMixin:
|
||||
|
||||
async def async_approve_swap(self, req_id: str) -> None:
|
||||
"""Approve a swap — reassign today's chore to the requester."""
|
||||
req = next((r for r in self.storage.get_swap_requests()
|
||||
if r.get("id") == req_id and r.get("status") == "pending"), None)
|
||||
req = next(
|
||||
(r for r in self.storage.get_swap_requests() if r.get("id") == req_id and r.get("status") == "pending"),
|
||||
None,
|
||||
)
|
||||
if not req:
|
||||
raise ValueError(f"Swap request {req_id} not found")
|
||||
chore = self.get_chore(req["chore_id"])
|
||||
if chore:
|
||||
# Stamp the dated override *and* the cached current child. The
|
||||
# override is what every eligibility gate reads (#781); the cached
|
||||
# field keeps the sensors/cards consistent without waiting for the
|
||||
# next assignment pass.
|
||||
chore.assignment_swap_child_id = req["requester_id"]
|
||||
chore.assignment_swap_date = dt_util.as_local(dt_util.now()).date().isoformat()
|
||||
chore.assignment_current_child_id = req["requester_id"]
|
||||
self.storage.update_chore(chore)
|
||||
self.storage.update_swap_request(req_id, status="approved")
|
||||
self.hass.bus.async_fire("taskmate_swap_approved", {
|
||||
"chore_id": req["chore_id"], "requester_id": req["requester_id"],
|
||||
"from_child_id": req.get("from_child_id", ""),
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
# Consume the request, exactly as rejection does (#783). Nothing reads a
|
||||
# request once it leaves "pending" — both readers filter on it — so
|
||||
# keeping it would grow the store forever. The approval stays observable
|
||||
# through the event below and the chore's own dated override. `req` is
|
||||
# already captured, so the payload survives the removal.
|
||||
self.storage.remove_swap_request(req_id)
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_swap_approved",
|
||||
{
|
||||
"chore_id": req["chore_id"],
|
||||
"requester_id": req["requester_id"],
|
||||
"from_child_id": req.get("from_child_id", ""),
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
await self.storage.async_save()
|
||||
await self.async_refresh()
|
||||
|
||||
@@ -197,7 +223,8 @@ class ChoresMixin:
|
||||
except (ValueError, TypeError):
|
||||
_LOGGER.warning(
|
||||
"Chore '%s' has an unparseable deadline_at %r — ignoring it",
|
||||
getattr(chore, "name", ""), raw,
|
||||
getattr(chore, "name", ""),
|
||||
raw,
|
||||
)
|
||||
return None
|
||||
# A naive value came from hand-edited storage; treat it as local time
|
||||
@@ -237,7 +264,8 @@ class ChoresMixin:
|
||||
changed = True
|
||||
_LOGGER.info(
|
||||
"Reactive chore '%s' expired (deadline %s)",
|
||||
chore.name, getattr(chore, "deadline_at", ""),
|
||||
chore.name,
|
||||
getattr(chore, "deadline_at", ""),
|
||||
)
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_chore_expired",
|
||||
@@ -297,6 +325,8 @@ class ChoresMixin:
|
||||
data["assignment_current_child_id"] = ""
|
||||
data["skip_date"] = ""
|
||||
data["skip_count"] = 0
|
||||
data["assignment_swap_child_id"] = ""
|
||||
data["assignment_swap_date"] = ""
|
||||
data["publish_calendar_published_dates"] = []
|
||||
data["disabled_for"] = []
|
||||
data["enabled"] = True
|
||||
@@ -352,9 +382,7 @@ class ChoresMixin:
|
||||
await self.async_refresh()
|
||||
return count
|
||||
|
||||
async def async_approve_chores_bulk(
|
||||
self, completion_ids: list[str] | None = None
|
||||
) -> int:
|
||||
async def async_approve_chores_bulk(self, completion_ids: list[str] | None = None) -> int:
|
||||
"""Approve several pending chore completions at once. Returns count approved.
|
||||
|
||||
If completion_ids is given, only those (still-pending) completions are
|
||||
@@ -390,7 +418,7 @@ class ChoresMixin:
|
||||
visibility_entity: str = "",
|
||||
visibility_state: str = "on",
|
||||
visibility_operator: str = "equals",
|
||||
) -> list[Chore]:
|
||||
) -> list[Chore]:
|
||||
"""Add multiple chores at once with shared settings."""
|
||||
chores = []
|
||||
for name in chore_names:
|
||||
@@ -412,7 +440,7 @@ class ChoresMixin:
|
||||
visibility_entity=visibility_entity,
|
||||
visibility_state=visibility_state,
|
||||
visibility_operator=visibility_operator,
|
||||
)
|
||||
)
|
||||
self.storage.add_chore(chore)
|
||||
chores.append(chore)
|
||||
|
||||
@@ -453,7 +481,10 @@ class ChoresMixin:
|
||||
extra_prefixes.append(f"{prev_name} — ")
|
||||
extra_prefixes.append(f"{chore.name} — ")
|
||||
await self._cleanup_chore_from_calendars(
|
||||
chore, cleanup_entities, today, summary_prefixes=extra_prefixes,
|
||||
chore,
|
||||
cleanup_entities,
|
||||
today,
|
||||
summary_prefixes=extra_prefixes,
|
||||
)
|
||||
self.storage.update_chore(chore)
|
||||
# Replacing or clearing the picture orphans the old file; delete it —
|
||||
@@ -464,9 +495,7 @@ class ChoresMixin:
|
||||
await self.storage.async_save()
|
||||
await self.async_refresh()
|
||||
|
||||
async def _async_release_image(
|
||||
self, image_url: str, *, excluding_chore_id: str = ""
|
||||
) -> None:
|
||||
async def _async_release_image(self, image_url: str, *, excluding_chore_id: str = "") -> None:
|
||||
"""Delete a chore image file, but only if nothing else still shows it.
|
||||
|
||||
`async_clone_chore` copies `image_url` straight from the source, so two
|
||||
@@ -493,9 +522,7 @@ class ChoresMixin:
|
||||
# Nothing sweeps taskmate_images, so the file has to go with the chore —
|
||||
# unless a clone still shows it (#768).
|
||||
if existing is not None and getattr(existing, "image_url", ""):
|
||||
await self._async_release_image(
|
||||
existing.image_url, excluding_chore_id=chore_id
|
||||
)
|
||||
await self._async_release_image(existing.image_url, excluding_chore_id=chore_id)
|
||||
self.storage.remove_chore(chore_id)
|
||||
self.storage.remove_completions_for_chore(chore_id)
|
||||
self.storage.remove_last_completed_for_chore(chore_id)
|
||||
@@ -503,6 +530,9 @@ class ChoresMixin:
|
||||
self.storage.remove_chore_from_task_groups(chore_id)
|
||||
# Drop queued scheduled changes (#675) — nothing left to apply them to.
|
||||
self.storage.remove_scheduled_changes_for_chore(chore_id)
|
||||
# Drop pending swap requests (#785), else they sit in the parent's
|
||||
# approval queue forever showing "?" for the chore that no longer exists.
|
||||
self.storage.remove_swap_requests_for_chore(chore_id)
|
||||
# Remove chore from children's chore_order lists
|
||||
for child in self.storage.get_children():
|
||||
if chore_id in child.chore_order:
|
||||
@@ -533,9 +563,7 @@ class ChoresMixin:
|
||||
# Reject skipping a sticky group follower — the group would drift.
|
||||
group = self.storage.get_task_group_for_chore(chore_id)
|
||||
if group and group.policy == "sticky" and group.chore_ids and group.chore_ids[0] != chore_id:
|
||||
raise ValueError(
|
||||
"Cannot skip a sticky group follower; skip the leader chore instead"
|
||||
)
|
||||
raise ValueError("Cannot skip a sticky group follower; skip the leader chore instead")
|
||||
|
||||
pool = self._chore_assignment_pool(chore)
|
||||
if len(pool) <= 1:
|
||||
@@ -549,6 +577,12 @@ class ChoresMixin:
|
||||
chore.skip_date = today_iso
|
||||
chore.skip_count = 0
|
||||
|
||||
# A skip is a deliberate move of today's assignee, so it supersedes any
|
||||
# approved swap — otherwise the swap override would keep winning and the
|
||||
# skip would silently do nothing.
|
||||
chore.assignment_swap_child_id = ""
|
||||
chore.assignment_swap_date = ""
|
||||
|
||||
# Cyclical: A → B → … → unassigned → back to A.
|
||||
# skip_count < pool_size → advance to next child
|
||||
# skip_count == pool_size → unassigned (no child today)
|
||||
@@ -612,9 +646,13 @@ class ChoresMixin:
|
||||
chore.assigned_to = [child_id] + [c for c in pool if c != child_id]
|
||||
chore.assignment_rotation_anchor = today.isoformat()
|
||||
|
||||
# Any previous skip is wiped — manual start is an explicit reset.
|
||||
# Any previous skip or approved swap is wiped — manual start is an
|
||||
# explicit reset, and a lingering swap override would outrank the
|
||||
# child the parent just picked.
|
||||
chore.skip_date = ""
|
||||
chore.skip_count = 0
|
||||
chore.assignment_swap_child_id = ""
|
||||
chore.assignment_swap_date = ""
|
||||
|
||||
chore.assignment_current_child_id = child_id
|
||||
|
||||
@@ -623,7 +661,9 @@ class ChoresMixin:
|
||||
await self.async_refresh()
|
||||
return chore
|
||||
|
||||
async def async_complete_chore(self, chore_id: str, child_id: str, as_parent: bool = False, photo_url: str = "") -> ChoreCompletion | None:
|
||||
async def async_complete_chore(
|
||||
self, chore_id: str, child_id: str, as_parent: bool = False, photo_url: str = ""
|
||||
) -> ChoreCompletion | None:
|
||||
"""Mark a chore as completed by a child.
|
||||
|
||||
When ``as_parent`` is True the completion auto-approves (the parent is the
|
||||
@@ -664,8 +704,8 @@ class ChoresMixin:
|
||||
# SINGLE daily quota across the whole pool. Enforce it here at completion
|
||||
# time, not just in the card UI — otherwise a caller can award every pool
|
||||
# member by completing the chore once per child_id (one call each).
|
||||
assignment_mode = getattr(chore, 'assignment_mode', 'everyone')
|
||||
if assignment_mode != 'everyone':
|
||||
assignment_mode = getattr(chore, "assignment_mode", "everyone")
|
||||
if assignment_mode != "everyone":
|
||||
if self._is_rotation_done_today(chore):
|
||||
_LOGGER.debug(
|
||||
"complete_chore no-op: '%s' already completed today (rotation quota filled)",
|
||||
@@ -676,16 +716,17 @@ class ChoresMixin:
|
||||
# assignee. Parents (as_parent) may complete on behalf of any pool
|
||||
# member — e.g. ticking it off for the off-rotation child. first_come
|
||||
# keeps its competitive semantics (every pool member may race).
|
||||
if not as_parent and assignment_mode != 'first_come':
|
||||
if not as_parent and assignment_mode != "first_come":
|
||||
if child_id not in self._compute_active_children(chore):
|
||||
_LOGGER.debug(
|
||||
"complete_chore no-op: '%s' not assigned to %s today",
|
||||
chore.name, child.name,
|
||||
chore.name,
|
||||
child.name,
|
||||
)
|
||||
return None
|
||||
|
||||
# Check recurrence window for Mode B chores
|
||||
if getattr(chore, 'schedule_mode', 'specific_days') == 'recurring':
|
||||
if getattr(chore, "schedule_mode", "specific_days") == "recurring":
|
||||
if not self.is_chore_available_for_child(chore, child_id):
|
||||
_LOGGER.debug(
|
||||
"complete_chore no-op: '%s' not available yet (recurrence window)",
|
||||
@@ -694,7 +735,7 @@ class ChoresMixin:
|
||||
return None
|
||||
|
||||
# Check availability for one-shot chores
|
||||
if getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot':
|
||||
if getattr(chore, "schedule_mode", "specific_days") == "one_shot":
|
||||
if not self.is_chore_available_for_child(chore, child_id):
|
||||
_LOGGER.debug(
|
||||
"complete_chore no-op: '%s' not available (one-shot done or expired)",
|
||||
@@ -718,11 +759,13 @@ class ChoresMixin:
|
||||
if comp_dt.date() == today:
|
||||
todays_completions_count += 1
|
||||
|
||||
daily_limit = getattr(chore, 'daily_limit', 1)
|
||||
daily_limit = getattr(chore, "daily_limit", 1)
|
||||
if todays_completions_count >= daily_limit:
|
||||
_LOGGER.debug(
|
||||
"complete_chore no-op: daily limit reached for '%s' (%d/%d today)",
|
||||
chore.name, todays_completions_count, daily_limit,
|
||||
chore.name,
|
||||
todays_completions_count,
|
||||
daily_limit,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -781,7 +824,7 @@ class ChoresMixin:
|
||||
self.storage.set_last_completed(chore_id, child_id, now.isoformat())
|
||||
|
||||
# One-shot: if auto-approved, disable for this child immediately
|
||||
if getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot' and auto_approve:
|
||||
if getattr(chore, "schedule_mode", "specific_days") == "one_shot" and auto_approve:
|
||||
if child_id not in chore.disabled_for:
|
||||
chore.disabled_for.append(child_id)
|
||||
self._check_one_shot_fully_disabled(chore)
|
||||
@@ -792,8 +835,11 @@ class ChoresMixin:
|
||||
# Fire approval notification only if it stays pending
|
||||
if not auto_approve:
|
||||
await self._async_notify_pending_approval(
|
||||
child.name, chore.name, chore.points,
|
||||
completion_id=completion.id, photo_url=completion.photo_url,
|
||||
child.name,
|
||||
chore.name,
|
||||
chore.points,
|
||||
completion_id=completion.id,
|
||||
photo_url=completion.photo_url,
|
||||
)
|
||||
|
||||
await self.async_refresh()
|
||||
@@ -819,19 +865,17 @@ class ChoresMixin:
|
||||
if not chore:
|
||||
raise ValueError(f"Chore {chore_id} not found")
|
||||
|
||||
if not getattr(chore, 'enabled', True):
|
||||
if not getattr(chore, "enabled", True):
|
||||
raise ValueError(f"Chore '{chore.name}' is disabled")
|
||||
|
||||
schedule_mode = getattr(chore, 'schedule_mode', 'specific_days')
|
||||
if schedule_mode == 'one_shot':
|
||||
raise ValueError(
|
||||
f"Chore '{chore.name}' is a one-shot chore and cannot be parent-completed"
|
||||
)
|
||||
schedule_mode = getattr(chore, "schedule_mode", "specific_days")
|
||||
if schedule_mode == "one_shot":
|
||||
raise ValueError(f"Chore '{chore.name}' is a one-shot chore and cannot be parent-completed")
|
||||
|
||||
now = dt_util.now()
|
||||
|
||||
# Determine child pool — empty assigned_to means all children
|
||||
assigned = getattr(chore, 'assigned_to', []) or []
|
||||
assigned = getattr(chore, "assigned_to", []) or []
|
||||
if assigned:
|
||||
child_ids = list(assigned)
|
||||
else:
|
||||
@@ -860,8 +904,12 @@ class ChoresMixin:
|
||||
# the "Current" column / child-stats card stops pointing at the
|
||||
# original child. The pointer recomputes at the next midnight refresh.
|
||||
if getattr(chore, "assignment_mode", "everyone") != "everyone":
|
||||
if getattr(chore, "assignment_current_child_id", ""):
|
||||
if getattr(chore, "assignment_current_child_id", "") or getattr(chore, "assignment_swap_date", ""):
|
||||
chore.assignment_current_child_id = ""
|
||||
# Drop the swap override too, or it would re-point the column
|
||||
# at the swapped-to child on the next assignment pass.
|
||||
chore.assignment_swap_child_id = ""
|
||||
chore.assignment_swap_date = ""
|
||||
self.storage.update_chore(chore)
|
||||
|
||||
await self.storage.async_save()
|
||||
@@ -912,9 +960,7 @@ class ChoresMixin:
|
||||
for c in all_completions
|
||||
)
|
||||
if already_done:
|
||||
raise ValueError(
|
||||
f"Bonus sub-task '{subtask.name}' already completed today."
|
||||
)
|
||||
raise ValueError(f"Bonus sub-task '{subtask.name}' already completed today.")
|
||||
|
||||
completion = ChoreCompletion(
|
||||
chore_id=chore_id,
|
||||
@@ -960,19 +1006,24 @@ class ChoresMixin:
|
||||
comp_date = dt_util.as_local(completion.completed_at).date()
|
||||
is_bonus = bool(completion.bonus_subtask_id)
|
||||
if is_bonus:
|
||||
subtask = next(
|
||||
(b for b in chore.bonus_subtasks if b.id == completion.bonus_subtask_id), None
|
||||
)
|
||||
subtask = next((b for b in chore.bonus_subtasks if b.id == completion.bonus_subtask_id), None)
|
||||
pts = subtask.points if subtask else 0
|
||||
elif completion.timed_duration_seconds > 0 and chore.task_type == "timed":
|
||||
rate_seconds = chore.timed_rate_minutes * 60
|
||||
pts = (completion.timed_duration_seconds // rate_seconds) * chore.timed_rate_points if rate_seconds > 0 else 0
|
||||
pts = (
|
||||
(completion.timed_duration_seconds // rate_seconds) * chore.timed_rate_points
|
||||
if rate_seconds > 0
|
||||
else 0
|
||||
)
|
||||
else:
|
||||
pts = self._apply_time_adjustment(
|
||||
chore, self.effective_chore_points(chore), completion.completed_at
|
||||
)
|
||||
total_awarded = await self._award_points(
|
||||
child, pts, completion_date=comp_date, skip_streak=is_bonus,
|
||||
child,
|
||||
pts,
|
||||
completion_date=comp_date,
|
||||
skip_streak=is_bonus,
|
||||
chore_id=completion.chore_id,
|
||||
)
|
||||
completion.approved = True
|
||||
@@ -984,9 +1035,7 @@ class ChoresMixin:
|
||||
# reviewed (covers single approve AND "approve all", which
|
||||
# reuses this method per completion).
|
||||
if getattr(self, "notifications", None):
|
||||
await self.notifications.clear_approval(
|
||||
"pending_chore_approval", completion_id
|
||||
)
|
||||
await self.notifications.clear_approval("pending_chore_approval", completion_id)
|
||||
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_chore_approved",
|
||||
@@ -999,7 +1048,7 @@ class ChoresMixin:
|
||||
)
|
||||
|
||||
# One-shot: disable for this child on approval (parent completions only)
|
||||
if not is_bonus and getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot':
|
||||
if not is_bonus and getattr(chore, "schedule_mode", "specific_days") == "one_shot":
|
||||
if completion.child_id not in chore.disabled_for:
|
||||
chore.disabled_for.append(completion.child_id)
|
||||
self._check_one_shot_fully_disabled(chore)
|
||||
@@ -1031,13 +1080,17 @@ class ChoresMixin:
|
||||
{"child_name": child.name, "child_id": child.id},
|
||||
)
|
||||
await self._celebrate(
|
||||
child, "all_chores_done",
|
||||
f"{child.name} finished every chore today!", tier=1,
|
||||
child,
|
||||
"all_chores_done",
|
||||
f"{child.name} finished every chore today!",
|
||||
tier=1,
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Cannot approve completion %s: chore (%s) or child (%s) not found",
|
||||
completion_id, completion.chore_id, completion.child_id,
|
||||
completion_id,
|
||||
completion.chore_id,
|
||||
completion.child_id,
|
||||
)
|
||||
return
|
||||
_LOGGER.warning("Completion %s not found for approval", completion_id)
|
||||
@@ -1082,9 +1135,7 @@ class ChoresMixin:
|
||||
and c.child_id == completion.child_id
|
||||
and not c.bonus_subtask_id
|
||||
]
|
||||
child.last_completion_date = (
|
||||
max(remaining).isoformat() if remaining else None
|
||||
)
|
||||
child.last_completion_date = max(remaining).isoformat() if remaining else None
|
||||
# Reverse any streak milestones this completion unlocked.
|
||||
# Milestone bonuses are logged as separate transactions
|
||||
# (not part of points_awarded), so dropping the streak
|
||||
@@ -1094,24 +1145,15 @@ class ChoresMixin:
|
||||
if lost:
|
||||
try:
|
||||
milestones = self.parse_milestone_setting(
|
||||
self.storage.get_setting(
|
||||
"streak_milestones", self.DEFAULT_STREAK_MILESTONES
|
||||
)
|
||||
self.storage.get_setting("streak_milestones", self.DEFAULT_STREAK_MILESTONES)
|
||||
)
|
||||
except ValueError:
|
||||
milestones = self.parse_milestone_setting(
|
||||
self.DEFAULT_STREAK_MILESTONES
|
||||
)
|
||||
milestones = self.parse_milestone_setting(self.DEFAULT_STREAK_MILESTONES)
|
||||
refund = sum(milestones.get(d, 0) for d in lost)
|
||||
if refund > 0:
|
||||
child.points = max(0, child.points - refund)
|
||||
child.total_points_earned = max(
|
||||
0, child.total_points_earned - refund
|
||||
)
|
||||
child.career_score = (
|
||||
child.total_points_earned
|
||||
- child.total_penalties_received
|
||||
)
|
||||
child.total_points_earned = max(0, child.total_points_earned - refund)
|
||||
child.career_score = child.total_points_earned - child.total_penalties_received
|
||||
self.storage.add_points_transaction(
|
||||
PointsTransaction(
|
||||
child_id=child.id,
|
||||
@@ -1120,9 +1162,7 @@ class ChoresMixin:
|
||||
created_at=dt_util.now(),
|
||||
)
|
||||
)
|
||||
child.streak_milestones_achieved = sorted(
|
||||
d for d in achieved if d <= child.current_streak
|
||||
)
|
||||
child.streak_milestones_achieved = sorted(d for d in achieved if d <= child.current_streak)
|
||||
|
||||
self.storage.update_child(child)
|
||||
|
||||
@@ -1133,7 +1173,8 @@ class ChoresMixin:
|
||||
# chore/child on the same day (caller disposes of the records).
|
||||
comp_date = dt_util.as_local(target_completion.completed_at).date()
|
||||
bonus_completions = [
|
||||
c for c in completions
|
||||
c
|
||||
for c in completions
|
||||
if c.chore_id == target_completion.chore_id
|
||||
and c.child_id == target_completion.child_id
|
||||
and c.bonus_subtask_id
|
||||
@@ -1151,13 +1192,11 @@ class ChoresMixin:
|
||||
self.storage.update_child(child)
|
||||
|
||||
# Undo last_completed store so recurrence window resets correctly
|
||||
self.storage.undo_last_completed(
|
||||
target_completion.chore_id, target_completion.child_id
|
||||
)
|
||||
self.storage.undo_last_completed(target_completion.chore_id, target_completion.child_id)
|
||||
|
||||
# One-shot: re-enable for this child
|
||||
chore = self.get_chore(target_completion.chore_id)
|
||||
if chore and getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot':
|
||||
if chore and getattr(chore, "schedule_mode", "specific_days") == "one_shot":
|
||||
if target_completion.child_id in chore.disabled_for:
|
||||
chore.disabled_for.remove(target_completion.child_id)
|
||||
chore.enabled = True
|
||||
@@ -1168,14 +1207,10 @@ class ChoresMixin:
|
||||
async def async_reject_chore(self, completion_id: str) -> None:
|
||||
"""Reject a chore completion and fully reverse all awards if already granted."""
|
||||
completions = self.storage.get_completions()
|
||||
target_completion = next(
|
||||
(c for c in completions if c.id == completion_id), None
|
||||
)
|
||||
target_completion = next((c for c in completions if c.id == completion_id), None)
|
||||
|
||||
if target_completion:
|
||||
bonus_completions = self._reverse_completion_awards(
|
||||
target_completion, completions
|
||||
)
|
||||
bonus_completions = self._reverse_completion_awards(target_completion, completions)
|
||||
for bc in bonus_completions:
|
||||
self.storage.remove_completion(bc.id)
|
||||
|
||||
@@ -1190,21 +1225,22 @@ class ChoresMixin:
|
||||
if target_completion:
|
||||
child = self.get_child(target_completion.child_id)
|
||||
chore = self.get_chore(target_completion.chore_id)
|
||||
self.hass.bus.async_fire("taskmate_chore_rejected", {
|
||||
"child_id": target_completion.child_id,
|
||||
"child_name": getattr(child, "name", ""),
|
||||
"chore_id": target_completion.chore_id,
|
||||
"chore_name": getattr(chore, "name", ""),
|
||||
"completion_id": completion_id,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_chore_rejected",
|
||||
{
|
||||
"child_id": target_completion.child_id,
|
||||
"child_name": getattr(child, "name", ""),
|
||||
"chore_id": target_completion.chore_id,
|
||||
"chore_name": getattr(chore, "name", ""),
|
||||
"completion_id": completion_id,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
# Dismiss the mobile approval push for this reviewed completion. Also
|
||||
# covers undoing an already-approved chore (whose push was cleared at
|
||||
# approval): re-clearing a stale tag is a harmless no-op.
|
||||
if getattr(self, "notifications", None):
|
||||
await self.notifications.clear_approval(
|
||||
"pending_chore_approval", completion_id
|
||||
)
|
||||
await self.notifications.clear_approval("pending_chore_approval", completion_id)
|
||||
|
||||
async def async_undo_chore_approval(self, completion_id: str) -> None:
|
||||
"""Undo an accidental approval: reverse the awards and return the
|
||||
@@ -1240,14 +1276,17 @@ class ChoresMixin:
|
||||
|
||||
child = self.get_child(target.child_id)
|
||||
chore = self.get_chore(target.chore_id)
|
||||
self.hass.bus.async_fire("taskmate_chore_approval_undone", {
|
||||
"child_id": target.child_id,
|
||||
"child_name": getattr(child, "name", ""),
|
||||
"chore_id": target.chore_id,
|
||||
"chore_name": getattr(chore, "name", ""),
|
||||
"completion_id": completion_id,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_chore_approval_undone",
|
||||
{
|
||||
"child_id": target.child_id,
|
||||
"child_name": getattr(child, "name", ""),
|
||||
"chore_id": target.chore_id,
|
||||
"chore_name": getattr(chore, "name", ""),
|
||||
"completion_id": completion_id,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
def _check_one_shot_fully_disabled(self, chore) -> None:
|
||||
"""Check if a one-shot chore should be fully disabled (all children done)."""
|
||||
@@ -1278,16 +1317,16 @@ class ChoresMixin:
|
||||
return False
|
||||
|
||||
# Check if chore is globally disabled (soft-disabled one-shot chores)
|
||||
if not getattr(chore, 'enabled', True):
|
||||
if not getattr(chore, "enabled", True):
|
||||
return False
|
||||
|
||||
# Check per-child disabling (one-shot chores completed by this child)
|
||||
disabled_for = getattr(chore, 'disabled_for', [])
|
||||
disabled_for = getattr(chore, "disabled_for", [])
|
||||
if child_id in disabled_for:
|
||||
return False
|
||||
|
||||
# Dynamic assignment — only the active child(ren) see alternating/random chores
|
||||
if getattr(chore, 'assignment_mode', 'everyone') != 'everyone':
|
||||
if getattr(chore, "assignment_mode", "everyone") != "everyone":
|
||||
active = self._compute_active_children(chore)
|
||||
if child_id not in active:
|
||||
return False
|
||||
@@ -1298,9 +1337,9 @@ class ChoresMixin:
|
||||
return False
|
||||
|
||||
# Check visibility entity first — if not visible, chore is not available
|
||||
visibility_entity = getattr(chore, 'visibility_entity', '')
|
||||
visibility_state = getattr(chore, 'visibility_state', 'on')
|
||||
visibility_operator = getattr(chore, 'visibility_operator', 'equals')
|
||||
visibility_entity = getattr(chore, "visibility_entity", "")
|
||||
visibility_state = getattr(chore, "visibility_state", "on")
|
||||
visibility_operator = getattr(chore, "visibility_operator", "equals")
|
||||
if not self._is_visibility_entity_active(visibility_entity, visibility_state, visibility_operator):
|
||||
return False
|
||||
|
||||
@@ -1318,7 +1357,7 @@ class ChoresMixin:
|
||||
|
||||
# Chore dependencies (FEAT-1): this chore unlocks only once every chore
|
||||
# it depends on has an approved completion today by this same child.
|
||||
depends_on = getattr(chore, 'depends_on', []) or []
|
||||
depends_on = getattr(chore, "depends_on", []) or []
|
||||
if depends_on:
|
||||
dep_today = dt_util.as_local(dt_util.now()).date()
|
||||
completions = self._cached_completions()
|
||||
@@ -1327,18 +1366,18 @@ class ChoresMixin:
|
||||
c.chore_id == dep_id
|
||||
and c.child_id == child_id
|
||||
and c.approved
|
||||
and not getattr(c, 'bonus_subtask_id', '')
|
||||
and not getattr(c, "bonus_subtask_id", "")
|
||||
and dt_util.as_local(c.completed_at).date() == dep_today
|
||||
for c in completions
|
||||
)
|
||||
if not satisfied:
|
||||
return False
|
||||
|
||||
schedule_mode = getattr(chore, 'schedule_mode', 'specific_days')
|
||||
schedule_mode = getattr(chore, "schedule_mode", "specific_days")
|
||||
|
||||
# One-shot chores: only available on the day they were created
|
||||
if schedule_mode == 'one_shot':
|
||||
created_date = getattr(chore, 'created_date', '')
|
||||
if schedule_mode == "one_shot":
|
||||
created_date = getattr(chore, "created_date", "")
|
||||
if created_date:
|
||||
today = dt_util.as_local(dt_util.now()).date()
|
||||
try:
|
||||
@@ -1348,25 +1387,25 @@ class ChoresMixin:
|
||||
pass
|
||||
return True
|
||||
|
||||
if schedule_mode != 'recurring':
|
||||
if schedule_mode != "recurring":
|
||||
return True
|
||||
|
||||
recurrence = getattr(chore, 'recurrence', 'weekly')
|
||||
first_occurrence_mode = getattr(chore, 'first_occurrence_mode', 'available_immediately')
|
||||
recurrence_day = getattr(chore, 'recurrence_day', '')
|
||||
recurrence_start = getattr(chore, 'recurrence_start', '')
|
||||
recurrence = getattr(chore, "recurrence", "weekly")
|
||||
first_occurrence_mode = getattr(chore, "first_occurrence_mode", "available_immediately")
|
||||
recurrence_day = getattr(chore, "recurrence_day", "")
|
||||
recurrence_start = getattr(chore, "recurrence_start", "")
|
||||
|
||||
now = dt_util.now()
|
||||
today = dt_util.as_local(now).date()
|
||||
|
||||
window_days = {
|
||||
'every_2_days': 2,
|
||||
'weekly': 7,
|
||||
'every_2_weeks': 14,
|
||||
"every_2_days": 2,
|
||||
"weekly": 7,
|
||||
"every_2_weeks": 14,
|
||||
}.get(recurrence, 7)
|
||||
|
||||
record = self.storage.get_last_completed(chore.id, child_id)
|
||||
current_iso = record.get('current')
|
||||
current_iso = record.get("current")
|
||||
|
||||
if not current_iso:
|
||||
# Never completed — a future recurrence anchor always defers
|
||||
@@ -1377,7 +1416,7 @@ class ChoresMixin:
|
||||
return False
|
||||
except ValueError:
|
||||
pass
|
||||
if first_occurrence_mode == 'wait_for_first_occurrence' and recurrence_day:
|
||||
if first_occurrence_mode == "wait_for_first_occurrence" and recurrence_day:
|
||||
target_dow = _DOW_MAP.get(recurrence_day.lower())
|
||||
if target_dow is not None and today.weekday() != target_dow:
|
||||
return False
|
||||
@@ -1389,7 +1428,7 @@ class ChoresMixin:
|
||||
return True
|
||||
|
||||
# every_2_days with anchor — check alignment
|
||||
if recurrence == 'every_2_days' and recurrence_start:
|
||||
if recurrence == "every_2_days" and recurrence_start:
|
||||
try:
|
||||
anchor = date.fromisoformat(recurrence_start)
|
||||
days_since_anchor = (today - anchor).days
|
||||
@@ -1402,7 +1441,7 @@ class ChoresMixin:
|
||||
pass
|
||||
|
||||
# weekly/every_2_weeks with specific day — only available on that day
|
||||
if recurrence_day and recurrence in ('weekly', 'every_2_weeks'):
|
||||
if recurrence_day and recurrence in ("weekly", "every_2_weeks"):
|
||||
target_dow = _DOW_MAP.get(recurrence_day.lower())
|
||||
if target_dow is not None and today.weekday() != target_dow:
|
||||
return False
|
||||
@@ -1477,7 +1516,9 @@ class ChoresMixin:
|
||||
changed = True
|
||||
_LOGGER.info(
|
||||
"Chore '%s' expired (expires_on %s, today %s)",
|
||||
chore.name, expires_on, today.isoformat(),
|
||||
chore.name,
|
||||
expires_on,
|
||||
today.isoformat(),
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
@@ -1492,11 +1533,11 @@ class ChoresMixin:
|
||||
changed = False
|
||||
|
||||
for chore in self.storage.get_chores():
|
||||
if getattr(chore, 'schedule_mode', 'specific_days') != 'one_shot':
|
||||
if getattr(chore, "schedule_mode", "specific_days") != "one_shot":
|
||||
continue
|
||||
if not getattr(chore, 'enabled', True):
|
||||
if not getattr(chore, "enabled", True):
|
||||
continue
|
||||
created_date = getattr(chore, 'created_date', '')
|
||||
created_date = getattr(chore, "created_date", "")
|
||||
if not created_date:
|
||||
continue
|
||||
try:
|
||||
@@ -1506,7 +1547,9 @@ class ChoresMixin:
|
||||
changed = True
|
||||
_LOGGER.info(
|
||||
"One-shot chore '%s' expired (created %s, today %s)",
|
||||
chore.name, created_date, today.isoformat(),
|
||||
chore.name,
|
||||
created_date,
|
||||
today.isoformat(),
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
@@ -1563,18 +1606,15 @@ class ChoresMixin:
|
||||
if not chore:
|
||||
raise ValueError(f"Unknown chore: {chore_id}")
|
||||
if getattr(chore, "assignment_mode", "everyone") == "everyone":
|
||||
raise ValueError(
|
||||
f"Chore '{chore.name}' uses 'everyone' mode and cannot join a group"
|
||||
)
|
||||
raise ValueError(f"Chore '{chore.name}' uses 'everyone' mode and cannot join a group")
|
||||
existing_group = self.storage.get_task_group_for_chore(chore_id)
|
||||
if existing_group and existing_group.id != exclude_group_id:
|
||||
raise ValueError(
|
||||
f"Chore '{chore.name}' already belongs to group '{existing_group.name}'"
|
||||
)
|
||||
raise ValueError(f"Chore '{chore.name}' already belongs to group '{existing_group.name}'")
|
||||
|
||||
async def async_add_task_group(self, name: str, policy: str, chore_ids: list[str] | None = None):
|
||||
"""Create a task group."""
|
||||
from .models import TaskGroup
|
||||
|
||||
if policy not in ("sticky", "spread"):
|
||||
raise ValueError(f"Unknown task group policy: {policy}")
|
||||
chore_ids = list(chore_ids or [])
|
||||
|
||||
Reference in New Issue
Block a user