Spaces:
Running
Running
Satyam S
QR self-enrollment, multi-photo attendance, reject-and-rematch reinforcement, and enrollment reliability fixes
4f0aa08 | """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() | |