251 files

This commit is contained in:
Home Assistant Version Control
2026-10-16 12:38:25 +00:00
parent 3acf0cdf8d
commit 64eb0c647c
251 changed files with 6380 additions and 14560 deletions
+238 -24
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from calendar import monthrange
from datetime import date, datetime, timedelta
from datetime import date, datetime, time, timedelta
from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util
@@ -20,7 +20,7 @@ from .const import (
)
from .coord_rejections import clean_reject_reason
from .coord_teamwork import teamwork_config_error
from .models import Chore, ChoreCompletion, PointsTransaction
from .models import Chore, ChoreCompletion, PointsTransaction, parse_datetime
if TYPE_CHECKING:
pass
@@ -929,7 +929,10 @@ class ChoresMixin:
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)
# A grown-up's own completion never waits for approval (#1061).
auto_approve = (
as_parent or child.is_adult or (not chore.requires_approval and not requires_photo and not is_open_ended)
)
# 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)
@@ -996,12 +999,18 @@ class ChoresMixin:
photo_url: str = "",
note: str = "",
suggested_points: int = 0,
backdated: bool = False,
) -> 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).
``backdated`` is a parent logging a job for an earlier day: ``now`` is
then that day's timestamp. It is paid for that day (weekend and birthday
bonuses follow the day) but leaves the streak alone, and only moves the
recurrence window forward, never back.
"""
completion = ChoreCompletion(
chore_id=chore.id,
@@ -1017,6 +1026,7 @@ class ChoresMixin:
# 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,
backdated=backdated,
)
# Record the completion before awarding anything. Awarding can suspend
@@ -1027,7 +1037,16 @@ class ChoresMixin:
self.storage.add_completion(completion)
if auto_approve:
total_awarded = await self._award_points(child, points, chore_id=chore.id)
if backdated:
total_awarded = await self._award_points(
child,
points,
completion_date=dt_util.as_local(now).date(),
skip_streak=True,
chore_id=chore.id,
)
else:
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
@@ -1043,12 +1062,17 @@ class ChoresMixin:
"chore_name": chore.name,
"points": points,
"difficulty": getattr(chore, "difficulty", "medium"),
"timestamp": dt_util.now().isoformat(),
"timestamp": now.isoformat() if backdated else dt_util.now().isoformat(),
**({"backdated": True} if backdated else {}),
},
)
# Update last_completed store (window starts at completion time, midnight-rounded)
self.storage.set_last_completed(chore.id, child.id, now.isoformat())
# Update last_completed store (window starts at completion time, midnight-rounded).
# A job logged for an earlier day must not pull the window back behind a
# completion the child made since, so it only records when it is the latest.
current = parse_datetime((self.storage.get_last_completed(chore.id, child.id) or {}).get("current"))
if not backdated or current is None or now > current:
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:
@@ -1097,6 +1121,175 @@ class ChoresMixin:
if hasattr(self, "_async_evaluate_challenges"):
await self._async_evaluate_challenges(child.id)
# How far back a grown-up may log a job that was done but not ticked.
BACKDATE_MAX_DAYS = 7
async def async_complete_chore_on_date(self, chore_id: str, child_id: str, day: date) -> ChoreCompletion:
"""Log a job a child did on an earlier day but nobody ticked at the time.
Parent-only (the service gates it). The completion is stamped midday
on ``day`` so it lands in that day's (and that week's) totals, and it
is approved straight away — the grown-up logging it is the approver. It
is recorded and followed up by the same code a parent ticking a job
for a child uses, so badges, quest steps, challenges, the recurrence
window and one-shot disabling all behave as they do for any approval.
It must be a day in the last ``BACKDATE_MAX_DAYS`` days, not today
(today is ticked the normal way), for a chore that was assigned to the
child and due that day, with that day's daily limit — and, for a
weekly-target chore, that week's quota — not yet used up.
Refused, because they can't be reconstructed or priced after the fact:
rotation and teamwork chores (whose turn it was), a day won at auction,
and open-ended chores (they need the child's note and the parent's price).
Points are the chore's base points (with its difficulty) plus the
weekend multiplier when ``day`` was a Saturday or Sunday; the time-of-day,
speed and roulette adjustments are left out, as there was no live tap.
The streak is left alone: rewriting ``last_completion_date`` to a past
day would break a streak the child is on today.
Every refusal raises ValueError with a message meant for the parent.
"""
chore = self.get_chore(chore_id)
if not chore:
raise ValueError(f"Chore {chore_id} not found")
child = self.get_child(child_id)
if not child:
raise ValueError(f"Child {child_id} not found")
today = dt_util.as_local(dt_util.now()).date()
if not (today - timedelta(days=self.BACKDATE_MAX_DAYS) <= day < today):
raise ValueError(f"Pick a day in the last {self.BACKDATE_MAX_DAYS} days, before today.")
refusal = self._backdate_refusal(chore, child, day)
if refusal:
raise ValueError(refusal)
points = self.effective_chore_points(chore)
completed_at = datetime.combine(day, time(12, 0), tzinfo=dt_util.DEFAULT_TIME_ZONE)
completion = await self._async_record_completion(
chore,
child,
completed_at,
auto_approve=True,
points=points,
as_parent=True,
backdated=True,
)
await self._async_after_completions(chore, [(child, completion, points)], auto_approve=True)
return completion
def completions_on_date(self, day: date) -> list[dict]:
"""Every chore completion made on ``day`` (local time), sub-tasks excluded.
Shaped like the sensors' ``todays_completions`` rows, so the sticker
chart can show a past day with the same code it uses for today.
"""
return [
{
"completion_id": comp.id,
"chore_id": comp.chore_id,
"child_id": comp.child_id,
"approved": comp.approved,
"points": comp.points_awarded,
}
for comp in self.storage.get_completions()
if not comp.bonus_subtask_id and dt_util.as_local(comp.completed_at).date() == day
]
def _backdate_refusal(self, chore, child, day: date, *, quota: bool = True) -> str | None:
"""Why ``chore`` can't be logged for ``child`` on ``day``, or None if it can.
The one place the past-day rules live: logging a job refuses with this
message, and the panel's day lookup offers exactly the chores it lets
through, so the dialog never offers a tap TaskMate would then refuse.
``quota=False`` leaves out what that day and week have already used
(the daily limit and a weekly target's quota). The sticker chart shows
those itself, as done tiles that can still be undone.
"""
child_id = child.id
if not getattr(chore, "enabled", True):
return f"'{chore.name}' is switched off."
assigned = chore.assigned_to or []
if (assigned and child_id not in assigned) or child_id in (getattr(chore, "disabled_for", None) or []):
return f"'{chore.name}' isn't one of {child.name}'s jobs."
if (getattr(chore, "assignment_mode", "everyone") or "everyone") != "everyone" or self.teamwork_size(chore):
return f"'{chore.name}' rotates or needs a team, so it can't be logged for a past day."
if getattr(chore, "open_ended", False):
return f"'{chore.name}' is open-ended, so it needs the child's note and a price. Use the child's card."
if self.is_vacation_day(day):
return f"{day.strftime('%A')} was a holiday, so no jobs were due."
# A birthday with "chores off" is a day off for everything but mandatory
# chores, the same rule today's chore list follows.
if not getattr(chore, "mandatory", False) and self.is_birthday_day_off(child, day):
return f"{day.strftime('%A')} was {child.name}'s birthday, a day off from jobs."
if self.auction_winner(chore, day):
return f"'{chore.name}' was won at auction that day, so it can't be logged afterwards."
# A weekly-target chore (#883) has no fixed days — the child picks them —
# so it isn't "due" on any one day; its quota is the gate instead.
weekly_target = int(getattr(chore, "weekly_target", 0) or 0)
if weekly_target:
if quota and self.weekly_target_met(chore, child_id, on=day):
return f"'{chore.name}' already reached its {weekly_target} for that week."
elif not self._is_chore_scheduled_for_date(chore, day):
return f"'{chore.name}' wasn't due on {day.strftime('%A')}."
if not quota:
return None
redo_ids = self.inspection_redo_completion_ids()
done_that_day = sum(
1
for comp in self.storage.get_completions()
if comp.id not in redo_ids
and comp.chore_id == chore.id
and comp.child_id == child_id
and not comp.bonus_subtask_id
and dt_util.as_local(comp.completed_at).date() == day
)
daily_limit = max(1, int(getattr(chore, "daily_limit", 1) or 1))
if done_that_day >= daily_limit:
return f"'{chore.name}' was already done {done_that_day}/{daily_limit} times on {day.strftime('%A')}."
return None
def loggable_on(self, day: date) -> dict[str, list[str]]:
"""The chores each child could have had logged for ``day``.
Everything ``_backdate_refusal`` allows apart from the day's and week's
quotas, which the sticker chart shows itself. The panel uses it so its
list and the past-day chart follow every schedule TaskMate knows —
recurrences, calendar moves, holidays — not just fixed weekdays.
"""
loggable: dict[str, list[str]] = {}
for child in self.storage.get_children():
ids = [
chore.id
for chore in self.storage.get_chores()
if self._backdate_refusal(chore, child, day, quota=False) is None
]
if ids:
loggable[child.id] = ids
return loggable
def week_progress_on(self, day: date) -> dict[str, dict[str, int]]:
"""Each child's count for every weekly-target chore, in the week of ``day``.
The past-day sticker chart caps a weekly-target chore at what is left
of that week's quota, the way today's chart reads ``weekly_chore_progress``,
so it never offers a tick the backend would refuse.
"""
progress: dict[str, dict[str, int]] = {}
for chore in self.storage.get_chores():
if int(getattr(chore, "weekly_target", 0) or 0) <= 0:
continue
for child in self.storage.get_children():
count = self.weekly_completion_count(chore.id, child.id, on=day)
if count:
progress.setdefault(child.id, {})[chore.id] = count
return progress
async def async_parent_complete_chore(self, chore_id: str) -> ChoreCompletion:
"""Mark a chore as completed by the parent — zero points, advances recurrence."""
chore = self.get_chore(chore_id)
@@ -1211,12 +1404,14 @@ class ChoresMixin:
if already_done:
raise ValueError(f"Bonus sub-task '{subtask.name}' already completed today.")
# A grown-up's own sub-task never waits for approval (#1061).
needs_approval = chore.requires_approval and not child.is_adult
completion = ChoreCompletion(
chore_id=chore_id,
child_id=child_id,
completed_at=now,
approved=not chore.requires_approval,
points_awarded=subtask.points if not chore.requires_approval else 0,
approved=not needs_approval,
points_awarded=subtask.points if not needs_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,
@@ -1227,7 +1422,7 @@ class ChoresMixin:
# awarding can suspend.
self.storage.add_completion(completion)
if not chore.requires_approval:
if not needs_approval:
total_awarded = await self._award_points(child, subtask.points, skip_streak=True)
completion.approved = True
completion.approved_at = dt_util.now()
@@ -1235,7 +1430,7 @@ class ChoresMixin:
self.storage.update_completion(completion)
await self.storage.async_save()
if chore.requires_approval:
if needs_approval:
await self._async_notify_pending_approval(
child.name, f"{chore.name} › {subtask.name}", subtask.points, completion_id=completion.id
)
@@ -1455,8 +1650,9 @@ class ChoresMixin:
# Only reverse streak for parent completions, and only
# when this was the child's sole completion that day —
# streaks are day-based, not per-completion
if not completion.bonus_subtask_id:
# streaks are day-based, not per-completion. A backdated one
# never advanced the streak, so it has nothing to give back.
if not completion.bonus_subtask_id and not completion.backdated:
reject_date = dt_util.as_local(completion.completed_at).date()
other_same_day = any(
c.id != completion.id
@@ -1508,8 +1704,10 @@ class ChoresMixin:
)
)
child.streak_milestones_achieved = sorted(d for d in achieved if d <= child.current_streak)
# The weekly target bonus this completion helped earn (#1044).
self._reverse_weekly_target_bonus(completion, child)
# The weekly target bonus this completion helped earn (#1044).
# Outside the streak block: a backdated job never moved the
# streak, but it can complete a week's target like any other.
self._reverse_weekly_target_bonus(completion, child)
bonus_completions: list = []
is_parent = not target_completion.bonus_subtask_id
@@ -1532,8 +1730,17 @@ class ChoresMixin:
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)
# Undo last_completed store so recurrence window resets correctly.
# A backdated completion only owns the current entry if it was the
# latest when logged; otherwise a later completion does, and popping
# it would reopen that chore's window.
lc_current = parse_datetime(
(self.storage.get_last_completed(target_completion.chore_id, target_completion.child_id) or {}).get(
"current"
)
)
if not target_completion.backdated or lc_current == target_completion.completed_at:
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)
@@ -1900,33 +2107,40 @@ class ChoresMixin:
days_since = (today - last_dt).days
return days_since >= window_days
def weekly_completion_count(self, chore_id: str, child_id: str) -> int:
def weekly_completion_count(self, chore_id: str, child_id: str, on: date | None = None) -> int:
"""How many times ``child_id`` has completed ``chore_id`` this week.
Monday-anchored, matching the Challenges period convention. Bonus
sub-tasks don't count; pending completions do, so a chore can't be
submitted a fourth time just because the parent hasn't got round to
approving the third yet.
``on`` asks about the week containing that day instead of this one, for
a job being logged against a past week.
"""
today = dt_util.as_local(dt_util.now()).date()
week_start = today - timedelta(days=today.weekday())
anchor = on or dt_util.as_local(dt_util.now()).date()
week_start = anchor - timedelta(days=anchor.weekday())
week_end = week_start + timedelta(days=6)
count = 0
for comp in self._cached_completions_for_chore(chore_id):
if comp.child_id != child_id or getattr(comp, "bonus_subtask_id", ""):
continue
try:
if dt_util.as_local(comp.completed_at).date() >= week_start:
if week_start <= dt_util.as_local(comp.completed_at).date() <= week_end:
count += 1
except (AttributeError, TypeError, ValueError):
continue
return count
def weekly_target_met(self, chore, child_id: str) -> bool:
"""True when a weekly-target chore has had its quota filled this week."""
def weekly_target_met(self, chore, child_id: str, on: date | None = None) -> bool:
"""True when a weekly-target chore has had its quota filled this week.
``on`` checks the week containing that day (see ``weekly_completion_count``).
"""
target = int(getattr(chore, "weekly_target", 0) or 0)
if target <= 0:
return False
return self.weekly_completion_count(chore.id, child_id) >= target
return self.weekly_completion_count(chore.id, child_id, on) >= target
@staticmethod
def _week_key(when) -> str: