"""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, } # Why the room has no broker, in the teacher's words. # # "Cannot reach" and "is not set up" are different problems with different # people to fetch: the first is a network or a broker that is down and # will come back, the second is a machine that was never told where the # church network is and needs an administrator. Saying the first when the # truth is the second sends somebody to look at a router for an hour. BROKER_UNREACHABLE = "Cannot reach the church network" BROKER_WORDING = { "not configured": "This room has no church network set up", "no broker library": "This room has no church network set up", } def broker_message(why=None): """What to tell the teacher about a broker that is not there.""" return BROKER_WORDING.get(why, BROKER_UNREACHABLE) def availability(mode, coordinator_online, broker_reachable=True, why=None): """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, broker_message(why) 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