456 files
This commit is contained in:
@@ -10,7 +10,10 @@ from typing import TYPE_CHECKING
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import images, photos
|
||||
from .const import CHORE_NOTE_MAX_LENGTH, CHORE_SUGGESTED_POINTS_MAX
|
||||
from .chore_undo import child_can_undo, undo_window_seconds
|
||||
from .const import CHORE_NOTE_MAX_LENGTH, CHORE_SUGGESTED_POINTS_MAX, DAILY_PROGRESS_KEEP_DAYS, QUALITY_RATINGS
|
||||
from .coord_rejections import clean_reject_reason
|
||||
from .coord_teamwork import teamwork_config_error
|
||||
from .models import Chore, ChoreCompletion, PointsTransaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -76,8 +79,18 @@ class ChoresMixin:
|
||||
manual_start_child_id: str = "",
|
||||
deadline_at: str = "",
|
||||
speed_bonus_points: int = 0,
|
||||
team_size: int = 0,
|
||||
team_points_mode: str = "each",
|
||||
team_bonus: int = 0,
|
||||
due_time: str = "",
|
||||
) -> Chore:
|
||||
"""Add a new chore."""
|
||||
# Teamwork (#928) is refused up front, before anything is stored.
|
||||
team_error = teamwork_config_error(
|
||||
team_size, assignment_mode=assignment_mode, team_points_mode=team_points_mode
|
||||
)
|
||||
if team_error:
|
||||
raise ValueError(team_error)
|
||||
# One-shot chores: force daily_limit=1, set created_date to today
|
||||
if schedule_mode == "one_shot":
|
||||
daily_limit = 1
|
||||
@@ -127,6 +140,10 @@ class ChoresMixin:
|
||||
require_availability=require_availability,
|
||||
deadline_at=deadline_at,
|
||||
speed_bonus_points=max(0, int(speed_bonus_points or 0)),
|
||||
team_size=int(team_size or 0),
|
||||
team_points_mode=team_points_mode,
|
||||
team_bonus=max(0, int(team_bonus or 0)),
|
||||
due_time=due_time,
|
||||
)
|
||||
# Cache today's active child so the card can show it immediately
|
||||
active = self._compute_active_children(chore, today)
|
||||
@@ -157,6 +174,9 @@ class ChoresMixin:
|
||||
raise ValueError(f"Chore {chore_id} not found")
|
||||
if getattr(chore, "assignment_mode", "everyone") in ("everyone", "unassigned"):
|
||||
raise ValueError("Only rotation chores can be swapped")
|
||||
# A chore won at auction (#982) is the winner's by their own bid.
|
||||
if self.auction_winner(chore):
|
||||
raise ValueError("This chore was won at auction today, so it can't be swapped")
|
||||
requester = self.get_child(requester_id)
|
||||
if not requester:
|
||||
raise ValueError(f"Child {requester_id} not found")
|
||||
@@ -341,6 +361,7 @@ class ChoresMixin:
|
||||
data["assignment_swap_child_id"] = ""
|
||||
data["assignment_swap_date"] = ""
|
||||
data["publish_calendar_published_dates"] = []
|
||||
data["moved_occurrences"] = {}
|
||||
data["disabled_for"] = []
|
||||
data["enabled"] = True
|
||||
|
||||
@@ -374,12 +395,15 @@ class ChoresMixin:
|
||||
if action not in ("delete", "enable", "disable", "reassign"):
|
||||
raise ValueError(f"Unknown bulk action {action}")
|
||||
count = 0
|
||||
called_off = []
|
||||
for cid in ids:
|
||||
chore = self.storage.get_chore(cid)
|
||||
if not chore:
|
||||
continue
|
||||
if action == "delete":
|
||||
self.storage.remove_chore(cid)
|
||||
# Its auctions go with it, live ones called off (#999).
|
||||
called_off += self.remove_chore_from_auctions(cid)
|
||||
elif action == "enable":
|
||||
chore.enabled = True
|
||||
chore.disabled_for = []
|
||||
@@ -393,9 +417,13 @@ class ChoresMixin:
|
||||
count += 1
|
||||
await self.storage.async_save()
|
||||
await self.async_refresh()
|
||||
for auction, bidders in called_off:
|
||||
await self._async_tell_bidders_called_off(auction, bidders)
|
||||
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, rating: int | None = None
|
||||
) -> int:
|
||||
"""Approve several pending chore completions at once. Returns count approved.
|
||||
|
||||
If completion_ids is given, only those (still-pending) completions are
|
||||
@@ -411,6 +439,9 @@ class ChoresMixin:
|
||||
the button reads as broken. The batch refreshes once at the end instead.
|
||||
Safe because the per-approval side-effects (badges, quests, challenges,
|
||||
the all-done check) all read `storage` directly, never `self.data`.
|
||||
|
||||
``rating`` (#927) applies the same 1-3 star quality rating to every
|
||||
approval in the batch; see ``async_approve_chore``.
|
||||
"""
|
||||
pending = {c.id for c in self.storage.get_completions() if not c.approved}
|
||||
if completion_ids:
|
||||
@@ -418,9 +449,10 @@ class ChoresMixin:
|
||||
targets = [cid for cid in dict.fromkeys(completion_ids) if cid in pending]
|
||||
else:
|
||||
targets = [c.id for c in self.storage.get_completions() if not c.approved]
|
||||
rated = {"rating": rating} if rating is not None else {}
|
||||
count = 0
|
||||
for cid in targets:
|
||||
await self.async_approve_chore(cid, refresh=False)
|
||||
await self.async_approve_chore(cid, refresh=False, **rated)
|
||||
count += 1
|
||||
if count:
|
||||
await self.async_refresh()
|
||||
@@ -475,6 +507,16 @@ class ChoresMixin:
|
||||
|
||||
async def async_update_chore(self, chore: Chore) -> None:
|
||||
"""Update a chore."""
|
||||
# Checked before storage is touched so a refused edit changes nothing.
|
||||
team_error = teamwork_config_error(
|
||||
getattr(chore, "team_size", 0),
|
||||
assignment_mode=getattr(chore, "assignment_mode", "everyone"),
|
||||
open_ended=bool(getattr(chore, "open_ended", False)),
|
||||
task_type=getattr(chore, "task_type", "standard") or "standard",
|
||||
team_points_mode=getattr(chore, "team_points_mode", "each"),
|
||||
)
|
||||
if team_error:
|
||||
raise ValueError(team_error)
|
||||
today = dt_util.as_local(dt_util.now()).date()
|
||||
# Capture pre-update state before storage is mutated below.
|
||||
existing = self.storage.get_chore(chore.id)
|
||||
@@ -557,6 +599,10 @@ class ChoresMixin:
|
||||
# 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)
|
||||
# Teamwork joins (#928) belong to the occurrence of a chore that's gone.
|
||||
self.storage.remove_team_joins_for_chore(chore_id)
|
||||
# Its auctions (#982) go with it; live ones are called off (#999).
|
||||
called_off = self.remove_chore_from_auctions(chore_id)
|
||||
# Remove chore from children's chore_order lists
|
||||
for child in self.storage.get_children():
|
||||
if chore_id in child.chore_order:
|
||||
@@ -564,6 +610,8 @@ class ChoresMixin:
|
||||
self.storage.update_child(child)
|
||||
await self.storage.async_save()
|
||||
await self.async_refresh()
|
||||
for auction, bidders in called_off:
|
||||
await self._async_tell_bidders_called_off(auction, bidders)
|
||||
|
||||
def get_chore(self, chore_id: str) -> Chore | None:
|
||||
"""Get a chore by ID."""
|
||||
@@ -807,10 +855,14 @@ class ChoresMixin:
|
||||
)
|
||||
return None
|
||||
|
||||
# Check daily limit (only count parent completions, not bonus sub-tasks)
|
||||
# Check daily limit (only count parent completions, not bonus sub-tasks).
|
||||
# A completion an inspection sent back to redo (#981) no longer counts.
|
||||
all_completions = self.storage.get_completions()
|
||||
redo_ids = self.inspection_redo_completion_ids()
|
||||
todays_completions_count = 0
|
||||
for comp in all_completions:
|
||||
if comp.id in redo_ids:
|
||||
continue
|
||||
if comp.chore_id == chore_id and comp.child_id == child_id and not comp.bonus_subtask_id:
|
||||
comp_dt = comp.completed_at
|
||||
if isinstance(comp_dt, str):
|
||||
@@ -862,29 +914,102 @@ class ChoresMixin:
|
||||
is_open_ended = bool(getattr(chore, "open_ended", False))
|
||||
if is_open_ended and not as_parent and not note:
|
||||
raise ValueError("Say what you did to submit this one.")
|
||||
# Teamwork chores (#928): this tap joins the day's occurrence. Only the
|
||||
# join that fills the team records anything — a completion for every
|
||||
# participant at once — so a lone join returns None like the other
|
||||
# soft outcomes above. Every gate above has already run for the joiner.
|
||||
if self.teamwork_size(chore):
|
||||
return await self._async_join_team_chore(chore, child, now, as_parent=as_parent, photo_url=photo_url)
|
||||
# Open-ended completions always go to a parent: the child suggested the
|
||||
# point value, so self-awarding it would let them set their own pay.
|
||||
auto_approve = as_parent or (not chore.requires_approval and not requires_photo and not is_open_ended)
|
||||
effective_points = self._apply_roulette_multiplier(
|
||||
chore,
|
||||
child_id,
|
||||
self._apply_speed_bonus(
|
||||
# Won at auction (#982): the winner is paid their winning bid for this
|
||||
# occurrence instead of the chore's own points — exactly what they bid.
|
||||
auction_price = self.auction_price_for(chore, child_id, today)
|
||||
if auction_price is not None:
|
||||
effective_points = auction_price
|
||||
else:
|
||||
effective_points = self._apply_roulette_multiplier(
|
||||
chore,
|
||||
self._apply_time_adjustment(chore, self.effective_chore_points(chore), now),
|
||||
now,
|
||||
),
|
||||
child_id,
|
||||
self._apply_speed_bonus(
|
||||
chore,
|
||||
self._apply_time_adjustment(chore, self.effective_chore_points(chore), now),
|
||||
now,
|
||||
),
|
||||
)
|
||||
# Redoing a chore an inspection sent back (#981): the original
|
||||
# completion's points stand, so this one earns nothing extra. It still
|
||||
# goes through the normal approval path.
|
||||
redo = self._inspection_redo_for(chore.id, child_id)
|
||||
if redo is not None:
|
||||
effective_points = 0
|
||||
completion = await self._async_record_completion(
|
||||
chore,
|
||||
child,
|
||||
now,
|
||||
auto_approve=auto_approve,
|
||||
points=effective_points,
|
||||
as_parent=as_parent,
|
||||
photo_url=photo_url,
|
||||
note=note,
|
||||
suggested_points=suggested_points,
|
||||
)
|
||||
if redo is not None:
|
||||
self._inspection_on_redo_recorded(redo, completion.id)
|
||||
await self._async_after_completions(
|
||||
chore,
|
||||
# An open-ended chore has no points of its own, so quote the
|
||||
# child's own estimate — otherwise every one of these pushes
|
||||
# announces "+0" (#832).
|
||||
[
|
||||
(
|
||||
child,
|
||||
completion,
|
||||
0
|
||||
if redo is not None
|
||||
else auction_price
|
||||
if auction_price is not None
|
||||
else (completion.suggested_points if is_open_ended else chore.points),
|
||||
)
|
||||
],
|
||||
auto_approve=auto_approve,
|
||||
)
|
||||
return completion
|
||||
|
||||
async def _async_record_completion(
|
||||
self,
|
||||
chore,
|
||||
child,
|
||||
now,
|
||||
*,
|
||||
auto_approve: bool,
|
||||
points: int,
|
||||
as_parent: bool = False,
|
||||
photo_url: str = "",
|
||||
note: str = "",
|
||||
suggested_points: int = 0,
|
||||
) -> ChoreCompletion:
|
||||
"""Store one completion and, when auto-approved, pay it.
|
||||
|
||||
The part of completing a chore that is per child. The caller has already
|
||||
run every eligibility gate and then calls _async_after_completions once
|
||||
for the whole batch (one child normally, a whole team for #928).
|
||||
"""
|
||||
completion = ChoreCompletion(
|
||||
chore_id=chore_id,
|
||||
child_id=child_id,
|
||||
chore_id=chore.id,
|
||||
child_id=child.id,
|
||||
completed_at=now,
|
||||
approved=auto_approve,
|
||||
points_awarded=effective_points if auto_approve else 0,
|
||||
submitted_points=effective_points,
|
||||
points_awarded=points if auto_approve else 0,
|
||||
submitted_points=points,
|
||||
photo_url=photo_url or "",
|
||||
note=note,
|
||||
suggested_points=suggested_points,
|
||||
# A child's own submission may be taken back inside the undo
|
||||
# window (#918); one a parent made on their behalf may not, and
|
||||
# nothing made while the window is 0 (off) ever can.
|
||||
child_undo_allowed=not as_parent and undo_window_seconds(self.storage) > 0,
|
||||
)
|
||||
|
||||
# Record the completion before awarding anything. Awarding can suspend
|
||||
@@ -895,7 +1020,7 @@ class ChoresMixin:
|
||||
self.storage.add_completion(completion)
|
||||
|
||||
if auto_approve:
|
||||
total_awarded = await self._award_points(child, effective_points, chore_id=chore_id)
|
||||
total_awarded = await self._award_points(child, points, chore_id=chore.id)
|
||||
completion.approved = True
|
||||
completion.approved_at = dt_util.now()
|
||||
completion.points_awarded = total_awarded
|
||||
@@ -908,53 +1033,61 @@ class ChoresMixin:
|
||||
"child_name": child.name,
|
||||
"chore_id": chore.id,
|
||||
"chore_name": chore.name,
|
||||
"points": effective_points,
|
||||
"points": points,
|
||||
"difficulty": getattr(chore, "difficulty", "medium"),
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
# Update last_completed store (window starts at completion time, midnight-rounded)
|
||||
self.storage.set_last_completed(chore_id, child_id, now.isoformat())
|
||||
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 child_id not in chore.disabled_for:
|
||||
chore.disabled_for.append(child_id)
|
||||
if child.id not in chore.disabled_for:
|
||||
chore.disabled_for.append(child.id)
|
||||
self._check_one_shot_fully_disabled(chore)
|
||||
self.storage.update_chore(chore)
|
||||
|
||||
return completion
|
||||
|
||||
async def _async_after_completions(self, chore, recorded: list, *, auto_approve: bool) -> None:
|
||||
"""Save, notify and refresh once for completions just recorded.
|
||||
|
||||
``recorded`` is a list of ``(child, completion, notify_points)``. One
|
||||
save and one refresh cover the batch (a refresh per child is the cost
|
||||
#794 measured); the approval push and the progression hooks stay per
|
||||
child.
|
||||
"""
|
||||
await self.storage.async_save()
|
||||
|
||||
# Fire approval notification only if it stays pending
|
||||
if not auto_approve:
|
||||
await self._async_notify_pending_approval(
|
||||
child.name,
|
||||
chore.name,
|
||||
# An open-ended chore has no points of its own, so quote the
|
||||
# child's own estimate — otherwise every one of these pushes
|
||||
# announces "+0" (#832).
|
||||
completion.suggested_points if is_open_ended else chore.points,
|
||||
completion_id=completion.id,
|
||||
photo_url=completion.photo_url,
|
||||
)
|
||||
for child, completion, notify_points in recorded:
|
||||
await self._async_notify_pending_approval(
|
||||
child.name,
|
||||
chore.name,
|
||||
notify_points,
|
||||
completion_id=completion.id,
|
||||
photo_url=completion.photo_url,
|
||||
)
|
||||
|
||||
await self.async_refresh()
|
||||
|
||||
# Trigger badge evaluation for anything that auto-approved
|
||||
if auto_approve and getattr(self, "badges", None):
|
||||
await self.badges.evaluate_for_child(child_id, "manual")
|
||||
if not auto_approve:
|
||||
return
|
||||
for child, _completion, _points in recorded:
|
||||
# Trigger badge evaluation for anything that auto-approved
|
||||
if getattr(self, "badges", None):
|
||||
await self.badges.evaluate_for_child(child.id, "manual")
|
||||
|
||||
# Auto-approved completions skip the parent-approval path, so they must
|
||||
# run the same post-approval progression hooks here — otherwise quest
|
||||
# steps and challenges only advance for approval-required chores (#558).
|
||||
if auto_approve:
|
||||
# Auto-approved completions skip the parent-approval path, so they must
|
||||
# run the same post-approval progression hooks here — otherwise quest
|
||||
# steps and challenges only advance for approval-required chores (#558).
|
||||
if hasattr(self, "_async_advance_quests"):
|
||||
await self._async_advance_quests(child_id, chore_id)
|
||||
await self._async_advance_quests(child.id, chore.id)
|
||||
if hasattr(self, "_async_evaluate_challenges"):
|
||||
await self._async_evaluate_challenges(child_id)
|
||||
|
||||
return completion
|
||||
await self._async_evaluate_challenges(child.id)
|
||||
|
||||
async def async_parent_complete_chore(self, chore_id: str) -> ChoreCompletion:
|
||||
"""Mark a chore as completed by the parent — zero points, advances recurrence."""
|
||||
@@ -1014,9 +1147,13 @@ class ChoresMixin:
|
||||
return completion
|
||||
|
||||
async def async_complete_bonus_subtask(
|
||||
self, chore_id: str, bonus_subtask_id: str, child_id: str
|
||||
self, chore_id: str, bonus_subtask_id: str, child_id: str, by_child: bool = True
|
||||
) -> ChoreCompletion:
|
||||
"""Complete a bonus sub-task (only available after parent chore is completed today)."""
|
||||
"""Complete a bonus sub-task (only available after parent chore is completed today).
|
||||
|
||||
``by_child=False`` marks a completion made from the admin panel, which
|
||||
the child may not undo (#918).
|
||||
"""
|
||||
chore = self.get_chore(chore_id)
|
||||
if not chore:
|
||||
raise ValueError(f"Chore {chore_id} not found")
|
||||
@@ -1074,6 +1211,7 @@ class ChoresMixin:
|
||||
points_awarded=subtask.points if not chore.requires_approval else 0,
|
||||
submitted_points=subtask.points,
|
||||
bonus_subtask_id=bonus_subtask_id,
|
||||
child_undo_allowed=by_child and undo_window_seconds(self.storage) > 0,
|
||||
)
|
||||
|
||||
# Written before the award for the same reason as the main completion
|
||||
@@ -1097,7 +1235,13 @@ class ChoresMixin:
|
||||
await self.async_refresh()
|
||||
return completion
|
||||
|
||||
async def async_approve_chore(self, completion_id: str, refresh: bool = True, points: int | None = None) -> None:
|
||||
async def async_approve_chore(
|
||||
self,
|
||||
completion_id: str,
|
||||
refresh: bool = True,
|
||||
points: int | None = None,
|
||||
rating: int | None = None,
|
||||
) -> None:
|
||||
"""Approve a chore completion.
|
||||
|
||||
``refresh=False`` is used by ``async_approve_chores_bulk`` so a batch
|
||||
@@ -1111,6 +1255,15 @@ class ChoresMixin:
|
||||
would for an ordinary approval. ``None`` means "pay what the submission
|
||||
was worth when it was made", falling back to the chore's value for
|
||||
completions recorded before that was stored.
|
||||
|
||||
``rating`` is the parent's optional 1-3 star quality rating (#927),
|
||||
honoured only while the ``quality_rating_enabled`` setting is on. It
|
||||
scales the base points by that star's multiplier before the streak and
|
||||
weekend multipliers ride on top, and is stored on the completion. No
|
||||
rating pays 100%. An explicit ``points`` award is already the parent's
|
||||
own valuation, so it is paid as-is and the rating is only recorded.
|
||||
Because the scaled total lands in ``points_awarded``, undo and reject
|
||||
reverse exactly what the rating paid.
|
||||
"""
|
||||
completions = self.storage.get_completions()
|
||||
for completion in completions:
|
||||
@@ -1121,7 +1274,9 @@ class ChoresMixin:
|
||||
completion_id,
|
||||
)
|
||||
return
|
||||
chore = self.get_chore(completion.chore_id)
|
||||
# A bounty's completion (#931) has no chore of its own; it is
|
||||
# priced and paid through this same path.
|
||||
chore = self.get_chore(completion.chore_id) or self.bounty_completion_chore(completion)
|
||||
child = self.get_child(completion.child_id)
|
||||
|
||||
if chore and child:
|
||||
@@ -1149,6 +1304,20 @@ class ChoresMixin:
|
||||
pts = self._apply_time_adjustment(
|
||||
chore, self.effective_chore_points(chore), completion.completed_at
|
||||
)
|
||||
stars = 0
|
||||
if rating is not None and self.quality_rating_enabled():
|
||||
try:
|
||||
stars = int(rating)
|
||||
except (TypeError, ValueError):
|
||||
stars = 0
|
||||
if stars not in QUALITY_RATINGS:
|
||||
_LOGGER.warning("Ignoring invalid quality rating %r for %s", rating, completion_id)
|
||||
stars = 0
|
||||
if stars and points is None:
|
||||
# Half-up, like the cards' preview (Python's round() is
|
||||
# half-to-even, so ★ on a 30-point chore would pay 22
|
||||
# where the card showed 23).
|
||||
pts = max(0, int(pts * self.quality_rating_multipliers()[stars] + 0.5))
|
||||
if points is not None:
|
||||
# An explicit award overrides every per-chore
|
||||
# calculation above (bonus sub-task, timed rate,
|
||||
@@ -1170,6 +1339,10 @@ class ChoresMixin:
|
||||
completion.approved = True
|
||||
completion.approved_at = dt_util.now()
|
||||
completion.points_awarded = 0
|
||||
completion.quality_rating = stars
|
||||
# A parent has reviewed it: the child can no longer undo
|
||||
# it, whatever the window says (#918).
|
||||
completion.child_undo_allowed = False
|
||||
self.storage.update_completion(completion)
|
||||
|
||||
total_awarded = await self._award_points(
|
||||
@@ -1181,6 +1354,8 @@ class ChoresMixin:
|
||||
)
|
||||
completion.points_awarded = total_awarded
|
||||
self.storage.update_completion(completion)
|
||||
if getattr(completion, "bounty_id", ""):
|
||||
self._bounty_on_approved(completion)
|
||||
|
||||
# Dismiss the mobile approval push now this completion is
|
||||
# reviewed (covers single approve AND "approve all", which
|
||||
@@ -1195,6 +1370,7 @@ class ChoresMixin:
|
||||
"chore_id": completion.chore_id,
|
||||
"completion_id": completion.id,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
**({"quality_rating": stars} if stars else {}),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1258,8 +1434,11 @@ class ChoresMixin:
|
||||
NOT remove or modify the completion records themselves.
|
||||
"""
|
||||
completion = target_completion
|
||||
if completion.points_awarded > 0:
|
||||
child = self.get_child(completion.child_id)
|
||||
child = self.get_child(completion.child_id)
|
||||
# Approval, not a positive payout, is what marks an award to reverse: a
|
||||
# 0-point chore still bumps the chore count and can advance the streak.
|
||||
# Pending submissions have been awarded nothing yet.
|
||||
if completion.approved:
|
||||
if child:
|
||||
child.points = max(0, child.points - completion.points_awarded)
|
||||
child.total_points_earned = max(0, child.total_points_earned - completion.points_awarded)
|
||||
@@ -1273,18 +1452,23 @@ class ChoresMixin:
|
||||
other_same_day = any(
|
||||
c.id != completion.id
|
||||
and c.child_id == completion.child_id
|
||||
and c.approved
|
||||
and not c.bonus_subtask_id
|
||||
and dt_util.as_local(c.completed_at).date() == reject_date
|
||||
for c in completions
|
||||
)
|
||||
if not other_same_day:
|
||||
streak_before_undo = child.current_streak or 0
|
||||
child.current_streak = max(0, child.current_streak - 1)
|
||||
# A streak freeze this completion earned goes too (#925).
|
||||
self._reverse_streak_freeze_earn(child, streak_before_undo)
|
||||
if getattr(child, "last_completion_date", None) == reject_date.isoformat():
|
||||
remaining = [
|
||||
dt_util.as_local(c.completed_at).date()
|
||||
for c in completions
|
||||
if c.id != completion.id
|
||||
and c.child_id == completion.child_id
|
||||
and c.approved
|
||||
and not c.bonus_subtask_id
|
||||
]
|
||||
child.last_completion_date = max(remaining).isoformat() if remaining else None
|
||||
@@ -1316,8 +1500,6 @@ class ChoresMixin:
|
||||
)
|
||||
child.streak_milestones_achieved = sorted(d for d in achieved if d <= child.current_streak)
|
||||
|
||||
self.storage.update_child(child)
|
||||
|
||||
bonus_completions: list = []
|
||||
is_parent = not target_completion.bonus_subtask_id
|
||||
if is_parent:
|
||||
@@ -1333,15 +1515,11 @@ class ChoresMixin:
|
||||
and c.id != target_completion.id
|
||||
and dt_util.as_local(c.completed_at).date() == comp_date
|
||||
]
|
||||
if bonus_completions:
|
||||
child = self.get_child(target_completion.child_id)
|
||||
for bc in bonus_completions:
|
||||
if bc.points_awarded > 0 and child:
|
||||
child.points = max(0, child.points - bc.points_awarded)
|
||||
child.total_points_earned = max(0, child.total_points_earned - bc.points_awarded)
|
||||
child.total_chores_completed = max(0, child.total_chores_completed - 1)
|
||||
if child:
|
||||
self.storage.update_child(child)
|
||||
for bc in bonus_completions:
|
||||
if bc.approved and child:
|
||||
child.points = max(0, child.points - bc.points_awarded)
|
||||
child.total_points_earned = max(0, child.total_points_earned - bc.points_awarded)
|
||||
child.total_chores_completed = max(0, child.total_chores_completed - 1)
|
||||
|
||||
# Undo last_completed store so recurrence window resets correctly
|
||||
self.storage.undo_last_completed(target_completion.chore_id, target_completion.child_id)
|
||||
@@ -1354,12 +1532,48 @@ class ChoresMixin:
|
||||
chore.enabled = True
|
||||
self.storage.update_chore(chore)
|
||||
|
||||
if child and (completion.approved or any(bc.approved for bc in bonus_completions)):
|
||||
# Keep the leaderboard and its chart in step once the main award and
|
||||
# any cascaded bonus sub-tasks have both been reversed.
|
||||
child.career_score = child.total_points_earned - child.total_penalties_received
|
||||
self.storage.update_child(child)
|
||||
self.storage.append_career_score_snapshot(child.id, dt_util.now().date().isoformat(), child.career_score)
|
||||
|
||||
return bonus_completions
|
||||
|
||||
async def async_reject_chore(self, completion_id: str) -> None:
|
||||
"""Reject a chore completion and fully reverse all awards if already granted."""
|
||||
async def async_undo_chore(self, completion_id: str) -> None:
|
||||
"""A child takes back their own completion (#918).
|
||||
|
||||
Allowed only for a pending submission no parent has reviewed yet, or an
|
||||
auto-approved one still inside the global undo window — see
|
||||
``chore_undo``. The caller has already checked the linked-child rule
|
||||
against the completion's *stored* child. Everything from the re-read to
|
||||
the reversal inside ``async_reject_chore`` runs without yielding, so a
|
||||
parent's approval or a second undo can't slip in between the check and
|
||||
the reversal.
|
||||
"""
|
||||
completions = self.storage.get_completions()
|
||||
completion = next((c for c in completions if c.id == completion_id), None)
|
||||
if completion is None:
|
||||
raise ValueError("This chore has already been undone. Refresh the card.")
|
||||
if not child_can_undo(completion, completions, undo_window_seconds(self.storage)):
|
||||
raise ValueError("This chore can't be undone any more. Ask a parent to undo it.")
|
||||
await self.async_reject_chore(completion_id, event="taskmate_chore_undone")
|
||||
|
||||
async def async_reject_chore(
|
||||
self, completion_id: str, event: str = "taskmate_chore_rejected", reason: str = ""
|
||||
) -> None:
|
||||
"""Reject a chore completion and fully reverse all awards if already granted.
|
||||
|
||||
``event`` is the bus event fired afterwards; a child's own undo fires
|
||||
``taskmate_chore_undone`` so automations can tell it from a parent's
|
||||
rejection. ``reason`` (#976) is the parent's optional "why", shown to
|
||||
the child; only a parent's rejection carries one.
|
||||
"""
|
||||
completions = self.storage.get_completions()
|
||||
target_completion = next((c for c in completions if c.id == completion_id), None)
|
||||
rejected = event == "taskmate_chore_rejected"
|
||||
reason = clean_reject_reason(reason) if rejected else ""
|
||||
|
||||
if target_completion:
|
||||
bonus_completions = self._reverse_completion_awards(target_completion, completions)
|
||||
@@ -1367,6 +1581,10 @@ class ChoresMixin:
|
||||
self.storage.remove_completion(bc.id)
|
||||
|
||||
self.storage.remove_completion(completion_id)
|
||||
if target_completion and getattr(target_completion, "bounty_id", ""):
|
||||
# A bounty goes back to the same child rather than to the board
|
||||
# (#931): renewed on a rejection, as it was on their own undo.
|
||||
self._bounty_on_withdrawn(target_completion, rejected=rejected)
|
||||
if target_completion and target_completion.approved and not target_completion.bonus_subtask_id:
|
||||
# Same as undo: a rejected completion must not leave the quest
|
||||
# chain standing on the step it unlocked.
|
||||
@@ -1376,23 +1594,35 @@ class ChoresMixin:
|
||||
# delete it (best-effort; foreign/blank URLs are ignored).
|
||||
if target_completion and getattr(target_completion, "photo_url", ""):
|
||||
await photos.async_delete_photo(self.hass, target_completion.photo_url)
|
||||
chore_name = ""
|
||||
if target_completion:
|
||||
chore = self.get_chore(target_completion.chore_id)
|
||||
bounty = None if chore else self.storage.get_bounty(getattr(target_completion, "bounty_id", "") or "")
|
||||
chore_name = getattr(chore, "name", "") or getattr(bounty, "title", "") or ""
|
||||
if rejected and not target_completion.bonus_subtask_id:
|
||||
self._record_rejection(
|
||||
"chore", target_completion.child_id, target_completion.chore_id, chore_name, reason
|
||||
)
|
||||
# An open inspection of it is void; a redo it was is owed again (#981).
|
||||
self._inspection_on_completion_removed(completion_id)
|
||||
await self.storage.async_save()
|
||||
await self.async_refresh()
|
||||
|
||||
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(),
|
||||
},
|
||||
)
|
||||
payload = {
|
||||
"child_id": target_completion.child_id,
|
||||
"child_name": getattr(child, "name", ""),
|
||||
"chore_id": target_completion.chore_id,
|
||||
"chore_name": chore_name,
|
||||
"completion_id": completion_id,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
}
|
||||
if rejected:
|
||||
payload["reason"] = reason
|
||||
self.hass.bus.async_fire(event, payload)
|
||||
if rejected and not target_completion.bonus_subtask_id:
|
||||
await self._async_notify_rejected("chore", child, chore_name, reason)
|
||||
# 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.
|
||||
@@ -1430,12 +1660,21 @@ class ChoresMixin:
|
||||
bc.approved = False
|
||||
bc.approved_at = None
|
||||
bc.points_awarded = 0
|
||||
bc.quality_rating = 0
|
||||
bc.child_undo_allowed = False
|
||||
self.storage.update_completion(bc)
|
||||
|
||||
target.approved = False
|
||||
target.approved_at = None
|
||||
target.points_awarded = 0
|
||||
# The rating belonged to the approval being undone (#927); a
|
||||
# re-approval rates it afresh.
|
||||
target.quality_rating = 0
|
||||
# A parent touched it, so child undo stays locked (#918).
|
||||
target.child_undo_allowed = False
|
||||
self.storage.update_completion(target)
|
||||
if getattr(target, "bounty_id", ""):
|
||||
self._bounty_on_unapproved(target)
|
||||
|
||||
# Quest progress advanced on approval, so it has to come back too —
|
||||
# otherwise re-approving the same completion advances the chain a
|
||||
@@ -1485,6 +1724,10 @@ class ChoresMixin:
|
||||
if self._is_child_on_vacation(self._cached_child(child_id)):
|
||||
return False
|
||||
|
||||
# Birthday day off (#924): only mandatory chores stay on the list.
|
||||
if not getattr(chore, "mandatory", False) and self.is_birthday_day_off(self._cached_child(child_id)):
|
||||
return False
|
||||
|
||||
# Check if chore is globally disabled (soft-disabled one-shot chores)
|
||||
if not getattr(chore, "enabled", True):
|
||||
return False
|
||||
@@ -1494,6 +1737,12 @@ class ChoresMixin:
|
||||
if child_id in disabled_for:
|
||||
return False
|
||||
|
||||
# Chore auction (#982): today's occurrence was won at auction, so it is
|
||||
# the winner's alone — whatever the mode, "everyone" included.
|
||||
winner = self.auction_winner(chore)
|
||||
if winner and winner != child_id:
|
||||
return False
|
||||
|
||||
# Dynamic assignment — only the active child(ren) see alternating/random chores
|
||||
if getattr(chore, "assignment_mode", "everyone") != "everyone":
|
||||
active = self._compute_active_children(chore)
|
||||
@@ -1542,6 +1791,13 @@ class ChoresMixin:
|
||||
if not satisfied:
|
||||
return False
|
||||
|
||||
# Calendar moves/removals (#977): an occurrence removed from today, or
|
||||
# moved away from it, takes the chore off today's list; one moved onto
|
||||
# today puts it on, whatever the regular schedule says.
|
||||
override = self.occurrence_override(chore, dt_util.as_local(dt_util.now()).date())
|
||||
if override is False:
|
||||
return False
|
||||
|
||||
schedule_mode = getattr(chore, "schedule_mode", "specific_days")
|
||||
|
||||
# One-shot chores: only available on the day they were created
|
||||
@@ -1576,6 +1832,14 @@ class ChoresMixin:
|
||||
record = self.storage.get_last_completed(chore.id, child_id)
|
||||
current_iso = record.get("current")
|
||||
|
||||
if override is True:
|
||||
# Moved onto today: open until it is done today. The regular window
|
||||
# and anchor maths would measure from the wrong day.
|
||||
try:
|
||||
return not current_iso or date.fromisoformat(current_iso[:10]) < today
|
||||
except (ValueError, TypeError):
|
||||
return True
|
||||
|
||||
if not current_iso:
|
||||
# Never completed — a future recurrence anchor always defers
|
||||
# availability, regardless of first_occurrence_mode
|
||||
@@ -1595,6 +1859,9 @@ class ChoresMixin:
|
||||
last_dt = date.fromisoformat(current_iso[:10])
|
||||
except (ValueError, TypeError):
|
||||
return True
|
||||
# A completion of a moved occurrence counts from the day it was moved
|
||||
# from, so moving one week's chore doesn't shift every week after it.
|
||||
last_dt = self.occurrence_origin(chore, last_dt)
|
||||
|
||||
# every_2_days with anchor — check alignment
|
||||
if recurrence == "every_2_days" and recurrence_start:
|
||||
@@ -1673,7 +1940,8 @@ class ChoresMixin:
|
||||
if due_days:
|
||||
today = dt_util.as_local(dt_util.now()).date()
|
||||
dow = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")[today.weekday()]
|
||||
if dow not in due_days:
|
||||
# An occurrence moved onto today (#977) is due whatever the weekday.
|
||||
if dow not in due_days and self.occurrence_override(chore, today) is not True:
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -1690,8 +1958,10 @@ class ChoresMixin:
|
||||
"""
|
||||
today = dt_util.as_local(dt_util.now()).date()
|
||||
out = []
|
||||
# Sent back to redo by an inspection (#981): owed again today.
|
||||
redo_ids = self.inspection_redo_completion_ids()
|
||||
with self.availability_build_scope():
|
||||
completions = self._cached_completions()
|
||||
completions = [c for c in self._cached_completions() if c.id not in redo_ids]
|
||||
for chore in self.storage.get_chores():
|
||||
if not self._is_chore_completable_by_child(chore, child_id):
|
||||
continue
|
||||
@@ -1716,6 +1986,99 @@ class ChoresMixin:
|
||||
out.append(chore)
|
||||
return out
|
||||
|
||||
def get_today_board(self) -> dict:
|
||||
"""Every chore each child has today and where it stands (#966).
|
||||
|
||||
Feeds the admin panel's Today page. Unlike ``get_due_chores_for_child``
|
||||
(outstanding only) this keeps the chores already done, so the panel can
|
||||
show done/total. A chore is on a child's board when it is still
|
||||
completable today, when they completed it today (a finished recurring
|
||||
chore is no longer "available", but it was still today's), or when a
|
||||
mandatory miss is waiting for it today.
|
||||
|
||||
Status per chore: ``done`` (approved, up to the daily limit), ``pending``
|
||||
(up to the limit, some awaiting approval), ``missed`` (a pending
|
||||
mandatory miss for today) or ``todo``.
|
||||
"""
|
||||
today = dt_util.as_local(dt_util.now()).date()
|
||||
today_iso = today.isoformat()
|
||||
missed = {(m.child_id, m.chore_id) for m in self.storage.get_mandatory_misses() if m.due_date == today_iso}
|
||||
children = []
|
||||
redo_ids = self.inspection_redo_completion_ids()
|
||||
with self.availability_build_scope():
|
||||
done_today: dict[tuple[str, str], list[bool]] = {}
|
||||
for c in self._cached_completions():
|
||||
# Sent back to redo by an inspection (#981): not done any more.
|
||||
if getattr(c, "bonus_subtask_id", "") or c.id in redo_ids:
|
||||
continue
|
||||
try:
|
||||
if dt_util.as_local(c.completed_at).date() != today:
|
||||
continue
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
continue
|
||||
done_today.setdefault((c.child_id, c.chore_id), []).append(bool(c.approved))
|
||||
chores = self.storage.get_chores()
|
||||
for child in self.storage.get_children():
|
||||
items = []
|
||||
for chore in chores:
|
||||
flags = done_today.get((child.id, chore.id), [])
|
||||
limit = max(int(getattr(chore, "daily_limit", 1) or 1), 1)
|
||||
open_today = (
|
||||
len(flags) < limit
|
||||
and self._is_chore_completable_by_child(chore, child.id)
|
||||
and not self.weekly_target_met(chore, child.id)
|
||||
)
|
||||
if open_today:
|
||||
status = "missed" if (child.id, chore.id) in missed else "todo"
|
||||
elif flags:
|
||||
status = "done" if all(flags) else "pending"
|
||||
elif (child.id, chore.id) in missed:
|
||||
status = "missed"
|
||||
else:
|
||||
continue
|
||||
items.append({"chore_id": chore.id, "status": status, "count": len(flags), "limit": limit})
|
||||
children.append(
|
||||
{
|
||||
"child_id": child.id,
|
||||
"due": len(items),
|
||||
"done": sum(1 for i in items if i["status"] in ("done", "pending")),
|
||||
"chores": items,
|
||||
}
|
||||
)
|
||||
return {"date": today_iso, "children": children}
|
||||
|
||||
async def async_record_daily_progress(self, _now=None) -> None:
|
||||
"""Store today's done/total per child for the Today page's week view (#966).
|
||||
|
||||
Runs just before midnight, while "today" is still the day being recorded;
|
||||
the scheduled-chore rules only know about the current day, so a past
|
||||
day's total can't be worked out afterwards.
|
||||
"""
|
||||
board = self.get_today_board()
|
||||
cutoff = (dt_util.as_local(dt_util.now()).date() - timedelta(days=DAILY_PROGRESS_KEEP_DAYS)).isoformat()
|
||||
for entry in board["children"]:
|
||||
self.storage.upsert_daily_progress(entry["child_id"], board["date"], entry["due"], entry["done"], cutoff)
|
||||
await self.storage.async_save()
|
||||
|
||||
def daily_progress_state(self, days: int = 7) -> dict:
|
||||
"""Last ``days`` days of stored done/total per child, today included live."""
|
||||
board = self.get_today_board()
|
||||
today = date.fromisoformat(board["date"])
|
||||
dates = [(today - timedelta(days=n)).isoformat() for n in range(days - 1, -1, -1)]
|
||||
out = {}
|
||||
for child in self.storage.get_children():
|
||||
stored = {e.get("date"): e for e in self.storage.get_daily_progress(child.id)}
|
||||
live = next((e for e in board["children"] if e["child_id"] == child.id), None)
|
||||
if live:
|
||||
stored[board["date"]] = {"due": live["due"], "done": live["done"]}
|
||||
out[child.id] = [
|
||||
{"date": d, "due": stored[d].get("due", 0), "done": stored[d].get("done", 0)}
|
||||
if d in stored
|
||||
else {"date": d}
|
||||
for d in dates
|
||||
]
|
||||
return {"board": board, "history": out}
|
||||
|
||||
async def _async_expire_dated_chores(self) -> None:
|
||||
"""Soft-disable any enabled chore whose expires_on date has passed."""
|
||||
today = dt_util.as_local(dt_util.now()).date()
|
||||
|
||||
Reference in New Issue
Block a user