252 lines
8.3 KiB
Python
252 lines
8.3 KiB
Python
"""Typed DAO layer for the children and admin_users tables.
|
|
|
|
All SQL for ``children`` and ``admin_users`` lives in this module per
|
|
the feature guardrail: callers get typed dataclasses back and never see
|
|
raw rows or SQL. Every method is async and runs through
|
|
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
|
|
executor and the event loop never blocks.
|
|
|
|
Error contract: violations surface as the underlying
|
|
:class:`sqlite3.IntegrityError` (duplicate, foreign-key, or CHECK
|
|
failure) from the execute call; the DAO adds no wrapping exception
|
|
type. A get that matches nothing returns ``None``; a mutation that
|
|
matches nothing is reported by :class:`~.db.ExecutionResult.rowcount`,
|
|
which each method returns where the caller may need it (reorder, and
|
|
the admin remove/set paths).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from .db import NestQuestDatabase
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ChildRecord:
|
|
"""One row of ``children``."""
|
|
|
|
id: int
|
|
display_name: str
|
|
colour: str | None
|
|
avatar_ref: str | None
|
|
sort_order: int
|
|
is_active: bool
|
|
created_at: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AdminUserRecord:
|
|
"""One row of ``admin_users``."""
|
|
|
|
ha_user_id: str
|
|
added_at: str
|
|
|
|
|
|
def _child_from_row(row: tuple) -> ChildRecord:
|
|
"""Build a :class:`ChildRecord` from a SELECT row."""
|
|
return ChildRecord(
|
|
id=row[0],
|
|
display_name=row[1],
|
|
colour=row[2],
|
|
avatar_ref=row[3],
|
|
sort_order=row[4],
|
|
is_active=bool(row[5]),
|
|
created_at=row[6],
|
|
)
|
|
|
|
|
|
_CHILD_COLUMNS = (
|
|
"id, display_name, colour, avatar_ref, sort_order, is_active, created_at"
|
|
)
|
|
|
|
|
|
class ChildrenDao:
|
|
"""Typed async data access for the ``children`` table.
|
|
|
|
Deletion is deliberately absent: children are deactivated, never
|
|
deleted, because completion history references them (feature
|
|
guardrail).
|
|
"""
|
|
|
|
def __init__(self, database) -> None:
|
|
self._database = database
|
|
|
|
async def create(
|
|
self,
|
|
display_name: str,
|
|
created_at: str,
|
|
*,
|
|
colour: str | None = None,
|
|
avatar_ref: str | None = None,
|
|
sort_order: int = 0,
|
|
is_active: bool = True,
|
|
) -> ChildRecord:
|
|
"""Insert one child and return the record as stored."""
|
|
result = await self._database.execute(
|
|
"INSERT INTO children (display_name, colour, avatar_ref, "
|
|
"sort_order, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
(
|
|
display_name,
|
|
colour,
|
|
avatar_ref,
|
|
sort_order,
|
|
int(is_active),
|
|
created_at,
|
|
),
|
|
)
|
|
child = await self.get(result.lastrowid)
|
|
assert child is not None
|
|
return child
|
|
|
|
async def get(self, child_id: int) -> ChildRecord | None:
|
|
"""Return the child with ``child_id``, or None."""
|
|
row = await self._database.fetch_one(
|
|
f"SELECT {_CHILD_COLUMNS} FROM children WHERE id = ?",
|
|
(child_id,),
|
|
)
|
|
return _child_from_row(row) if row is not None else None
|
|
|
|
async def list_active(self) -> list[ChildRecord]:
|
|
"""Return active children ordered by sort_order, then id."""
|
|
rows = await self._database.fetch_all(
|
|
f"SELECT {_CHILD_COLUMNS} FROM children WHERE is_active = 1 "
|
|
"ORDER BY sort_order, id"
|
|
)
|
|
return [_child_from_row(row) for row in rows]
|
|
|
|
async def list_all(self) -> list[ChildRecord]:
|
|
"""Return all children ordered by sort_order, then id."""
|
|
rows = await self._database.fetch_all(
|
|
f"SELECT {_CHILD_COLUMNS} FROM children "
|
|
"ORDER BY sort_order, id"
|
|
)
|
|
return [record for record in map(_child_from_row, rows)]
|
|
|
|
async def update(
|
|
self,
|
|
child_id: int,
|
|
*,
|
|
display_name: str | None = None,
|
|
colour: str | None = None,
|
|
avatar_ref: str | None = None,
|
|
sort_order: int | None = None,
|
|
) -> int:
|
|
"""Update the given fields; returns rows updated (0 if absent).
|
|
|
|
Only non-None arguments are written, so a caller cannot
|
|
accidentally blank a column by omitting it.
|
|
"""
|
|
assignments: list[str] = []
|
|
parameters: list[object] = []
|
|
if display_name is not None:
|
|
assignments.append("display_name = ?")
|
|
parameters.append(display_name)
|
|
if colour is not None:
|
|
assignments.append("colour = ?")
|
|
parameters.append(colour)
|
|
if avatar_ref is not None:
|
|
assignments.append("avatar_ref = ?")
|
|
parameters.append(avatar_ref)
|
|
if sort_order is not None:
|
|
assignments.append("sort_order = ?")
|
|
parameters.append(sort_order)
|
|
if not assignments:
|
|
return 0
|
|
parameters.append(child_id)
|
|
result = await self._database.execute(
|
|
f"UPDATE children SET {', '.join(assignments)} WHERE id = ?",
|
|
tuple(parameters),
|
|
)
|
|
return result.rowcount
|
|
|
|
async def set_active(self, child_id: int, is_active: bool) -> int:
|
|
"""Set the child's active flag; returns rows updated (0 if absent)."""
|
|
result = await self._database.execute(
|
|
"UPDATE children SET is_active = ? WHERE id = ?",
|
|
(int(is_active), child_id),
|
|
)
|
|
return result.rowcount
|
|
|
|
async def reorder(self, ordered_ids: list[int]) -> None:
|
|
"""Persist a new sort order: ``ordered_ids[i]`` gets sort_order i.
|
|
|
|
Runs inside one transaction; positions are staged at offset
|
|
-len (all negative, so any permutation of the same ids never
|
|
collides mid-reorder under the wrapper's single-connection
|
|
serialization), then finalized 0..n-1.
|
|
"""
|
|
if not ordered_ids:
|
|
return
|
|
async with self._database.transaction():
|
|
offset = len(ordered_ids)
|
|
await self._database.execute_many(
|
|
"UPDATE children SET sort_order = ? WHERE id = ?",
|
|
[
|
|
(-(offset + index), child_id)
|
|
for index, child_id in enumerate(ordered_ids)
|
|
],
|
|
)
|
|
await self._database.execute_many(
|
|
"UPDATE children SET sort_order = ? WHERE id = ?",
|
|
[
|
|
(index, child_id)
|
|
for index, child_id in enumerate(ordered_ids)
|
|
],
|
|
)
|
|
|
|
|
|
class AdminUsersDao:
|
|
"""Typed async access to the ``admin_users`` allowlist.
|
|
|
|
The allowlist stores HA user IDs, never usernames (feature
|
|
guardrail); an empty allowlist means no admin, enforced by callers
|
|
resolving through :meth:`exists`.
|
|
"""
|
|
|
|
def __init__(self, database) -> None:
|
|
self._database = database
|
|
|
|
async def add(self, ha_user_id: str, added_at: str) -> bool:
|
|
"""Add a user to the allowlist; True if inserted, False if present.
|
|
|
|
Idempotent by design: allowlist management UIs re-adding an
|
|
existing admin must not raise.
|
|
"""
|
|
existing = await self._database.fetch_one(
|
|
"SELECT 1 FROM admin_users WHERE ha_user_id = ?",
|
|
(ha_user_id,),
|
|
)
|
|
if existing is not None:
|
|
return False
|
|
await self._database.execute(
|
|
"INSERT INTO admin_users (ha_user_id, added_at) VALUES (?, ?)",
|
|
(ha_user_id, added_at),
|
|
)
|
|
return True
|
|
|
|
async def remove(self, ha_user_id: str) -> bool:
|
|
"""Remove a user from the allowlist; True if a row was removed."""
|
|
result = await self._database.execute(
|
|
"DELETE FROM admin_users WHERE ha_user_id = ?",
|
|
(ha_user_id,),
|
|
)
|
|
return result.rowcount > 0
|
|
|
|
async def list(self) -> list[AdminUserRecord]:
|
|
"""Return every allowlisted user, oldest first."""
|
|
rows = await self._database.fetch_all(
|
|
"SELECT ha_user_id, added_at FROM admin_users "
|
|
"ORDER BY added_at, ha_user_id"
|
|
)
|
|
return [
|
|
AdminUserRecord(ha_user_id=row[0], added_at=row[1])
|
|
for row in rows
|
|
]
|
|
|
|
async def exists(self, ha_user_id: str) -> bool:
|
|
"""Return True if ``ha_user_id`` is allowlisted."""
|
|
row = await self._database.fetch_one(
|
|
"SELECT 1 FROM admin_users WHERE ha_user_id = ?",
|
|
(ha_user_id,),
|
|
)
|
|
return row is not None |