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:
co-authored by
Claude Opus 5
parent
380f031092
commit
7790003c03
@@ -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
|
||||
Reference in New Issue
Block a user