Prismai / start_enrollment_session.py
Satyam S
QR self-enrollment, multi-photo attendance, reject-and-rematch reinforcement, and enrollment reliability fixes
4f0aa08
Raw
History Blame Contribute Delete
6.4 kB
"""One-command launcher for a QR-based self-enrollment session.
Starts the Flask app (threaded, bound to 0.0.0.0 so it can also be reached
directly over LAN) and a cloudflared quick tunnel (so it's also reachable
over the *public* internet β€” students on mobile data don't need to be on
the teacher's Wi-Fi). Once the tunnel is up, its URL is written to
activity_web/runtime/tunnel_url.txt, which the running Flask app reads to
build the QR code shown on the Attendance tab ("Show enrollment QR").
Usage:
python start_enrollment_session.py [--classroom cse8]
Ctrl+C stops both processes and clears the tunnel URL.
"""
from __future__ import annotations
import re
import shutil
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(REPO_ROOT))
from activity_web.backend.config import RUNTIME_DIR, TUNNEL_URL_PATH # noqa: E402
TUNNEL_URL_RE = re.compile(r"https://[a-zA-Z0-9\-]+\.trycloudflare\.com")
FLASK_PORT = 8080
def check_cloudflared() -> None:
if shutil.which("cloudflared") is None:
print(
"cloudflared isn't installed. On macOS: brew install cloudflared\n"
"(see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ "
"for other platforms)"
)
sys.exit(1)
def port_in_use(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
return sock.connect_ex(("127.0.0.1", port)) == 0
def stream_output(pipe, prefix: str, on_line=None) -> None:
for line in iter(pipe.readline, ""):
if not line:
break
print(f"[{prefix}] {line.rstrip()}")
if on_line:
on_line(line)
def main() -> None:
sys.stdout.reconfigure(line_buffering=True)
check_cloudflared()
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
if port_in_use(FLASK_PORT):
print(
f"Port {FLASK_PORT} is already in use β€” an enrollment session is probably already\n"
f"running (maybe in another terminal, or left over from earlier). Either use that\n"
f"one, or stop it first:\n"
f" lsof -ti:{FLASK_PORT} | xargs kill; pkill -f 'cloudflared tunnel'\n"
f"then re-run this script. (Starting a second instance on top of a live one leaves\n"
f"a stale tunnel URL behind, which is exactly what caused the Cloudflare 1033 error.)"
)
sys.exit(1)
# Only cleared past this point β€” starting a second instance while one is
# already live must NOT touch a working session's tunnel_url.txt.
TUNNEL_URL_PATH.unlink(missing_ok=True)
flask_proc: subprocess.Popen | None = None
tunnel_proc: subprocess.Popen | None = None
def cleanup() -> None:
TUNNEL_URL_PATH.unlink(missing_ok=True)
for proc in (tunnel_proc, flask_proc):
if proc is not None and proc.poll() is None:
proc.terminate()
for proc in (tunnel_proc, flask_proc):
if proc is None:
continue
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
try:
print(f"Starting the local server on http://0.0.0.0:{FLASK_PORT} ...")
flask_proc = subprocess.Popen(
[sys.executable, "-m", "activity_web.backend.app"],
cwd=REPO_ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
threading.Thread(target=stream_output, args=(flask_proc.stdout, "server"), daemon=True).start()
print("Starting the cloudflared tunnel ...")
tunnel_proc = subprocess.Popen(
["cloudflared", "tunnel", "--url", f"http://localhost:{FLASK_PORT}"],
cwd=REPO_ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
found_url: dict[str, str | None] = {"url": None}
def watch_for_url(line: str) -> None:
# Free quick tunnels have no uptime guarantee β€” if the connection
# drops and cloudflared reconnects, it can get reassigned a new
# hostname while this process keeps running. Always take the
# latest one (not just the first) so the QR panel self-heals
# instead of quietly serving a dead link.
match = TUNNEL_URL_RE.search(line)
if match and match.group(0) != found_url["url"]:
found_url["url"] = match.group(0)
TUNNEL_URL_PATH.write_text(found_url["url"], encoding="utf-8")
print(f"[tunnel] URL is now: {found_url['url']}")
threading.Thread(
target=stream_output, args=(tunnel_proc.stdout, "tunnel", watch_for_url), daemon=True
).start()
print("Waiting for the tunnel URL ...")
waited = 0.0
while not found_url["url"] and waited < 30:
time.sleep(0.5)
waited += 0.5
if flask_proc.poll() is not None:
print("The local server exited unexpectedly β€” check the [server] log above.")
return
if tunnel_proc.poll() is not None:
print("cloudflared exited unexpectedly β€” check the [tunnel] log above.")
return
if not found_url["url"]:
print("Timed out waiting for the tunnel URL. Check the [tunnel] log above for errors.")
return
print("\n" + "=" * 60)
print("Enrollment session is live.")
print(f" Public URL: {found_url['url']}")
print(f" Dashboard: http://localhost:{FLASK_PORT}/")
print("Open the dashboard, pick a classroom on the Attendance tab, and")
print("click 'Show enrollment QR' to display the QR code for students.")
print("=" * 60 + "\n")
print("Press Ctrl+C to stop the session.")
while True:
time.sleep(1)
if flask_proc.poll() is not None or tunnel_proc.poll() is not None:
print("A subprocess exited β€” stopping the session.")
break
except KeyboardInterrupt:
print("\nStopping...")
finally:
cleanup()
if __name__ == "__main__":
main()