Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions custom_components/taskmate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
TIME_CATEGORIES,
)
from .coordinator import TaskMateCoordinator
from .errors import TaskMateError
from .frontend import async_register_cards, async_register_frontend
from .models import PRAISE_NOTE_MAX, REJECT_REASON_MAX, Badge, BadgeCriterion, validate_chore_times
from .panel import async_register_panel
Expand Down Expand Up @@ -462,6 +463,9 @@ async def wrapped(call: ServiceCall) -> None:
try:
await handler(call)
except ValueError as err:
# A TaskMateError is already a translated ServiceValidationError.
if isinstance(err, ServiceValidationError):
raise
raise ServiceValidationError(str(err)) from err

return wrapped
Expand Down Expand Up @@ -536,7 +540,7 @@ async def _async_check_child_rate(call: ServiceCall) -> None:
if await authz.async_context_is_parent(hass, _get_coordinator(hass), call.context):
return
await _async_record_service_audit(hass, call, denied=True)
raise ServiceValidationError("Too many requests from this account, try again in a minute")
raise TaskMateError("rate_limited")

def _audited(handler):
"""Wrap a child-facing handler so its mutation is recorded too.
Expand Down Expand Up @@ -595,6 +599,8 @@ async def handle_complete_chore(call: ServiceCall) -> None:
try:
await coordinator.async_complete_chore_on_date(chore_id, child_id, completed_date)
except ValueError as err:
if isinstance(err, ServiceValidationError):
raise
raise ServiceValidationError(str(err)) from err
return
if as_parent:
Expand Down Expand Up @@ -622,6 +628,8 @@ async def handle_complete_chore(call: ServiceCall) -> None:
# soft rejections (daily limit, race lost, not-your-turn) are silent
# no-ops inside the coordinator. Surface real errors as a clean
# validation error rather than an unhandled 500 + traceback.
if isinstance(err, ServiceValidationError):
raise
raise ServiceValidationError(str(err)) from err

async def handle_complete_next_chore(call: ServiceCall) -> None:
Expand All @@ -643,7 +651,7 @@ async def handle_complete_next_chore(call: ServiceCall) -> None:
# The same gate as a child completing a chore for themselves.
await _async_require_linked_child(hass, call, coordinator, child_id)
if coordinator.get_child(child_id) is None:
raise ServiceValidationError(f"Child {child_id} not found")
raise TaskMateError("child_not_found", child_id=child_id)
chore = next_chore_for_child(coordinator, child_id)
if chore is None:
_LOGGER.debug("complete_next_chore: nothing left to complete for %s", child_id)
Expand Down Expand Up @@ -819,7 +827,7 @@ async def handle_undo_chore(call: ServiceCall) -> None:
completion_id = call.data["completion_id"]
completion = next((c for c in coordinator.storage.get_completions() if c.id == completion_id), None)
if completion is None:
raise ValueError("This chore has already been undone. Refresh the card.")
raise TaskMateError("undo_already_undone")
await _async_require_linked_child(hass, call, coordinator, completion.child_id)
await coordinator.async_undo_chore(completion_id)

Expand Down Expand Up @@ -935,6 +943,8 @@ async def handle_read_aloud(call: ServiceCall) -> None:
context=call.context,
)
except ValueError as err:
if isinstance(err, ServiceValidationError):
raise
raise ServiceValidationError(str(err)) from err

async def handle_spin_roulette(call: ServiceCall) -> None:
Expand All @@ -950,6 +960,8 @@ async def handle_spin_roulette(call: ServiceCall) -> None:
except ValueError as err:
# Roulette off, no spins left, nothing to spin for — all normal
# states the child should be told about, not server errors.
if isinstance(err, ServiceValidationError):
raise
raise ServiceValidationError(str(err)) from err

async def handle_choose_avatar(call: ServiceCall) -> None:
Expand Down Expand Up @@ -1232,6 +1244,8 @@ async def handle_add_chore(call: ServiceCall) -> None:
try:
times = validate_chore_times(call.data.get("times"), team_size=call.data.get("team_size", 0))
except ValueError as err:
if isinstance(err, ServiceValidationError):
raise
raise ServiceValidationError(str(err)) from err
# Scheduling and assignment (#1156): only the fields the call names,
# so everything else keeps the coordinator's defaults.
Expand Down
5 changes: 3 additions & 2 deletions custom_components/taskmate/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .const import DOMAIN
from .coordinator import TaskMateCoordinator
from .entity import taskmate_device_info
from .entity_names import TranslatedNameMixin


async def async_setup_entry(
Expand All @@ -29,7 +30,7 @@ async def async_setup_entry(
async_add_entities(entities)


class TaskMateBaseBinarySensor(CoordinatorEntity, BinarySensorEntity):
class TaskMateBaseBinarySensor(TranslatedNameMixin, CoordinatorEntity, BinarySensorEntity):
"""Base class for TaskMate binary sensors."""

def __init__(
Expand Down Expand Up @@ -58,7 +59,7 @@ def __init__(
"""Initialize the binary sensor."""
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_has_pending_approvals"
self._attr_name = "Has Pending Approvals"
self._set_translated_name("binary_sensor", "has_pending_approvals")

@property
def is_on(self) -> bool:
Expand Down
7 changes: 4 additions & 3 deletions custom_components/taskmate/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from .const import DOMAIN
from .coordinator import TaskMateCoordinator, button_keys, chore_has_button
from .entity import taskmate_device_info
from .entity_names import TranslatedNameMixin
from .models import Child, Chore, Reward

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -87,7 +88,7 @@ def async_update_entities() -> None:
coordinator.async_add_listener(async_update_entities)


class TaskMateBaseButton(CoordinatorEntity, ButtonEntity):
class TaskMateBaseButton(TranslatedNameMixin, CoordinatorEntity, ButtonEntity):
"""Base class for TaskMate buttons."""

def __init__(
Expand Down Expand Up @@ -120,7 +121,7 @@ def __init__(
self.child_id = child.id
self.chore_id = chore.id
self._attr_unique_id = f"{entry.entry_id}_{child.id}_{chore.id}_complete"
self._attr_name = f"{child.name}: Complete {chore.name}"
self._set_translated_name("button", "complete_chore", child=child.name, chore=chore.name)

@property
def icon(self) -> str:
Expand Down Expand Up @@ -177,7 +178,7 @@ def __init__(
self.child_id = child.id
self.reward_id = reward.id
self._attr_unique_id = f"{entry.entry_id}_{child.id}_{reward.id}_claim"
self._attr_name = f"{child.name}: Claim {reward.name}"
self._set_translated_name("button", "claim_reward", child=child.name, reward=reward.name)

@property
def icon(self) -> str:
Expand Down
9 changes: 5 additions & 4 deletions custom_components/taskmate/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from homeassistant.components.calendar import CalendarEntity, CalendarEntityFeature, CalendarEvent
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError, Unauthorized
from homeassistant.exceptions import Unauthorized
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
Expand All @@ -41,6 +41,7 @@
from .const import DOMAIN
from .coordinator import TaskMateCoordinator
from .entity import taskmate_device_info
from .errors import TaskMateError
from .models import Child, Chore

try: # The websocket connection a calendar-panel edit arrived on (see _acting_user_ids).
Expand Down Expand Up @@ -338,16 +339,16 @@ def _resolve_occurrence(self, uid: str, recurrence_id: str | None) -> tuple[Chor
@staticmethod
def _refuse_series(recurrence_range: str | None, rrule: str | None = None) -> None:
if (recurrence_range or "").upper() == "THISANDFUTURE" or rrule:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="calendar_series_not_supported")
raise TaskMateError("calendar_series_not_supported")

async def async_create_event(self, **kwargs: Any) -> None:
"""Add a one-off chore for this child on the event's date."""
user_id = await self._async_require_parent()
description = str(kwargs.get("description") or "")
if _PUBLISHED_MARKER in description:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="calendar_own_event")
raise TaskMateError("calendar_own_event")
if kwargs.get("rrule"):
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="calendar_repeating_create")
raise TaskMateError("calendar_repeating_create")
day, start_time = _local_date_time(kwargs["dtstart"])
chore = await self.coordinator.async_calendar_add_chore(
self._child_id,
Expand Down
14 changes: 7 additions & 7 deletions custom_components/taskmate/chore_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@

import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ServiceValidationError
from homeassistant.util import dt as dt_util

from .const import (
Expand All @@ -38,6 +37,7 @@
TIME_CATEGORIES,
recurrence_interval_days,
)
from .errors import TaskMateError

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -177,10 +177,10 @@ def resolve_children(coordinator, refs) -> list[str]:
else:
matches = [c for c in children if (c.name or "").strip().casefold() == ref.casefold()]
if not matches:
raise ServiceValidationError(f"Unknown child {ref!r}. Valid children: {valid}")
raise TaskMateError("service_unknown_child", ref=ref, valid=valid)
if len(matches) > 1:
ids = ", ".join(c.id for c in matches)
raise ServiceValidationError(f"More than one child is called {ref!r}; use the child id instead ({ids})")
raise TaskMateError("service_ambiguous_child", ref=ref, ids=ids)
child_id = matches[0].id
if child_id not in resolved:
resolved.append(child_id)
Expand All @@ -199,9 +199,9 @@ async def async_update_chore_from_call(coordinator, data: dict) -> None:
# set on it below reaches storage unless async_update_chore accepts it.
chore = coordinator.storage.get_chore(chore_id)
if chore is None:
raise ServiceValidationError(f"Chore {chore_id} not found")
raise TaskMateError("chore_not_found", chore_id=chore_id)
if not changes:
raise ServiceValidationError("Nothing to update: name at least one field to change")
raise TaskMateError("service_nothing_to_update")
if "assigned_to" in changes:
changes["assigned_to"] = resolve_children(coordinator, changes["assigned_to"])
if "recurrence_day" in changes:
Expand All @@ -222,9 +222,9 @@ async def async_delete_chore_from_call(coordinator, data: dict) -> None:
"""Delete a chore the same way the panel does (it goes to Recently deleted)."""
chore_id = data["chore_id"]
if not data.get("confirm"):
raise ServiceValidationError("Set confirm: true to delete the chore")
raise TaskMateError("service_confirm_delete")
if coordinator.storage.get_chore(chore_id) is None:
raise ServiceValidationError(f"Chore {chore_id} not found")
raise TaskMateError("chore_not_found", chore_id=chore_id)
await coordinator.async_remove_chore(chore_id)


Expand Down
11 changes: 5 additions & 6 deletions custom_components/taskmate/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector

from .const import DEFAULT_POINTS_ICON, DEFAULT_POINTS_NAME, DOMAIN
from .const import DEFAULT_POINTS_ICON, DOMAIN
from .points_name import default_points_name as _default_points_name


class TaskMateConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
Expand All @@ -28,6 +29,7 @@ class TaskMateConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
default_name = await self.hass.async_add_executor_job(_default_points_name, self.hass.config.language)

if user_input is not None:
# Check if already configured
Expand All @@ -37,7 +39,7 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Flo
return self.async_create_entry(
title="TaskMate",
data={
"points_name": user_input.get("points_name", DEFAULT_POINTS_NAME),
"points_name": user_input.get("points_name", default_name),
"points_icon": user_input.get("points_icon", DEFAULT_POINTS_ICON),
},
)
Expand All @@ -46,12 +48,9 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Flo
step_id="user",
data_schema=vol.Schema(
{
vol.Optional("points_name", default=DEFAULT_POINTS_NAME): str,
vol.Optional("points_name", default=default_name): str,
vol.Optional("points_icon", default=DEFAULT_POINTS_ICON): selector.IconSelector(),
}
),
errors=errors,
description_placeholders={
"title": "Welcome to TaskMate!",
},
)
21 changes: 11 additions & 10 deletions custom_components/taskmate/coord_appeals.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

