feat: let a teacher ask the sanctuary to call a child's parent

The coordinator already existed on the control computer and its contract
is frozen, so this is the classroom half of a contract that was read from
its README and source rather than guessed: four fields out, the status
topic subscribed before anything is published, and every word the teacher
reads taken from the coordinator's own message field.

It is a separate process, not a Plasma applet. A broker library, a
network timeout and a text entry have no business inside the only way a
teacher has of getting anywhere; if this dies the bar is untouched.

The panel mark is Breeze's call-outgoing. The old one was a bell, sitting
two inches from Plasma's notification bell in the same bar and meaning
something entirely different.

Two things it will not do: claim a call was displayed, and let a teacher
type the room. The machine already knows which room it is.

Sixty tests cover the contract without a broker. The live path was proven
against the real coordinator running in the lab, on a loopback broker,
with ProPresenter mocked and the church's networks rejected from inside
the container first.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-10 10:37:06 -05:00
co-authored by Claude Opus 5
parent 380f031092
commit 7790003c03
17 changed files with 1478 additions and 28 deletions
+290
View File
@@ -0,0 +1,290 @@
"""The classroom's side of Parent Call.
A teacher types a child's name and presses a button. This module holds
everything about that which does not need a broker: the topics, the shape
of a request, how to read a status, and what the teacher may be told.
**The contract is the coordinator's, not ours.** It is documented in
`/home/media/docker/parent-call/README.md` on the control computer and was
read from there rather than guessed. Four fields go out; anything else in
the payload is discarded at the far end. Every word the teacher reads comes
back from the coordinator in the `message` field, because the coordinator
is the only thing that knows what actually happened.
The rule the whole design turns on:
A ProPresenter call that succeeded means "sent to sanctuary".
It never means "displayed".
Nothing here says a call was seen, because nothing here knows.
"""
import re
import secrets
import time
import unicodedata
DEFAULT_ROOT = "classroom/parent-call"
# Statuses the coordinator can send. Names and wording are its, verbatim;
# the wording is only a fallback for a status that arrives without a
# message, because the coordinator's own text is what should be read.
SUBMITTED = "submitted"
WAITING_APPROVAL = "waiting_approval"
SENDING = "sending"
SENT_TO_SANCTUARY = "sent_to_sanctuary"
REJECTED = "rejected"
EXPIRED = "expired"
UNAVAILABLE = "unavailable"
ERROR = "error"
STATUSES = (
SUBMITTED,
WAITING_APPROVAL,
SENDING,
SENT_TO_SANCTUARY,
REJECTED,
EXPIRED,
UNAVAILABLE,
ERROR,
)
WORDING = {
SUBMITTED: "Sending",
WAITING_APPROVAL: "Waiting for approval",
SENDING: "Sending to sanctuary",
SENT_TO_SANCTUARY: "Sent to sanctuary",
REJECTED: "Not approved",
EXPIRED: "Expired before it was sent",
UNAVAILABLE: "Parent Call is unavailable",
ERROR: "Could not reach the sanctuary",
}
# Once one of these arrives there is nothing further to wait for, and the
# classroom stops listening. The coordinator's README asks for exactly
# this.
TERMINAL = frozenset((
SENT_TO_SANCTUARY,
REJECTED,
EXPIRED,
UNAVAILABLE,
ERROR,
))
# Only this one means the call is on its way and the teacher can walk away.
GOOD = frozenset((SENT_TO_SANCTUARY,))
MODE_AUTO = "auto"
MODE_APPROVAL = "approval"
MODE_OFF = "off"
MODES = (MODE_AUTO, MODE_APPROVAL, MODE_OFF)
# The coordinator's own limit, so a name is refused here rather than
# after a round trip. MAX_CHILD_LENGTH in its .env.
MAX_CHILD = 40
# Its id shape, so an id this end builds can never be rejected as
# unusable — the one failure it cannot answer, because the id is the
# topic it would answer on.
ID_SHAPE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$")
# Control, format, unassigned, surrogate, private-use, and every kind of
# space separator. The coordinator strips these; doing the same here
# means the teacher sees the name that will actually be sent.
INVISIBLE = frozenset(("Cc", "Cf", "Cn", "Co", "Cs", "Zl", "Zp", "Zs"))
class Topics:
"""Every topic the classroom touches, from one root."""
def __init__(self, root=DEFAULT_ROOT):
self.root = (root or DEFAULT_ROOT).rstrip("/")
@property
def mode(self):
"""Retained. auto | approval | off, as plain text."""
return f"{self.root}/mode"
@property
def coordinator(self):
"""Retained. Whether the sanctuary end is up at all."""
return f"{self.root}/coordinator"
@property
def request(self):
"""Never retained: a request is an event, and last Sunday's call
must not replay into this one."""
return f"{self.root}/request"
def status(self, request_id):
return f"{self.root}/status/{request_id}"
def visible(value):
"""The same text with everything invisible turned into plain spaces.
The same rule the coordinator applies, so the name in the field is
the name that reaches the screen.
"""
swapped = "".join(
" " if unicodedata.category(char) in INVISIBLE else char
for char in value
)
return " ".join(swapped.split())
def clean_child(value):
"""A child's name as it will be sent, or None if it is not one."""
if not isinstance(value, str):
return None
name = visible(unicodedata.normalize("NFC", value))
if not name or len(name) > MAX_CHILD:
return None
return name
def new_request_id(room, token=None):
"""A room-scoped id that the coordinator will accept.
Random rather than sequential: two rooms calling in the same second
must not be able to collide, and an id that can be predicted is an id
somebody else's status could be read on.
"""
suffix = token or secrets.token_hex(3)
candidate = f"{room}-{suffix}"
return candidate if ID_SHAPE.match(candidate) else suffix
def request_payload(request_id, room, child, now=None):
"""The whole vocabulary a classroom has: four fields.
No ProPresenter text, no message name, no theme. The coordinator
builds what reaches the screen from values that passed its own
validation, and a classroom cannot reach past it.
"""
return {
"id": request_id,
"room": room,
"child": child,
"created_at": int(now if now is not None else time.time()),
}
def read_mode(payload):
"""auto, approval or off — or None if it is not one of those."""
if isinstance(payload, (bytes, bytearray)):
try:
payload = payload.decode("utf-8")
except UnicodeDecodeError:
return None
if not isinstance(payload, str):
return None
mode = payload.strip().lower()
return mode if mode in MODES else None
def read_coordinator(payload):
"""Whether the sanctuary end says it is up.
Anything unreadable is treated as down. A classroom that cannot tell
should offer to send nothing, rather than let a teacher believe a
call went somewhere.
"""
import json
if isinstance(payload, (bytes, bytearray)):
try:
payload = payload.decode("utf-8")
except UnicodeDecodeError:
return False
if not isinstance(payload, str) or not payload.strip():
return False
try:
body = json.loads(payload)
except ValueError:
return False
return bool(isinstance(body, dict) and body.get("online") is True)
def read_status(payload, request_id=None):
"""A status update, or None if this is not one worth showing.
Defensive on purpose: this is the one message the teacher reads, and
a malformed payload must leave the dialog saying what it said before
rather than showing nonsense.
"""
import json
if isinstance(payload, (bytes, bytearray)):
try:
payload = payload.decode("utf-8")
except UnicodeDecodeError:
return None
try:
body = json.loads(payload)
except (TypeError, ValueError):
return None
if not isinstance(body, dict):
return None
status = body.get("status")
if not isinstance(status, str) or status not in STATUSES:
return None
# A status for somebody else's request is not ours to show. It should
# never arrive — we subscribe to one topic — but a wildcard mistake
# would otherwise put another room's child on this screen.
if request_id is not None and body.get("id") not in (None, request_id):
return None
message = body.get("message")
if not isinstance(message, str) or not message.strip():
message = WORDING.get(status, status)
detail = body.get("detail")
return {
"id": body.get("id"),
"status": status,
"message": visible(message)[:200],
"detail": visible(detail)[:200] if isinstance(detail, str) else None,
"terminal": status in TERMINAL,
"good": status in GOOD,
}
def availability(mode, coordinator_online, broker_reachable=True):
"""Whether a teacher may send a call now, and what to say if not.
Returns (can_send, message). The wording for an off mode is the
coordinator's own, so the room says the same thing the sanctuary
would.
"""
if not broker_reachable:
return False, "Cannot reach the church network"
if not coordinator_online:
return False, "The sanctuary system is not running"
if mode == MODE_OFF:
return False, WORDING[UNAVAILABLE]
if mode is None:
return False, "Parent Call has not been set up yet"
return True, None