203 files

This commit is contained in:
Home Assistant Version Control
2026-09-19 17:31:12 +00:00
parent 279159581d
commit 99467464e4
203 changed files with 2035 additions and 241 deletions
+57 -4
View File
@@ -10,6 +10,7 @@ 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 .models import Chore, ChoreCompletion, PointsTransaction
if TYPE_CHECKING:
@@ -685,7 +686,13 @@ class ChoresMixin:
return chore
async def async_complete_chore(
self, chore_id: str, child_id: str, as_parent: bool = False, photo_url: str = ""
self,
chore_id: str,
child_id: str,
as_parent: bool = False,
photo_url: str = "",
note: str = "",
suggested_points: int = 0,
) -> ChoreCompletion | None:
"""Mark a chore as completed by a child.
@@ -720,6 +727,18 @@ class ChoresMixin:
)
photo_url = ""
# The note and the suggested points are typed by a child, so bound both
# before they reach storage: an unbounded note bloats the store and the
# sensor attribute slice, and the suggestion is only ever a hint for the
# parent. Over-long notes are truncated rather than refused — losing a
# child's tail end beats refusing their chore.
note = (note or "").strip()[:CHORE_NOTE_MAX_LENGTH]
try:
suggested_points = int(suggested_points or 0)
except (TypeError, ValueError):
suggested_points = 0
suggested_points = min(max(0, suggested_points), CHORE_SUGGESTED_POINTS_MAX)
now = dt_util.now()
today = dt_util.as_local(now).date()
@@ -824,7 +843,16 @@ class ChoresMixin:
# ServiceValidationError by the service layer.
if requires_photo and not as_parent and not (photo_url or "").strip():
raise ValueError("This chore requires a photo as evidence.")
auto_approve = as_parent or (not chore.requires_approval and not requires_photo)
# An open-ended chore is a placeholder for unlisted work, so the note IS
# the chore — without it the parent has nothing to review. Same shape as
# the photo gate above: enforced here rather than only in the card, so
# the button entity, automations and Dev Tools can't bypass it.
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.")
# 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,
@@ -842,6 +870,8 @@ class ChoresMixin:
approved=auto_approve,
points_awarded=effective_points if auto_approve else 0,
photo_url=photo_url or "",
note=note,
suggested_points=suggested_points,
)
# Record the completion before awarding anything. Awarding can suspend
@@ -888,7 +918,10 @@ class ChoresMixin:
await self._async_notify_pending_approval(
child.name,
chore.name,
chore.points,
# 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,
)
@@ -1049,12 +1082,19 @@ class ChoresMixin:
await self.async_refresh()
return completion
async def async_approve_chore(self, completion_id: str, refresh: bool = True) -> None:
async def async_approve_chore(self, completion_id: str, refresh: bool = True, points: int | None = None) -> None:
"""Approve a chore completion.
``refresh=False`` is used by ``async_approve_chores_bulk`` so a batch
pays for one coordinator rebuild rather than one per completion (#794).
The caller must refresh once it is done.
``points`` lets the approving parent set what the chore was actually
worth (#832) — needed for open-ended submissions, useful for any chore.
It replaces the chore's *base* points, so the streak and level
multipliers ``_award_points`` applies still ride on top exactly as they
would for an ordinary approval. ``None`` means "use the chore's value"
and leaves the existing behaviour untouched.
"""
completions = self.storage.get_completions()
for completion in completions:
@@ -1085,6 +1125,19 @@ class ChoresMixin:
pts = self._apply_time_adjustment(
chore, self.effective_chore_points(chore), completion.completed_at
)
if points is not None:
# An explicit award overrides every per-chore
# calculation above (bonus sub-task, timed rate,
# time-of-day adjustment) — the parent has looked at
# what was actually done and priced it themselves.
try:
pts = max(0, int(points))
except (TypeError, ValueError):
_LOGGER.warning(
"Ignoring non-numeric points override %r for completion %s",
points,
completion_id,
)
# Claim the completion before awarding. The "already
# approved" check above and the award are separated by an
# await that can suspend, so two approvals landing together