from . import notify_strings
from .const import NOTIF_TYPE_APPEAL_DECIDED, NOTIF_TYPE_APPEAL_SUBMITTED
from .errors import TaskMateError
from .models import RewardClaim, clean_label, generate_id, parse_datetime

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -123,17 +124,17 @@ def appealable_for_child(self, child_id: str) -> list[dict[str, Any]]:
async def async_appeal(self, child_id: str, kind: str, target_id: str, message: str = "") -> dict[str, Any]:
"""A child appeals one item from their appealable list."""
if not self.appeals_enabled():
raise ValueError("Appeals are switched off.")
raise TaskMateError("appeals_off")
child = self.get_child(child_id)
if child is None:
raise ValueError(f"Child {child_id} not found")
raise TaskMateError("child_not_found", child_id=child_id)
if target_id in self._appealed_targets():
raise ValueError("That has already been appealed.")
raise TaskMateError("appeals_already_appealed")
item = next(
(i for i in self.appealable_for_child(child_id) if i["kind"] == kind and i["target_id"] == target_id), None
)
if item is None:
raise ValueError("That can't be appealed.")
raise TaskMateError("appeals_not_appealable")
now = dt_util.now()
record = {
"id": generate_id(),
Expand Down Expand Up @@ -197,12 +198,12 @@ async def async_decide_appeal(self, appeal_id: str, overturn: bool, note: str =
"""A parent upholds the original decision or overturns it."""
appeal = next((a for a in self._appeals() if a.get("id") == appeal_id), None)
if appeal is None:
raise ValueError("That appeal no longer exists.")
raise TaskMateError("appeals_not_found")
if appeal.get("status") != "open":
raise ValueError("That appeal has already been decided.")
raise TaskMateError("appeals_already_decided")
child = self.get_child(appeal["child_id"])
if child is None:
raise ValueError("The child is no longer here.")
raise TaskMateError("appeals_child_gone")
if overturn:
await self._async_overturn(appeal, child)
now = dt_util.now()
Expand Down Expand Up @@ -252,7 +253,7 @@ async def _async_overturn(self, appeal: dict[str, Any], child) -> None:
if kind == "reward":
reward = self.get_reward(appeal.get("item_id", ""))
if reward is None:
raise ValueError("That reward no longer exists.")
raise TaskMateError("appeals_reward_gone")
claim = RewardClaim(reward_id=reward.id, child_id=child.id, claimed_at=dt_util.now())
self.storage.add_reward_claim(claim)
try:
Expand All @@ -264,12 +265,12 @@ async def _async_overturn(self, appeal: dict[str, Any], child) -> None:
if kind == "penalty":
txn = next((t for t in self.storage.get_points_transactions() if t.id == appeal["target_id"]), None)
if txn is None:
raise ValueError("That penalty has already been taken back.")
raise TaskMateError("appeals_penalty_already_undone")
# The undo settles the make-it-right offer: cancels an open one,
# refuses when the penalty was already earned back.
await self.async_undo_transaction(txn.id)
return
raise ValueError(f"Unknown appeal kind {kind}")
raise TaskMateError("appeals_unknown_kind", kind=kind)


def _trim(appeals: list[dict[str, Any]]) -> None:
Expand Down
Loading
Loading