Business-layer functions over ChildrenDao for creating and editing child profiles, so the Feature 09 service gate never touches the DAO directly. - children.py: create_child (display name required and trimmed, whitespace-only rejected; duplicate display name allowed but logged as a warning, case-insensitive and whitespace-normalised on both sides), edit_child (_UNSET sentinel for leave-alone; returns the updated record; renaming onto a different child's name warns, own name does not), set_child_active (the only removal path), and list_children (display order). Explicit None for optional fields raises instead of silently no-oping: the DAO's update deliberately writes only provided fields and cannot express NULL, so a clear request must not masquerade as one. UTC second-precision stamps per the timestamp policy. - tests/test_children_registry.py: 27 tests covering the typed round-trips, name validation matrix, duplicate-name warning behaviour (create and rename, including the rename-to-self non-warning), partial edits, refusal of unsupported clears, deactivation round-trip and list ordering.
211 lines
7.7 KiB
Python
211 lines
7.7 KiB
Python
"""Business layer for the children registry (Feature 03).
|
|
|
|
Sits between the Feature 09 service gate and the typed
|
|
:class:`~.dao_children.ChildrenDao`: callers get validation, name
|
|
policies and the duplicate-name warning here, and never talk to the
|
|
DAO or SQL directly. Children are never hard-deleted — deactivation
|
|
is a separate explicit operation (:func:`set_child_active`), so
|
|
completion history keeps its references (feature guardrail).
|
|
|
|
Name policy: the display name is required and trimmed; an empty or
|
|
whitespace-only name is rejected with ValueError. A duplicate display
|
|
name is ALLOWED (two children may legitimately share a nickname) but
|
|
logged as a warning so an accidental double-add is visible in the HA
|
|
log. The duplicate check is case-insensitive and whitespace-normalised
|
|
on both sides, because "ada" and "Ada " are the same name to a parent.
|
|
|
|
Colour and avatar are optional free-form strings, trimmed when present;
|
|
``sort_order`` is an int with the DAO/schema default of 0. Clearing an
|
|
optional field back to NULL is deliberately rejected: the DAO's update
|
|
writes only provided fields (it cannot express NULL), so the business
|
|
layer refuses an explicit ``None`` instead of silently ignoring it.
|
|
Timestamps follow the module timestamp policy: the business layer
|
|
stamps UTC ISO-8601 at second precision so every row it creates is
|
|
uniform.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import logging
|
|
|
|
from .dao_children import ChildRecord, ChildrenDao
|
|
from .db import NestQuestDatabase
|
|
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
|
|
#: in :func:`edit_child`.
|
|
_UNSET = object()
|
|
|
|
|
|
def _now_stamp() -> str:
|
|
"""Return the module's strict UTC second-precision stamp."""
|
|
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
|
timespec="seconds"
|
|
)
|
|
|
|
|
|
def _validate_text(
|
|
value: object, field: str, *, required: bool = False
|
|
) -> str | None:
|
|
"""Validate a free-text field and return it trimmed.
|
|
|
|
``None`` raises when ``required`` and otherwise returns None. A
|
|
non-string value is rejected; an empty result is rejected for
|
|
required fields (whitespace-only names are empty to a parent).
|
|
"""
|
|
if value is None:
|
|
if required:
|
|
raise ValueError(f"{field} is required")
|
|
return None
|
|
if not isinstance(value, str):
|
|
raise ValueError(f"{field} must be a string, got {value!r}")
|
|
trimmed = value.strip()
|
|
if required and not trimmed:
|
|
raise ValueError(f"{field} is required")
|
|
return trimmed
|
|
|
|
|
|
def _validate_sort_order(value: object) -> int:
|
|
"""Reject non-int sort orders (bools included) before the DAO."""
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
raise ValueError(f"sort_order must be an integer, got {value!r}")
|
|
return value
|
|
|
|
|
|
async def _warn_on_duplicate_name(
|
|
database: NestQuestDatabase, display_name: str
|
|
) -> None:
|
|
"""Log a warning when ``display_name`` duplicates an existing child.
|
|
|
|
Case-insensitive and whitespace-normalised on both sides. The
|
|
duplicate is still allowed — this only makes it visible; the log
|
|
line names both rows so an accidental double-add can be found.
|
|
"""
|
|
lowered = display_name.casefold()
|
|
for child in await ChildrenDao(database).list_all():
|
|
if child.display_name.strip().casefold() == lowered:
|
|
LOGGER.warning(
|
|
"Child display name %r duplicates existing child %d "
|
|
"(%r); allowed, but check this was intentional",
|
|
display_name,
|
|
child.id,
|
|
child.display_name,
|
|
)
|
|
return
|
|
|
|
|
|
async def create_child(
|
|
database: NestQuestDatabase,
|
|
display_name: str,
|
|
*,
|
|
colour: str | None = None,
|
|
avatar_ref: str | None = None,
|
|
sort_order: int = 0,
|
|
) -> ChildRecord:
|
|
"""Create a child profile and return the stored record.
|
|
|
|
The display name is required, trimmed, and rejected when empty.
|
|
A duplicate display name (case-insensitive) is allowed but logged
|
|
as a warning. The duplicate scan and the INSERT are deliberately
|
|
not one transaction: the warning is advisory, so serialising
|
|
creates to protect it would buy nothing.
|
|
"""
|
|
name = _validate_text(display_name, "display_name", required=True)
|
|
assert name is not None
|
|
colour_value = _validate_text(colour, "colour")
|
|
avatar_value = _validate_text(avatar_ref, "avatar_ref")
|
|
order = _validate_sort_order(sort_order)
|
|
await _warn_on_duplicate_name(database, name)
|
|
return await ChildrenDao(database).create(
|
|
name,
|
|
_now_stamp(),
|
|
colour=colour_value,
|
|
avatar_ref=avatar_value,
|
|
sort_order=order,
|
|
)
|
|
|
|
|
|
async def edit_child(
|
|
database: NestQuestDatabase,
|
|
child_id: int,
|
|
*,
|
|
display_name: str | object = _UNSET,
|
|
colour: str | None | object = _UNSET,
|
|
avatar_ref: str | None | object = _UNSET,
|
|
sort_order: int | object = _UNSET,
|
|
) -> ChildRecord:
|
|
"""Edit a child profile and return the updated record.
|
|
|
|
Arguments default to the module sentinel ``_UNSET`` meaning "leave
|
|
this field alone". An explicitly passed ``None`` for an optional
|
|
field raises: the DAO's update writes only provided fields and
|
|
cannot express NULL, so a silent no-op would mask the caller's
|
|
intent (see the module docstring). A provided display name is
|
|
validated like create's and, when it duplicates a DIFFERENT child,
|
|
still allowed with a warning. Raises ValueError when the child
|
|
does not exist.
|
|
"""
|
|
dao = ChildrenDao(database)
|
|
existing = await dao.get(child_id)
|
|
if existing is None:
|
|
raise ValueError(f"child {child_id} does not exist")
|
|
updates: dict[str, object] = {}
|
|
if display_name is not _UNSET:
|
|
name = _validate_text(display_name, "display_name", required=True)
|
|
assert name is not None
|
|
if name.casefold() != existing.display_name.strip().casefold():
|
|
await _warn_on_duplicate_name(database, name)
|
|
updates["display_name"] = name
|
|
if colour is not _UNSET:
|
|
value = _validate_text(colour, "colour")
|
|
if value is None:
|
|
raise ValueError(
|
|
"clearing colour is not supported; pass a replacement "
|
|
"value or omit the field"
|
|
)
|
|
updates["colour"] = value
|
|
if avatar_ref is not _UNSET:
|
|
value = _validate_text(avatar_ref, "avatar_ref")
|
|
if value is None:
|
|
raise ValueError(
|
|
"clearing avatar_ref is not supported; pass a "
|
|
"replacement value or omit the field"
|
|
)
|
|
updates["avatar_ref"] = value
|
|
if sort_order is not _UNSET:
|
|
updates["sort_order"] = _validate_sort_order(sort_order)
|
|
if updates:
|
|
await dao.update(child_id, **updates)
|
|
edited = await dao.get(child_id)
|
|
assert edited is not None
|
|
return edited
|
|
|
|
|
|
async def set_child_active(
|
|
database: NestQuestDatabase, child_id: int, is_active: bool
|
|
) -> ChildRecord:
|
|
"""Deactivate or reactivate a child; returns the updated record.
|
|
|
|
Deactivation hides the child from the panel without deleting their
|
|
history (feature guardrail); this is the only removal path.
|
|
Raises ValueError when the child does not exist.
|
|
"""
|
|
dao = ChildrenDao(database)
|
|
if await dao.get(child_id) is None:
|
|
raise ValueError(f"child {child_id} does not exist")
|
|
await dao.set_active(child_id, is_active)
|
|
updated = await dao.get(child_id)
|
|
assert updated is not None
|
|
return updated
|
|
|
|
|
|
async def list_children(
|
|
database: NestQuestDatabase, *, active_only: bool = False
|
|
) -> list[ChildRecord]:
|
|
"""Return children in display order (sort_order, then id)."""
|
|
dao = ChildrenDao(database)
|
|
if active_only:
|
|
return await dao.list_active()
|
|
return await dao.list_all()
|