diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 89f359c..9143ccb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -53,3 +53,5 @@ jobs: tags: | ghcr.io/${{ steps.lowercase.outputs.owner }}/${{ steps.image.outputs.name }}:${{ github.sha }} ${{ steps.image.outputs.extra_tag }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/docker-compose.yml b/docker-compose.yml index 202ed26..49d609c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,6 @@ services: network_mode: host environment: - DISPLAY=:0 - - CHECK_IN_API_URL=http://10.8.0.1:8000 env_file: - .env volumes: diff --git a/fonts/Montserrat-VariableFont_wght.ttf b/fonts/Montserrat-VariableFont_wght.ttf deleted file mode 100644 index 451e692..0000000 Binary files a/fonts/Montserrat-VariableFont_wght.ttf and /dev/null differ diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..70e366d --- /dev/null +++ b/mypy.ini @@ -0,0 +1,13 @@ +[mypy] +strict = true +explicit_package_bases = true +mypy_path = src + +[mypy-adafruit_pn532.*] +ignore_missing_imports = true + +[mypy-qtawesome] +ignore_missing_imports = true + +[mypy-pyqttoast.*] +ignore_missing_imports = true diff --git a/requirements.txt b/requirements.txt index e6c0f76..b8b5bab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,13 @@ +pydantic==2.13.4 pyserial==3.5 requests==2.32.5 adafruit-circuitpython-pn532 RPi.GPIO; sys_platform == 'linux' pyqt6==6.11.0 +pyqt6-stubs qtawesome -pyqt6-sip \ No newline at end of file +pyqt6-sip +pyqt-toast-notification==1.3.3 +mypy==2.1.0 +types-pyserial +types-requests \ No newline at end of file diff --git a/run_dev.sh b/run_dev.sh index e190a06..f61e700 100755 --- a/run_dev.sh +++ b/run_dev.sh @@ -11,10 +11,4 @@ for arg in "$@"; do fi done -output_file="log.txt" - -echo "" >> "$output_file" - -date "+%Y-%m-%d %H:%M:%S" >> "$output_file" - -python src/main.py "$@" 2>&1 | tee -a log.txt +python src/main.py "$@" diff --git a/src/app_context.py b/src/app_context.py deleted file mode 100644 index 93de625..0000000 --- a/src/app_context.py +++ /dev/null @@ -1,33 +0,0 @@ -import threading - -from controllers.traffic_light_controller import TrafficLightController -from hardware.traffic_light import TrafficLight - - -class AppContext: - def __init__(self, traffic_light: TrafficLightController): - self.traffic_light = traffic_light - self.window = None - self.nav = None - self.check_in = None - self.account = None - self.dispatcher = None - self.has_barcode_scanner = False - self._rfid_lock = threading.Lock() - self._rfid: str = "" - - @property - def rfid(self) -> str: - with self._rfid_lock: - return self._rfid - - @rfid.setter - def rfid(self, value: str) -> None: - with self._rfid_lock: - self._rfid = value - - @classmethod - def create(cls, traffic_usb_id=None) -> "AppContext": - light = TrafficLight(traffic_usb_id) - traffic = TrafficLightController(light) - return cls(traffic) diff --git a/src/assets/shared/background_main.png b/src/assets/background_main.png similarity index 100% rename from src/assets/shared/background_main.png rename to src/assets/background_main.png diff --git a/src/assets/check_in_manual/button_check_in.png b/src/assets/check_in_manual/button_check_in.png deleted file mode 100644 index d5f7161..0000000 Binary files a/src/assets/check_in_manual/button_check_in.png and /dev/null differ diff --git a/src/assets/check_in_rfid/icon_check_in.png b/src/assets/check_in_rfid/icon_check_in.png deleted file mode 100644 index 05728bb..0000000 Binary files a/src/assets/check_in_rfid/icon_check_in.png and /dev/null differ diff --git a/src/assets/create_account_barcode/button_fill_manually.png b/src/assets/create_account_barcode/button_fill_manually.png deleted file mode 100644 index 6771e90..0000000 Binary files a/src/assets/create_account_barcode/button_fill_manually.png and /dev/null differ diff --git a/src/assets/create_account_barcode/outline_1.png b/src/assets/create_account_barcode/outline_1.png deleted file mode 100644 index 45147ba..0000000 Binary files a/src/assets/create_account_barcode/outline_1.png and /dev/null differ diff --git a/src/assets/create_account_barcode/outline_2.png b/src/assets/create_account_barcode/outline_2.png deleted file mode 100644 index d42add7..0000000 Binary files a/src/assets/create_account_barcode/outline_2.png and /dev/null differ diff --git a/src/assets/create_account_manual/outline_1.png b/src/assets/create_account_manual/outline_1.png deleted file mode 100644 index 45147ba..0000000 Binary files a/src/assets/create_account_manual/outline_1.png and /dev/null differ diff --git a/src/assets/create_account_manual/outline_2.png b/src/assets/create_account_manual/outline_2.png deleted file mode 100644 index d42add7..0000000 Binary files a/src/assets/create_account_manual/outline_2.png and /dev/null differ diff --git a/src/assets/create_account_manual/register.png b/src/assets/create_account_manual/register.png deleted file mode 100644 index 3fbf9af..0000000 Binary files a/src/assets/create_account_manual/register.png and /dev/null differ diff --git a/src/assets/fonts/Montserrat-Black.ttf b/src/assets/fonts/Montserrat-Black.ttf new file mode 100644 index 0000000..2fab7ab Binary files /dev/null and b/src/assets/fonts/Montserrat-Black.ttf differ diff --git a/src/assets/fonts/Montserrat-BlackItalic.ttf b/src/assets/fonts/Montserrat-BlackItalic.ttf new file mode 100644 index 0000000..04d3b47 Binary files /dev/null and b/src/assets/fonts/Montserrat-BlackItalic.ttf differ diff --git a/src/assets/fonts/Montserrat-Bold.ttf b/src/assets/fonts/Montserrat-Bold.ttf new file mode 100644 index 0000000..4033587 Binary files /dev/null and b/src/assets/fonts/Montserrat-Bold.ttf differ diff --git a/src/assets/fonts/Montserrat-BoldItalic.ttf b/src/assets/fonts/Montserrat-BoldItalic.ttf new file mode 100644 index 0000000..0cc5c2c Binary files /dev/null and b/src/assets/fonts/Montserrat-BoldItalic.ttf differ diff --git a/src/assets/fonts/Montserrat-ExtraBold.ttf b/src/assets/fonts/Montserrat-ExtraBold.ttf new file mode 100644 index 0000000..476ec30 Binary files /dev/null and b/src/assets/fonts/Montserrat-ExtraBold.ttf differ diff --git a/src/assets/fonts/Montserrat-ExtraBoldItalic.ttf b/src/assets/fonts/Montserrat-ExtraBoldItalic.ttf new file mode 100644 index 0000000..a1ac9a9 Binary files /dev/null and b/src/assets/fonts/Montserrat-ExtraBoldItalic.ttf differ diff --git a/src/assets/fonts/Montserrat-ExtraLight.ttf b/src/assets/fonts/Montserrat-ExtraLight.ttf new file mode 100644 index 0000000..efaeab0 Binary files /dev/null and b/src/assets/fonts/Montserrat-ExtraLight.ttf differ diff --git a/src/assets/fonts/Montserrat-ExtraLightItalic.ttf b/src/assets/fonts/Montserrat-ExtraLightItalic.ttf new file mode 100644 index 0000000..a8d18de Binary files /dev/null and b/src/assets/fonts/Montserrat-ExtraLightItalic.ttf differ diff --git a/src/assets/fonts/Montserrat-Italic.ttf b/src/assets/fonts/Montserrat-Italic.ttf new file mode 100644 index 0000000..5f08df0 Binary files /dev/null and b/src/assets/fonts/Montserrat-Italic.ttf differ diff --git a/src/assets/fonts/Montserrat-Light.ttf b/src/assets/fonts/Montserrat-Light.ttf new file mode 100644 index 0000000..881f12d Binary files /dev/null and b/src/assets/fonts/Montserrat-Light.ttf differ diff --git a/src/assets/fonts/Montserrat-LightItalic.ttf b/src/assets/fonts/Montserrat-LightItalic.ttf new file mode 100644 index 0000000..b2991d0 Binary files /dev/null and b/src/assets/fonts/Montserrat-LightItalic.ttf differ diff --git a/src/assets/fonts/Montserrat-Medium.ttf b/src/assets/fonts/Montserrat-Medium.ttf new file mode 100644 index 0000000..c9a39ea Binary files /dev/null and b/src/assets/fonts/Montserrat-Medium.ttf differ diff --git a/src/assets/fonts/Montserrat-MediumItalic.ttf b/src/assets/fonts/Montserrat-MediumItalic.ttf new file mode 100644 index 0000000..086dd6e Binary files /dev/null and b/src/assets/fonts/Montserrat-MediumItalic.ttf differ diff --git a/src/assets/fonts/Montserrat-Regular.ttf b/src/assets/fonts/Montserrat-Regular.ttf new file mode 100644 index 0000000..895e220 Binary files /dev/null and b/src/assets/fonts/Montserrat-Regular.ttf differ diff --git a/src/assets/fonts/Montserrat-SemiBold.ttf b/src/assets/fonts/Montserrat-SemiBold.ttf new file mode 100644 index 0000000..161477a Binary files /dev/null and b/src/assets/fonts/Montserrat-SemiBold.ttf differ diff --git a/src/assets/fonts/Montserrat-SemiBoldItalic.ttf b/src/assets/fonts/Montserrat-SemiBoldItalic.ttf new file mode 100644 index 0000000..73dc6c6 Binary files /dev/null and b/src/assets/fonts/Montserrat-SemiBoldItalic.ttf differ diff --git a/src/assets/fonts/Montserrat-Thin.ttf b/src/assets/fonts/Montserrat-Thin.ttf new file mode 100644 index 0000000..c9cf195 Binary files /dev/null and b/src/assets/fonts/Montserrat-Thin.ttf differ diff --git a/src/assets/fonts/Montserrat-ThinItalic.ttf b/src/assets/fonts/Montserrat-ThinItalic.ttf new file mode 100644 index 0000000..e6dfc05 Binary files /dev/null and b/src/assets/fonts/Montserrat-ThinItalic.ttf differ diff --git a/src/assets/qr_codes/qr_waiver.png b/src/assets/qr_waiver.png similarity index 100% rename from src/assets/qr_codes/qr_waiver.png rename to src/assets/qr_waiver.png diff --git a/src/assets/qr_codes/qr_website.png b/src/assets/qr_website.png similarity index 100% rename from src/assets/qr_codes/qr_website.png rename to src/assets/qr_website.png diff --git a/src/assets/shared/button_generic.png b/src/assets/shared/button_generic.png deleted file mode 100644 index 7eeb581..0000000 Binary files a/src/assets/shared/button_generic.png and /dev/null differ diff --git a/src/assets/shared/field.png b/src/assets/shared/field.png deleted file mode 100644 index 581d067..0000000 Binary files a/src/assets/shared/field.png and /dev/null differ diff --git a/src/assets/shared/icon_checked_box.png b/src/assets/shared/icon_checked_box.png deleted file mode 100644 index 19cd880..0000000 Binary files a/src/assets/shared/icon_checked_box.png and /dev/null differ diff --git a/src/assets/shared/icon_home.png b/src/assets/shared/icon_home.png deleted file mode 100644 index f9e9621..0000000 Binary files a/src/assets/shared/icon_home.png and /dev/null differ diff --git a/src/assets/shared/icon_unchecked_box.png b/src/assets/shared/icon_unchecked_box.png deleted file mode 100644 index 37f2074..0000000 Binary files a/src/assets/shared/icon_unchecked_box.png and /dev/null differ diff --git a/src/assets/shared/outline_full.png b/src/assets/shared/outline_full.png deleted file mode 100644 index deee252..0000000 Binary files a/src/assets/shared/outline_full.png and /dev/null differ diff --git a/src/assets/sign_waiver/button_done_scanning.png b/src/assets/sign_waiver/button_done_scanning.png deleted file mode 100644 index 113f889..0000000 Binary files a/src/assets/sign_waiver/button_done_scanning.png and /dev/null differ diff --git a/src/assets/sign_waiver/outline_1.png b/src/assets/sign_waiver/outline_1.png deleted file mode 100644 index 27efff9..0000000 Binary files a/src/assets/sign_waiver/outline_1.png and /dev/null differ diff --git a/src/assets/sign_waiver/outline_2.png b/src/assets/sign_waiver/outline_2.png deleted file mode 100644 index 1cb2946..0000000 Binary files a/src/assets/sign_waiver/outline_2.png and /dev/null differ diff --git a/src/assets/sign_waiver/outline_3.png b/src/assets/sign_waiver/outline_3.png deleted file mode 100644 index 404aa59..0000000 Binary files a/src/assets/sign_waiver/outline_3.png and /dev/null differ diff --git a/src/assets/sign_waiver/qr_waiver.png b/src/assets/sign_waiver/qr_waiver.png deleted file mode 100644 index c6dc0cf..0000000 Binary files a/src/assets/sign_waiver/qr_waiver.png and /dev/null differ diff --git a/src/config.py b/src/config.py deleted file mode 100644 index b8b262b..0000000 --- a/src/config.py +++ /dev/null @@ -1,3 +0,0 @@ -import os - -API_BASE_URL = os.environ.get("CHECK_IN_API_URL") diff --git a/src/controllers/account_controller.py b/src/controllers/account_controller.py index 2d32eb4..e65bc8d 100644 --- a/src/controllers/account_controller.py +++ b/src/controllers/account_controller.py @@ -1,107 +1,66 @@ +from __future__ import annotations + import logging -from threading import Thread +from typing import Literal -from PyQt6.QtCore import QTimer +from pyqttoast import ToastPreset -from controllers.api_controller import ApiController, ExternalApiError +from controllers.api_controller import ExternalApiError +from misc.api_models import CreateAccountResponse, Student, StudentResponse +from misc.global_context import context class AccountController: - def __init__(self, ctx): - self.ctx = ctx - - def go_to_review_from_barcode(self, barcode): - self.ctx.nav.show_status("Looking up student...") - logging.info(f"looking up student by barcode: {barcode}") - Thread(target=self._lookup_barcode_worker, args=(barcode,), daemon=True).start() - - def _lookup_barcode_worker(self, barcode): - try: - student = ApiController.lookup_by_barcode(barcode) - except ExternalApiError as e: - self.ctx.dispatcher.call.emit(lambda: self._on_external_api_error(e.api)) - return - self.ctx.dispatcher.call.emit(lambda s=student: self._on_barcode_result(s)) - - def _on_barcode_result(self, student): - self.ctx.nav.hide_status() - if student is None: - self.ctx.nav.show_status("Student not found. Please enter your details manually.") - QTimer.singleShot(3000, self.ctx.nav.hide_status) - return - self.ctx.nav.go_to_create_account_review( - pid=student["pid"], - first_name=student["first_name"], - last_name=student["last_name"], - email=student["email"], - ) + def __init__(self) -> None: + logging.info("account controller initialized") - def go_to_review_from_pid(self, pid): - self.ctx.nav.show_status("Looking up student...") - logging.info(f"looking up student by PID: {pid}") - Thread(target=self._lookup_pid_worker, args=(pid,), daemon=True).start() - - def _lookup_pid_worker(self, pid): + def lookup(self, by: Literal["pid", "barcode"], value: str) -> Student | None: + context().main_window.show_toast_async("Looking Up Student", "", ToastPreset.INFORMATION) + logging.info(f"looking up student by {by}: {value}") try: - student = ApiController.lookup_by_pid(pid) + response = context().api_controller.request("GET", f"/accounts/{by}/{value}") + if response.status_code == 404: + context().main_window.show_toast_async("Student Not Found", "Please enter your details manually", ToastPreset.ERROR) + return None + response.raise_for_status() + return StudentResponse.model_validate(response.json()).student except ExternalApiError as e: - self.ctx.dispatcher.call.emit(lambda: self._on_external_api_error(e.api)) - return - self.ctx.dispatcher.call.emit(lambda s=student: self._on_pid_result(s, pid)) - - def _on_pid_result(self, student, pid): - self.ctx.nav.hide_status() - if student is None: - self.ctx.nav.show_status("Student not found. Please check your PID.") - QTimer.singleShot(3000, self.ctx.nav.hide_status) - return - self.ctx.nav.go_to_create_account_review( - pid=pid, - first_name=student["first_name"], - last_name=student["last_name"], - email=student["email"], - ) - - def create_account_from_review(self, *, first_name, last_name, email, pid): - if pid: - self._create(pid=pid) - else: - self._create(first_name=first_name, last_name=last_name, email=email) + context().main_window.show_toast_async(f"System Error: {e.api}", "Please talk to a staff member", ToastPreset.ERROR) + return None + except Exception as e: + logging.error(f"error looking up student by {by}: {e}") + context().main_window.show_toast_async("Student Not Found", "Please enter your details manually", ToastPreset.ERROR) + return None - def _create(self, *, barcode=None, pid=None, first_name=None, last_name=None, email=None): - self.ctx.nav.show_status("Account creation in progress!") + def create_account( + self, + *, + barcode: str | None = None, + pid: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + email: str | None = None, + ) -> bool: + context().main_window.show_toast_async("Account creation in progress!", "", ToastPreset.INFORMATION) logging.info(f"creating account: pid={pid} barcode={barcode}") - Thread( - target=self._create_worker, - kwargs=dict(barcode=barcode, pid=pid, first_name=first_name, last_name=last_name, email=email), - daemon=True, - ).start() - - def _on_external_api_error(self, api: str): - self.ctx.nav.hide_status() - self.ctx.nav.show_status(f"system error ({api.upper()} api). please talk to a staff member.") - QTimer.singleShot(4000, self.ctx.nav.hide_status) - - def _create_worker(self, *, barcode, pid, first_name, last_name, email): + payload = {k: v for k, v in { + "rfid": context().session.rfid, + "barcode": barcode, + "pid": pid, + "first_name": first_name, + "last_name": last_name, + "email": email, + }.items() if v is not None} try: - result = ApiController.create_account( - self.ctx.rfid, - barcode=barcode, - pid=pid, - first_name=first_name, - last_name=last_name, - email=email, - ) + response = context().api_controller.request("POST", "/accounts", json=payload) + response.raise_for_status() + CreateAccountResponse.model_validate(response.json()) + logging.info("account creation succeeded") + return True except ExternalApiError as e: - self.ctx.dispatcher.call.emit(lambda: self._on_external_api_error(e.api)) - return - self.ctx.dispatcher.call.emit(lambda r=result: self._on_create_result(r)) - - def _on_create_result(self, result): - self.ctx.nav.hide_status() - if result is None: - self.ctx.nav.show_status("ERROR! Could not create account, please try manually.") - QTimer.singleShot(3000, self.ctx.nav.hide_status) - return - logging.info("account creation succeeded") - self.ctx.nav.pop() + context().main_window.show_toast_async(f"System Error: {e.api}", "Please talk to a staff member", ToastPreset.ERROR) + return False + except Exception as e: + logging.error(f"error creating account: {e}") + context().main_window.show_toast_async("Account Creation Failed", "Please try again or talk to a staff member", ToastPreset.ERROR) + return False diff --git a/src/controllers/api_controller.py b/src/controllers/api_controller.py index 15abcc5..eefdbbc 100644 --- a/src/controllers/api_controller.py +++ b/src/controllers/api_controller.py @@ -1,121 +1,44 @@ +from __future__ import annotations + import logging import time +from typing import Any + import requests -from config import API_BASE_URL +from misc.global_config import config SILENT_PATHS = frozenset(["/health", "/traffic-light"]) class ExternalApiError(Exception): - def __init__(self, api: str): + def __init__(self, api: str) -> None: self.api = api - super().__init__(f"External API error: {api}") - - -class ApiUnreachableError(Exception): - pass - - -class ApiController: - @staticmethod - def _req(method, path, **kwargs): - url = f"{API_BASE_URL}{path}" - start = time.time() - resp = requests.request(method, url, **kwargs) - ms = (time.time() - start) * 1000 - if resp.status_code == 502: - raise ExternalApiError(resp.json().get("api", "unknown")) + super().__init__(f"External API error: {api}")\ + + +class APIController: + def __init__(self) -> None: + if not self.ping(): + raise RuntimeError("Could not connect to the API") + logging.info("api controller initialized") + + def request(self, method: str, path: str, **kwargs: Any) -> requests.Response: + url = f"{config().CHECK_IN_API_URL}{path}" + start: float = time.time() + response = requests.request(method, url, timeout=3, **kwargs) + req_duration = (time.time() - start) * 1000 + # 502 is the error code used by the api server to signal that the server error was upstream + # and not directly caused by a failure of the check-in api + if response.status_code == 502: + raise ExternalApiError(response.json().get("api", "unknown")) if path not in SILENT_PATHS: - logging.info(f"[CLIENT] {method.upper()} {url} -> {resp.status_code} ({ms:.0f}ms)") - return resp - - @staticmethod - def ping(): - try: - resp = ApiController._req("GET", "/health", timeout=5) - except Exception as e: - raise ApiUnreachableError(str(e)) from e - if not resp.ok: - raise ApiUnreachableError(f"status {resp.status_code}") - - @staticmethod - def checkin_by_uuid(uuid): - try: - resp = ApiController._req("GET", f"/check-in/uuid/{uuid}", timeout=10) - resp.raise_for_status() - return resp.json() - except Exception as e: - logging.error(f"error during check-in for uuid {uuid}: {e}") - return {"status": "api_error"} - - @staticmethod - def checkin_by_pid(pid): - try: - resp = ApiController._req("GET", f"/check-in/pid/{pid}", timeout=10) - resp.raise_for_status() - return resp.json() - except Exception as e: - logging.error(f"error during check-in for pid {pid}: {e}") - return {"status": "api_error"} - - @staticmethod - def set_traffic_light(color): - try: - ApiController._req("POST", "/traffic-light", json={"color": color}, timeout=5) - except Exception as e: - logging.error(f"error setting traffic light: {e}") - - @staticmethod - def get_traffic_light(): - try: - resp = ApiController._req("GET", "/traffic-light", timeout=5) - return resp.json().get("color", "off") - except Exception as e: - logging.error(f"error getting traffic light: {e}") - return "off" - - @staticmethod - def lookup_by_pid(pid): - try: - resp = ApiController._req("GET", f"/accounts/lookup/pid/{pid}", timeout=10) - if resp.status_code == 404: - return None - resp.raise_for_status() - return resp.json() - except Exception as e: - logging.error(f"error looking up student by pid {pid}: {e}") - return None - - @staticmethod - def lookup_by_barcode(barcode): - try: - resp = ApiController._req("GET", f"/accounts/lookup/barcode/{barcode}", timeout=10) - if resp.status_code == 404: - return None - resp.raise_for_status() - return resp.json() - except Exception as e: - logging.error(f"error looking up student by barcode: {e}") - return None + logging.info(f"[CLIENT] {method.upper()} {url} -> {response.status_code} ({req_duration:.0f}ms)") + return response - @staticmethod - def create_account(rfid, *, barcode, pid, first_name, last_name, email): + def ping(self) -> bool: try: - payload = {"rfid": rfid} - if barcode: - payload["barcode"] = barcode - if pid: - payload["pid"] = pid - if first_name: - payload["first_name"] = first_name - if last_name: - payload["last_name"] = last_name - if email: - payload["email"] = email - resp = ApiController._req("POST", "/accounts", json=payload, timeout=30) - resp.raise_for_status() - return resp.json() - except Exception as e: - logging.error(f"error creating account: {e}") - return None + response = self.request("GET", "/health") + return response.ok + except Exception: + return False diff --git a/src/controllers/barcode_scanner_controller.py b/src/controllers/barcode_scanner_controller.py index 7fe31aa..e9e3035 100644 --- a/src/controllers/barcode_scanner_controller.py +++ b/src/controllers/barcode_scanner_controller.py @@ -1,60 +1,89 @@ +from __future__ import annotations + import logging -import time +import threading from threading import Thread -from views.check_in_manual import CheckInManual -from views.create_account_barcode import CreateAccountBarcode -from views.create_account_manual import CreateAccountManual +from controllers.health_controller import CriticalSystemType +from hardware.barcode_scanner_netum_nt_em61 import BarcodeScannerNetumNTEM61 +from hardware.usb_ports import USBDeviceType +from misc.global_config import config +from misc.global_context import context +from misc.timeout import run_with_timeout +from ui.views.create_account_barcode import CreateAccountBarcode +from ui.views.create_account_manual import CreateAccountManual +from ui.views.create_account_review import CreateAccountReview +from ui.views.home_screen import HomeScreen class BarcodeScannerController: - def __init__(self, ctx): - self.ctx = ctx + _barcode_scanner: BarcodeScannerNetumNTEM61 | None - def start(self, scanner): - thread = Thread(target=self._run, args=(scanner,), daemon=True) - thread.start() + def __init__(self) -> None: + self._stop = threading.Event() + self._thread: Thread | None = None + self._barcode_scanner: BarcodeScannerNetumNTEM61 | None = None - def _run(self, scanner): - logging.info("now reading barcodes") - scanner_error = False + if config().HAS_BARCODE_SCANNER: + logging.info("opening barcode scanner serial port") + port = context().usb_port_controller.get_usb_device_port(USBDeviceType.BARCODE_SCANNER) + self._barcode_scanner = run_with_timeout(lambda: BarcodeScannerNetumNTEM61(port), "barcode scanner") + self._thread = Thread(target=self._run, daemon=True) + self._thread.start() + + logging.info("barcode scanner controller initialized") + + def _reconnect(self) -> bool: + logging.info("attempting barcode scanner reconnect") try: - while True: - if scanner_error: - time.sleep(0.5) - if scanner.reconnect(): - logging.info("barcode scanner reconnected") - scanner_error = False - continue + assert self._barcode_scanner + if not self._barcode_scanner.reconnect(): + return False - try: - barcode = scanner.read_barcode() - except OSError as e: - logging.error("barcode scanner disconnected: %s", e) - scanner_error = True - continue + self._stop = threading.Event() + self._thread = Thread(target=self._run, daemon=True) + self._thread.start() + return True + except Exception as e: + logging.error("barcode scanner reconnect failed: %s", e) + return False + def _run(self) -> None: + assert self._barcode_scanner + logging.info("now reading barcodes") + + try: + while not self._stop.is_set(): + barcode = self._barcode_scanner.read_barcode() if barcode is None: continue logging.debug("raw barcode received: %r", barcode) - if not scanner.is_valid(barcode): + if not self._barcode_scanner.is_valid(barcode): logging.warning("invalid barcode rejected: %r", barcode) continue logging.info("barcode scanned: %r", barcode) - curr_frame = self.ctx.nav.get_curr_frame() - - if curr_frame == CheckInManual: - self.ctx.dispatcher.call.emit( - lambda b=barcode: self.ctx.check_in.handle_by_pid(b) - ) - elif curr_frame in (CreateAccountBarcode, CreateAccountManual): - self.ctx.dispatcher.call.emit( - lambda b=barcode: self.ctx.account.go_to_review_from_barcode(b) - ) + curr_screen = context().navigation_controller.get_curr_screen() + + if curr_screen == HomeScreen: + context().check_in_controller.check_in("pid", barcode) + elif curr_screen in (CreateAccountBarcode, CreateAccountManual): + student = context().account_controller.lookup("barcode", barcode) + if student is not None: + context().session.set_student(student) + context().main_window.main_thread_dispatcher.emit( + lambda: context().navigation_controller.navigate(CreateAccountReview) + ) else: - logging.debug("barcode scanned on unhandled screen: %s", curr_frame) + logging.debug("barcode scanned on ignored screen: %s", curr_screen) except Exception as e: logging.exception("barcode scanner thread crashed: %s", e) + + self._stop.set() + + context().health_controller.get_system(CriticalSystemType.BARCODE_SCANNER).mark_unhealthy( + retry_interval=5, + retry_callback=self._reconnect, + ) diff --git a/src/controllers/check_in_controller.py b/src/controllers/check_in_controller.py index dc7f8b6..0162f20 100644 --- a/src/controllers/check_in_controller.py +++ b/src/controllers/check_in_controller.py @@ -1,59 +1,83 @@ +from __future__ import annotations + import logging +from threading import Thread +from typing import Literal -from PyQt6.QtCore import QTimer +from pyqttoast import ToastPreset -from controllers.api_controller import ApiController -from views.user_welcome import UserWelcome -from views.transition_screen import TransitionScreen +from misc.api_models import CheckInNoAccount, CheckInNoWaiver, CheckInOk, CheckInResponse, StudentResponse, check_in_response_validator +from misc.global_config import config +from misc.global_context import context +from hardware.traffic_light import TrafficLightState +from ui.views.create_account_barcode import CreateAccountBarcode +from ui.views.sign_waiver import SignWaiver +from ui.views.user_welcome import UserWelcome class CheckInController: - def __init__(self, ctx): - self.ctx = ctx - - def handle_by_uuid(self, tag): - # Called from background thread — dispatch to main thread via signal. - self.ctx.dispatcher.call.emit( - lambda: self._run_check_in(tag, ApiController.checkin_by_uuid) - ) - - def handle_by_pid(self, pid): - # Called on main thread (button click or barcode dispatcher). - self._run_check_in(pid, ApiController.checkin_by_pid) - - def _run_check_in(self, identifier, check_fn, welcome_message="Welcome back"): - result = check_fn(identifier) - status = result.get("status") - - if status == "api_error": - logging.error("API error during check-in") - self.ctx.traffic_light.request_red() - self.ctx.nav.show_status("System error, please let staff know.") - QTimer.singleShot(4000, self.ctx.nav.hide_status) - return - - if status == "no_account": - logging.info(f"no account found for {identifier}") - self.ctx.traffic_light.request_red() - if not self.ctx.has_barcode_scanner: - self.ctx.nav.get_frame(TransitionScreen).display( - "Looks like you don't have an account.\nUse the other kiosk to set one up!" - ) - QTimer.singleShot(6000, self.ctx.nav.back_to_main) + def __init__(self) -> None: + logging.info("check-in controller initialized") + + def check_in( + self, + method: Literal["rfid", "pid"], + identifier: str, + *, + welcome_message: str = "Welcome back" + ) -> None: + accounts_path = f"/accounts/{method}/{identifier}" + + def worker() -> None: + try: + get_account_response = context().api_controller.request("GET", accounts_path) + if get_account_response.status_code == 404: + result: CheckInResponse = CheckInNoAccount() + else: + get_account_response.raise_for_status() + email = StudentResponse.model_validate(get_account_response.json()).student.email + check_in_response = context().api_controller.request("POST", "/check-in", json={"email": email}) + check_in_response.raise_for_status() + result = check_in_response_validator.validate_python(check_in_response.json()) + except Exception as e: + logging.error(f"error during check-in for {identifier}: {e}") + context().traffic_light_controller.request_state_async(TrafficLightState.RED) + context().main_window.show_toast_async("System Error", "Please let staff know", ToastPreset.ERROR) return - self.ctx.nav.go_to_create_account( - on_done=lambda: self._run_check_in( - identifier, check_fn, welcome_message="Thank you for registering" + + context().main_window.main_thread_dispatcher.emit(lambda r=result: handle(r)) + + def handle(result: CheckInResponse) -> None: + if isinstance(result, CheckInNoAccount): + logging.info(f"no account found for {identifier}") + context().traffic_light_controller.request_state_async(TrafficLightState.YELLOW) + + if not config().HAS_BARCODE_SCANNER: + context().navigation_controller.navigate_via_transition( + "Looks like you don't have an account. Use the other kiosk to set one up!", + delay_ms=5000, + next_action=context().navigation_controller.reset_check_in_session, + ) + return + + context().session.check_in_method = method + context().session.check_in_identifier = identifier + context().navigation_controller.navigate(CreateAccountBarcode) + return + + if isinstance(result, CheckInNoWaiver): + logging.info(f"no waiver for {identifier}") + context().traffic_light_controller.request_state_async(TrafficLightState.YELLOW) + context().navigation_controller.navigate_via_transition( + "Looks like you haven't signed the waiver yet, let's fix that!", + delay_ms=3000, + next_action=lambda: context().navigation_controller.navigate(SignWaiver), ) - ) - return - - if status == "no_waiver": - logging.info(f"no waiver for {identifier}") - self.ctx.traffic_light.request_yellow() - self.ctx.nav.go_to_sign_waiver() - return - - logging.info(f"check-in successful: {result['name']}") - self.ctx.traffic_light.request_green() - self.ctx.nav.get_frame(UserWelcome).display_name(result["name"], welcome_message) + return + + assert isinstance(result, CheckInOk) + logging.info(f"check-in successful: {result.name}") + context().traffic_light_controller.request_state_async(TrafficLightState.GREEN) + context().navigation_controller.get_screen(UserWelcome).display_name(result.name, welcome_message) + + Thread(target=worker, daemon=True).start() diff --git a/src/controllers/health_controller.py b/src/controllers/health_controller.py new file mode 100644 index 0000000..e371008 --- /dev/null +++ b/src/controllers/health_controller.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import json +import logging +import socket +import threading +import time +from enum import Enum +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread +from typing import Callable, Any +import requests +from pyqttoast import ToastPreset + +from misc.global_config import config +from misc.global_context import context + +_HOSTNAME: str = socket.gethostname() +_lock = threading.Lock() +_last_title: str | None = None +_unresolved: bool = False + + +class _HealthHandler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args: Any) -> None: + pass + + def do_GET(self) -> None: + if self.path != "/health": + self.send_response(404) + self.end_headers() + return + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + body: dict[str, Any] = { + "status": "ok", + } + self.wfile.write(json.dumps(body).encode()) + + +def _start_health_server(port: int = 8001) -> None: + logging.info("starting health server") + srv = ThreadingHTTPServer(("0.0.0.0", port), _HealthHandler) + threading.Thread(target=srv.serve_forever, daemon=True, name="health-http").start() + logging.info("health server listening on :%d", port) + + +def _send_embed(embed: dict[str, Any], content: str | None = None, *, blocking: bool) -> None: + webhook_url: str = config().DISCORD_WEBHOOK_URL + if not webhook_url: + logging.warning("could not send embed because no embed url has been specified") + return + + payload: dict[str, Any] = { + "embeds": [embed], + "allowed_mentions": { + "parse": ["roles"] + } + } + + if content: + payload["content"] = content + + def send() -> None: + try: + requests.post(webhook_url, json=payload, timeout=5) + except Exception: + logging.exception("failed to send discord notification") + + if blocking: + send() + else: + threading.Thread(target=send, daemon=True, name="discord-notify").start() + + +class CriticalSystemType(Enum): + SYSTEM_BOOT = "System Boot" + API_CONNECTION = "Backend API Connection" + RFID_READER = "RFID Reader" + BARCODE_SCANNER = "Barcode Scanner" + + +class CriticalSystem: + # DO NOT EVER INVOKE DIRECTLY + def __init__( + self, + system_type: CriticalSystemType, + monitor_health_check_func: Callable[[], bool] | None = None, + period_seconds: int | None = None + ): + self.system_type = system_type + self._is_healthy: bool = True + self.monitor_health_check_func = monitor_health_check_func + self.period_seconds = period_seconds + self.last_run: float = 0 + + @classmethod + def with_monitoring( + cls, + system_type: CriticalSystemType, + monitor_health_check_func: Callable[[], bool], + *, + period_seconds: int + ) -> "CriticalSystem": + return cls(system_type, monitor_health_check_func, period_seconds) + + @classmethod + def without_monitoring(cls, system_type: CriticalSystemType) -> CriticalSystem: + return cls(system_type) + + def is_healthy(self) -> bool: + return self._is_healthy + + def mark_healthy(self) -> None: + old = self._is_healthy + self._is_healthy = True + if not old: + self._notify_resolved() + + def mark_unhealthy( + self, + retry_interval: float | None = None, + retry_callback: Callable[[], bool] | None = None, + ) -> None: + old = self._is_healthy + self._is_healthy = False + if old: + self._notify_critical() + if retry_interval is not None and retry_callback is not None: + def _retry_loop() -> None: + retries = 1 + while True: + time.sleep(retry_interval) + if retry_callback(): + self.mark_healthy() + return + logging.warning(f"retry failed for {self.system_type.value}") + context().main_window.show_toast_async( + f"System Error (Retry {retries}): {self.system_type.value}", + f"Trying again in {retry_interval}s", + ToastPreset.ERROR, + ) + retries += 1 + Thread(target=_retry_loop, daemon=True, name=f"health-retry-{self.system_type.value}").start() + + def _notify_critical(self, *, blocking: bool = False) -> None: + embed: dict[str, Any] = { + "title": f":x: Critical System Failure: {self.system_type.value}", + # "description": f"```\n{detail[:1800]}\n```", + "color": 0xED4245, + "footer": {"text": _HOSTNAME}, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + role_mention = f"<@&{config().DISCORD_CRITICAL_ALERT_ROLE_ID}>" + _send_embed(embed, content=role_mention, blocking=blocking) + + context().main_window.show_toast_async(f"System Error: {self.system_type.value}", + "Please mention this to a staff member", ToastPreset.ERROR) + + def _notify_resolved(self, *, blocking: bool = False) -> None: + embed: dict[str, Any] = { + "title": f":white_check_mark: Resolved: {self.system_type.value}", + "color": 0x57F287, + "footer": {"text": _HOSTNAME}, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + _send_embed(embed, blocking=blocking) + + context().main_window.show_toast_async(f"System Error Resolved: {self.system_type.value}", + "Thank you for your patience", ToastPreset.SUCCESS) + + def notify_log(self, message: str = "", *, blocking: bool = False) -> None: + embed: dict[str, Any] = { + "title": f":page_facing_up: {self.system_type.value}", + "color": 0x5865F2, + "footer": {"text": _HOSTNAME}, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + if message: + embed["description"] = message + _send_embed(embed, blocking=blocking) + + +class HealthController: + def __init__(self) -> None: + self._systems: list[CriticalSystem] = [] + self._monitor_thread: Thread | None = None + self._monitor_thread_running: bool = True + + _start_health_server(port=config().HEALTH_SERVER_PORT) + + def panic(self) -> None: + pass + + def start_monitoring(self) -> None: + if self._monitor_thread and self._monitor_thread.is_alive(): + return + self._monitor_thread = threading.Thread(target=self._run, daemon=True) + self._monitor_thread.start() + + def stop_monitoring(self) -> None: + if self._monitor_thread: + self._monitor_thread.join() + self._monitor_thread = None + + def register(self, system: CriticalSystem) -> None: + self._systems.append(system) + + def get_system(self, system_type: CriticalSystemType) -> CriticalSystem: + for system in self._systems: + if system.system_type == system_type: + return system + + system = CriticalSystem.without_monitoring(system_type) + self.register(system) + return system + + def _run(self) -> None: + while self._monitor_thread_running: + now = time.time() + for system in self._systems: + if not system.monitor_health_check_func: + continue + assert system.period_seconds is not None + + if now - system.last_run >= system.period_seconds: + try: + system.monitor_health_check_func() + except Exception: + pass + system.last_run = now + time.sleep(0.5) diff --git a/src/controllers/navigation_controller.py b/src/controllers/navigation_controller.py index e4faf3a..2e49f98 100644 --- a/src/controllers/navigation_controller.py +++ b/src/controllers/navigation_controller.py @@ -1,176 +1,85 @@ +from __future__ import annotations + +import logging import uuid +from collections.abc import Callable +from typing import TypeVar, cast + +from PyQt6.QtCore import QTimer + +from misc.global_context import context +from ui.base import Screen +from ui.views.check_in_manual import CheckInManual +from ui.views.create_account_barcode import CreateAccountBarcode +from ui.views.create_account_manual import CreateAccountManual +from ui.views.create_account_review import CreateAccountReview +from ui.views.home_screen import HomeScreen +from ui.views.qr_codes import QRCodes +from ui.views.sign_waiver import SignWaiver +from ui.views.transition_screen import TransitionScreen +from ui.views.user_welcome import UserWelcome -from PyQt6.QtCore import QTimer, Qt -from PyQt6.QtWidgets import QLabel +T = TypeVar("T", bound=Screen) -from views.check_in_rfid import CheckInRFID -from views.transition_screen import TransitionScreen -from views.create_account_barcode import CreateAccountBarcode -from views.create_account_manual import CreateAccountManual -from views.create_account_no_pid import CreateAccountNoPid -from views.create_account_review import CreateAccountReview -from views.sign_waiver import SignWaiver -from views.check_in_manual import CheckInManual -from views.qr_codes import QRCodes -from views.user_welcome import UserWelcome +_timeouts: dict[type[Screen], int] = { + SignWaiver: 30000, + QRCodes: 30000, +} class NavigationController: - def __init__(self, window, ctx, dev_mode=False): - self.ctx = ctx - self._window = window - self._stacked = window.stacked - self._frames = {} - self._curr = None - self._frame_uuid = uuid.uuid4().hex - self._on_done_stack = [] - self._dev_overlay = None - - self._timeouts = { - SignWaiver: 30000, - QRCodes: 30000, - } - - for F in ( - CheckInRFID, + def __init__(self) -> None: + self._screens: dict[type[Screen], Screen] = {} + self._curr_screen: type[Screen] | None = None + self._screen_uuid: str = uuid.uuid4().hex + + for Screen in ( + HomeScreen, TransitionScreen, CreateAccountBarcode, CreateAccountManual, - CreateAccountNoPid, CreateAccountReview, SignWaiver, CheckInManual, QRCodes, UserWelcome, ): - frame = F(self) - self._frames[F] = frame - self._stacked.addWidget(frame) - - # Status overlay — floats over the stacked widget at the bottom - self._status_label = QLabel("", window.central) - self._status_label.setGeometry(40, 628, 1200, 56) - self._status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._status_label.setStyleSheet( - "color: #F5F0E6;" - "font: bold 18pt Montserrat;" - "background-color: rgba(0, 0, 0, 170);" - "border-radius: 10px;" - "border: none;" - ) - self._status_label.hide() - self._status_label.raise_() - - if dev_mode: - from views.components.dev_overlay import DevOverlay - self._dev_overlay = DevOverlay(window, self) - - self.show_frame(CheckInRFID) - - # ------------------------------------------------------------------ - # Core frame switching - # ------------------------------------------------------------------ - - def show_frame(self, screen_class): - if self._curr is not None: - self._frames[self._curr].on_hide() - self._curr = screen_class - self._frame_uuid = uuid.uuid4().hex - self._stacked.setCurrentWidget(self._frames[screen_class]) - self._frames[screen_class].on_show() - - if self._dev_overlay is not None: - self._dev_overlay.update(screen_class) - - if screen_class in self._timeouts: - uid = self._frame_uuid + screen = Screen(self) + self._screens[Screen] = screen + context().main_window.central.addWidget(screen) + + self.navigate(HomeScreen) + + logging.info("navigation controller initialized") + + def navigate(self, screen_class: type[T]) -> None: + if self._curr_screen is not None: + self._screens[self._curr_screen].on_hide() + self._curr_screen = screen_class + screen = self.get_screen(screen_class) + self._screen_uuid = uuid.uuid4().hex + context().main_window.central.setCurrentWidget(screen) + screen.on_show() + + if screen_class in _timeouts: + uid = self._screen_uuid QTimer.singleShot( - self._timeouts[screen_class], - lambda: self._on_timeout(uid), + _timeouts[screen_class], + lambda: self.reset_check_in_session() if uid == self._screen_uuid else None, ) - def get_frame(self, screen_class): - return self._frames[screen_class] - - def get_curr_frame(self): - return self._curr - - # ------------------------------------------------------------------ - # Status overlay - # ------------------------------------------------------------------ - - def show_status(self, text): - self._status_label.setText(text) - self._status_label.show() - self._status_label.raise_() - - def hide_status(self): - self._status_label.hide() - - # ------------------------------------------------------------------ - # Stack-based flow - # ------------------------------------------------------------------ - - def push(self, screen_class, on_done=None): - self._on_done_stack.append(on_done) - self.show_frame(screen_class) - - def pop(self): - cb = self._on_done_stack.pop() if self._on_done_stack else None - if cb: - cb() - else: - self.back_to_main() - - # ------------------------------------------------------------------ - # Named navigations - # ------------------------------------------------------------------ - - def back_to_main(self): - self._on_done_stack.clear() - self.ctx.rfid = "" - self.ctx.traffic_light.request_off() - self.show_frame(CheckInRFID) - - def go_to_no_id(self): - self.get_frame(CheckInManual).clear_entries() - self.show_frame(CheckInManual) - - def go_to_create_account_manual(self): - self.get_frame(CreateAccountManual).clear_entries() - self.show_frame(CreateAccountManual) - - def go_to_create_account_no_pid(self): - self.get_frame(CreateAccountNoPid).clear_entries() - self.show_frame(CreateAccountNoPid) - - def go_to_create_account_review(self, pid="", first_name="", last_name="", email=""): - pid_locked = bool(pid) - self.get_frame(CreateAccountReview).setup( - first_name=first_name, - last_name=last_name, - email=email, - pid=pid, - pid_locked=pid_locked, - ) - self.show_frame(CreateAccountReview) - - def go_to_create_account(self, on_done): - self.get_frame(TransitionScreen).display( - "Looks like you don't have an account,\nlet's set one up!" - ) - QTimer.singleShot(3000, lambda: self.push(CreateAccountBarcode, on_done=on_done)) - - def go_to_sign_waiver(self): - self.get_frame(TransitionScreen).display( - "Looks like you haven't signed\nthe waiver yet,\nlet's fix that!" - ) - QTimer.singleShot(3000, lambda: self.show_frame(SignWaiver)) - - # ------------------------------------------------------------------ - # Internal - # ------------------------------------------------------------------ - - def _on_timeout(self, uid): - if uid == self._frame_uuid: - self.back_to_main() + def navigate_via_transition(self, message: str, *, delay_ms: int, next_action: Callable[[], None]) -> None: + self.get_screen(TransitionScreen).set_message(message) + self.navigate(TransitionScreen) + uid = self._screen_uuid + QTimer.singleShot(delay_ms, lambda: next_action() if uid == self._screen_uuid else None) + + def reset_check_in_session(self) -> None: + context().session.reset() + self.navigate(HomeScreen) + + def get_screen(self, screen_class: type[T]) -> T: + return cast(T, self._screens[screen_class]) + + def get_curr_screen(self) -> type[Screen] | None: + return self._curr_screen diff --git a/src/controllers/rfid_reader_controller.py b/src/controllers/rfid_reader_controller.py index c12fa1d..dc97536 100644 --- a/src/controllers/rfid_reader_controller.py +++ b/src/controllers/rfid_reader_controller.py @@ -1,115 +1,111 @@ -import time +from __future__ import annotations + import logging +import threading +import time import traceback -from os.path import exists -from threading import Thread, Event - -from controllers.api_controller import ApiController -from views.create_account_manual import CreateAccountManual -import notifier - - -class RfidReaderController: - def __init__(self, ctx): - self.ctx = ctx - self._stop = Event() - self._thread = None - self._reader = None - self._on_disconnect = None - self._disconnect_fired = False - - def start(self, reader, on_disconnect): - self._reader = reader - self._on_disconnect = on_disconnect - self._stop.clear() - self._disconnect_fired = False - self._thread = Thread(target=self._run_safe, args=(reader,), daemon=True, name="rfid-reader") +from threading import Thread + +from controllers.health_controller import CriticalSystemType +from hardware.rfid_reader_aitrip import RFIDReaderAITRIP +from hardware.usb_ports import USBDeviceType +from misc.global_context import context +from misc.timeout import run_with_timeout +from ui.views.home_screen import HomeScreen + + +class RFIDReaderController: + def __init__(self) -> None: + logging.info("opening RFID reader serial port") + + port = context().usb_port_controller.get_usb_device_port(USBDeviceType.RFID_READER) + self._reader = run_with_timeout(lambda: RFIDReaderAITRIP(port), "RFID Reader") + + self._stop = threading.Event() + + self._thread: Thread | None = None + self._thread = Thread(target=self._run, args=(self._reader,), daemon=True, name="rfid-reader") self._thread.start() - if self.ctx.traffic_light.connected: - poller = Thread(target=self._poll_traffic_light, daemon=True, name="traffic-light-poll") - poller.start() - def stop(self): + logging.info("rfid reader controller initialized") + + def stop(self) -> None: self._stop.set() if self._thread is not None and self._thread.is_alive(): self._thread.join(timeout=3) if self._reader is not None: self._reader.close() - def _fire_disconnect(self, reason): - if self._disconnect_fired: - return - self._disconnect_fired = True - self._stop.set() - cb = self._on_disconnect - if cb is not None: - self.ctx.dispatcher.call.emit(lambda: cb(reason)) - - def _run_safe(self, reader): + def _reconnect(self) -> bool: + logging.info("attempting RFID reader reconnect") try: - self._run(reader) - except BaseException as e: - tb = traceback.format_exc() - logging.critical("RFID reader thread died: %s\n%s", e, tb) - notifier.notify_critical( - "RFID reader thread died", - f"{type(e).__name__}: {e}\n\n{tb[-1500:]}", - ) - self._fire_disconnect(f"{type(e).__name__}: {e}") - - def _run(self, reader): + port = context().usb_port_controller.get_usb_device_port(USBDeviceType.RFID_READER) + self._reader = RFIDReaderAITRIP(port) + self._stop = threading.Event() + self._thread = Thread(target=self._run, args=(self._reader,), daemon=True, name="rfid-reader") + self._thread.start() + return True + except Exception as e: + logging.error("RFID reader reconnect failed: %s", e) + return False + + # The PN532 over UART intermittently fails to return its ACK. This is a + # transient serial glitch the reader recovers from, so tolerate a burst of + # consecutive failures before treating the reader as genuinely dead. + _MAX_CONSECUTIVE_ERRORS = 10 + + def _run(self, reader: RFIDReaderAITRIP) -> None: logging.info("now reading ID cards") - last_tag = 0 - last_time = 0 - - while not self._stop.is_set(): - try: - in_waiting = reader.get_ser_in_waiting() - except OSError as e: - if not exists(reader._usb_id): - logging.error("card reader disconnected: %s", e) - self._fire_disconnect(f"Card reader at {reader._usb_id} no longer present") - return - logging.debug("card reader transient error, retrying: %s", e) - time.sleep(0.2) - continue - - if in_waiting >= 14: - self.ctx.dispatcher.call.emit( - lambda: self.ctx.nav.get_frame(CreateAccountManual).clear_entries() - ) - tag = reader.grab_rfid() - - if " " in tag: + last_tag: str | None = None + last_time: float = 0 + consecutive_errors = 0 + + try: + while not self._stop.is_set(): + try: + tag = reader.read_rfid() + except OSError as e: + if not reader.is_present(): + raise + consecutive_errors += 1 + if consecutive_errors >= self._MAX_CONSECUTIVE_ERRORS: + raise + logging.warning( + "transient RFID read error (%d/%d): %s", + consecutive_errors, self._MAX_CONSECUTIVE_ERRORS, e, + ) + context().health_controller.get_system(CriticalSystemType.RFID_READER).notify_log( + f"transient read error ({consecutive_errors}/{self._MAX_CONSECUTIVE_ERRORS}): {e}" + ) + reader.flush() + time.sleep(0.05) + continue + + consecutive_errors = 0 + + if tag is None: continue if tag == last_tag and not reader.can_scan_again(last_time): logging.debug("suppressing repeat scan") continue - s_reason = reader.check_rfid(tag) - - if s_reason != "good": - logging.debug(s_reason) + if context().navigation_controller.get_curr_screen() != HomeScreen: + logging.debug("ignoring card tap off home screen") continue - else: - logging.debug("RFID check succeeded") - - self.ctx.rfid = tag - self.ctx.check_in.handle_by_uuid(tag) last_tag = tag last_time = time.time() - def _poll_traffic_light(self): - last_color = None - while not self._stop.is_set(): - time.sleep(0.1) - try: - color = ApiController.get_traffic_light() - except Exception as e: - logging.warning("traffic light poll error: %s", e) - continue - if color != last_color: - last_color = color - self.ctx.traffic_light.drive(color) + context().session.rfid = tag + context().check_in_controller.check_in("rfid", tag) + except Exception as exception: + tb = traceback.format_exc() + logging.critical("RFID reader thread died: %s\n%s", exception, tb) + + self._stop.set() + + context().health_controller.get_system(CriticalSystemType.RFID_READER).mark_unhealthy( + retry_interval=5, + retry_callback=self._reconnect, + ) \ No newline at end of file diff --git a/src/controllers/traffic_light_controller.py b/src/controllers/traffic_light_controller.py index dfb54ec..0ed528c 100644 --- a/src/controllers/traffic_light_controller.py +++ b/src/controllers/traffic_light_controller.py @@ -1,42 +1,49 @@ +import logging import threading +import time -from controllers.api_controller import ApiController -from hardware.traffic_light import TrafficLight +from hardware.traffic_light import TrafficLight, TrafficLightState +from hardware.usb_ports import USBDeviceType +from misc.global_config import config +from misc.global_context import context +from misc.timeout import run_with_timeout class TrafficLightController: - def __init__(self, light: TrafficLight): - self._light = light - - @property - def connected(self) -> bool: - return self._light.connected - - def drive(self, color: str) -> None: - if color == "red": - self._light.set_red() - elif color == "green": - self._light.set_green() - elif color == "yellow": - self._light.set_yellow() - else: - self._light.set_off() - - def _post(self, color: str) -> None: - threading.Thread( - target=ApiController.set_traffic_light, - args=(color,), - daemon=True, - ).start() - - def request_red(self) -> None: - self._post("red") - - def request_green(self) -> None: - self._post("green") - - def request_yellow(self) -> None: - self._post("yellow") - - def request_off(self) -> None: - self._post("off") + def __init__(self) -> None: + if config().HAS_TRAFFIC_LIGHT: + logging.info("opening traffic light serial port") + port = context().usb_port_controller.get_usb_device_port(USBDeviceType.TRAFFIC_LIGHT) + self._traffic_light = run_with_timeout(lambda: TrafficLight(port), "traffic light") + self._stop = threading.Event() + poller = threading.Thread(target=self._poll_traffic_light, daemon=True, name="traffic-light-poll") + poller.start() + self.request_state_async(TrafficLightState.OFF) + + logging.info("traffic light controller initialized") + + def stop(self) -> None: + self._stop.set() + + def request_state_async(self, state: TrafficLightState) -> None: + def worker() -> None: + try: + context().api_controller.request("POST", "/traffic-light", json={"state": state.value}) + except Exception as e: + logging.error(f"error setting traffic light: {e}") + + threading.Thread(target=worker, daemon=True).start() + + def _poll_traffic_light(self) -> None: + last_state: TrafficLightState | None = None + while not self._stop.is_set(): + time.sleep(0.2) + try: + response = context().api_controller.request("GET", "/traffic-light") + traffic_light_state = TrafficLightState(response.json().get("state")) + except Exception as e: + logging.warning("traffic light poll error: %s", e) + continue + if traffic_light_state != last_state: + last_state = traffic_light_state + self._traffic_light.set_state(traffic_light_state) diff --git a/src/dispatcher.py b/src/dispatcher.py deleted file mode 100644 index bb55188..0000000 --- a/src/dispatcher.py +++ /dev/null @@ -1,9 +0,0 @@ -from PyQt6.QtCore import QObject, pyqtSignal - - -class MainThreadDispatcher(QObject): - call = pyqtSignal(object) - - def __init__(self): - super().__init__() - self.call.connect(lambda fn: fn()) diff --git a/src/hardware/barcode_scanner.py b/src/hardware/barcode_scanner_netum_nt_em61.py similarity index 61% rename from src/hardware/barcode_scanner.py rename to src/hardware/barcode_scanner_netum_nt_em61.py index 7a25755..7ed5574 100644 --- a/src/hardware/barcode_scanner.py +++ b/src/hardware/barcode_scanner_netum_nt_em61.py @@ -3,18 +3,20 @@ from os.path import exists -class BarcodeScanner: - def __init__(self, usb_id): +"""No idea how long this link will work but I believe this is the barcode serial reader we are using currently +https://www.amazon.com/NETUM-NT-EM61-Embedded-Barcode-Scanner/dp/B0FVM3BXGD""" +class BarcodeScannerNetumNTEM61: + def __init__(self, usb_id: str) -> None: self._usb_id = usb_id - self._ser = None + self._ser: serial.Serial | None = None self._connect() - def _connect(self): + def _connect(self) -> None: self._ser = serial.Serial(self._usb_id, baudrate=9600, timeout=0.1) self._ser.reset_input_buffer() logging.info("barcode scanner connected at %s", self._usb_id) - def reconnect(self): + def reconnect(self) -> bool: if not exists(self._usb_id): return False try: @@ -24,15 +26,15 @@ def reconnect(self): self._ser = None return False - def read_barcode(self): - line = self._ser.read_until(b"\r") + def read_barcode(self) -> str | None: + line = self._ser.read_until(b"\r") # type: ignore[union-attr] if not line: return None barcode = line.decode("ascii", errors="ignore").strip() barcode = barcode.strip("ABCDabcd") # TODO: not sure if there is a better way to handle this return barcode if barcode else None - def is_valid(self, barcode): + def is_valid(self, barcode: str | None) -> bool: if not barcode: return False if len(barcode) > 32: diff --git a/src/hardware/rfid_reader.py b/src/hardware/rfid_reader_aitrip.py similarity index 58% rename from src/hardware/rfid_reader.py rename to src/hardware/rfid_reader_aitrip.py index 7af95cc..bb1c69c 100644 --- a/src/hardware/rfid_reader.py +++ b/src/hardware/rfid_reader_aitrip.py @@ -3,18 +3,18 @@ import logging import serial import time +from typing import Any from adafruit_pn532.uart import PN532_UART -expected_characters = 14 - -class Reader(Thread): - def __init__(self, usb_id): +"""No idea how long this link will work but I believe this is the RFID serial reader we are using currently +https://www.amazon.com/AITRIP-Wireless-Attenna-Interface-Raspberry/dp/B0DWSMVKT1""" +class RFIDReaderAITRIP(Thread): + def __init__(self, usb_id: str) -> None: super().__init__() self._usb_id = usb_id - self._pn532 = None - self._pending_tag = None + self._pn532: Any | None = None if not usb_id or not exists(usb_id): raise RuntimeError(f"Card reader not found at {usb_id!r}") try: @@ -23,7 +23,7 @@ def __init__(self, usb_id): except Exception as e: raise RuntimeError(f"Card reader failed to initialize: {e}") from e - def _init_pn532(self): + def _init_pn532(self) -> None: if self._pn532 is not None: try: self._pn532._uart.close() @@ -40,7 +40,7 @@ def _init_pn532(self): uart.close() raise - def reconnect(self): + def reconnect(self) -> bool: if not exists(self._usb_id): return False try: @@ -51,34 +51,34 @@ def reconnect(self): self._pn532 = None return False - def get_ser_in_waiting(self): + def read_rfid(self) -> str | None: try: - uid = self._pn532.read_passive_target(timeout=0.1) + uid = self._pn532.read_passive_target(timeout=0.1) # type: ignore[union-attr] except Exception as e: raise OSError(f"PN532 error: {e}") - if uid: - self._pending_tag = "".join(f"{b:02X}" for b in uid) - time.sleep(0.01) - self._pn532._uart.reset_input_buffer() - return expected_characters - self._pending_tag = None - return 0 + if not uid: + return None + time.sleep(0.01) + self._pn532._uart.reset_input_buffer() # type: ignore[union-attr] + tag = "".join(f"{b:02X}" for b in uid) + logging.info("parsed tag: " + tag) + return tag - def grab_rfid(self): - tag = self._pending_tag - self._pending_tag = None - logging.info("parsed tag: " + str(tag)) - return str(tag) + def is_present(self) -> bool: + return exists(self._usb_id) - def check_rfid(self, tag): - if not tag or len(tag) != expected_characters: - return "Tag was not the expected number of chars" - return "good" + def flush(self) -> None: + if self._pn532 is not None: + try: + self._pn532._uart.reset_input_buffer() + self._pn532._uart.reset_output_buffer() + except Exception as e: + logging.warning("failed to flush card reader serial port: %s", e) - def can_scan_again(self, last_time): + def can_scan_again(self, last_time: float) -> bool: return time.time() - last_time > 3 - def close(self): + def close(self) -> None: if self._pn532 is not None: try: self._pn532._uart.close() diff --git a/src/hardware/traffic_light.py b/src/hardware/traffic_light.py index e98e292..a548fc7 100644 --- a/src/hardware/traffic_light.py +++ b/src/hardware/traffic_light.py @@ -1,32 +1,24 @@ +from enum import Enum + import serial -import serial.tools.list_ports as list_ports -import time + + +class TrafficLightState(Enum): + OFF = "off" + RED = "red" + YELLOW = "yellow" + GREEN = "green" class TrafficLight: - def __init__(self, addr=None, baud=115200): - self.ser = None + def __init__(self, addr: str | None = None, baud: int = 115200) -> None: + self.ser: serial.Serial | None = None if addr: self.ser = serial.Serial(addr, baud) self.ser.reset_input_buffer() - @property - def connected(self) -> bool: - return self.ser is not None - - def set_off(self): - if self.ser: - self.ser.write(b"off\n") - - def set_red(self): - if self.ser: - self.ser.write(b"red\n") - - def set_yellow(self): - if self.ser: - self.ser.write(b"yellow\n") - - def set_green(self): + def set_state(self, state: TrafficLightState) -> None: if self.ser: - self.ser.write(b"green\n") + data_string = state.value + "\n" + self.ser.write(data_string.encode()) diff --git a/src/hardware/usb_ports.py b/src/hardware/usb_ports.py index 9ffafac..ffa257b 100644 --- a/src/hardware/usb_ports.py +++ b/src/hardware/usb_ports.py @@ -1,32 +1,51 @@ import logging from dataclasses import dataclass +from enum import Enum import serial.tools.list_ports +from misc.global_config import config + READER_AND_TRAFFIC_LIGHT_VID = 0x1A86 TRAFFIC_LIGHT_LOCATION = "1-1.1.2" -BARCODE_VID = 0x9901 +BARCODE_SCANNER_VID = 0x9901 @dataclass -class UsbIds: +class USBDevice: reader: str | None traffic_light: str | None barcode: str | None -def get_usb_ids() -> UsbIds: - reader = None - traffic_light = None - barcode = None - for p in serial.tools.list_ports.comports(): - logging.debug("USB port: %s vid=%s desc=%s", p.device, hex(p.vid) if p.vid else None, p.description) - if p.vid == READER_AND_TRAFFIC_LIGHT_VID: - if p.location == TRAFFIC_LIGHT_LOCATION: - traffic_light = p.device - else: - reader = p.device - elif p.vid == BARCODE_VID: - barcode = p.device - logging.info("USB detected — reader: %s, traffic_light: %s, barcode: %s", reader, traffic_light, barcode) - return UsbIds(reader, traffic_light, barcode) +class USBDeviceType(Enum): + RFID_READER = "rfid_reader" + TRAFFIC_LIGHT = "traffic_light" + BARCODE_SCANNER = "barcode_scanner" + + +class USBPortController: + def __init__(self) -> None: + self.get_usb_device_port(USBDeviceType.RFID_READER) + if config().HAS_TRAFFIC_LIGHT: + self.get_usb_device_port(USBDeviceType.TRAFFIC_LIGHT) + if config().HAS_BARCODE_SCANNER: + self.get_usb_device_port(USBDeviceType.BARCODE_SCANNER) + logging.info("usb port controller initialized") + + @classmethod + def get_usb_device_port(cls, device: USBDeviceType) -> str: + for port in serial.tools.list_ports.comports(): + vendor_id = port.vid + + match device: + case USBDeviceType.RFID_READER: + if vendor_id == READER_AND_TRAFFIC_LIGHT_VID and port.location != TRAFFIC_LIGHT_LOCATION: + return port.device + case USBDeviceType.TRAFFIC_LIGHT: + if vendor_id == READER_AND_TRAFFIC_LIGHT_VID and port.location == TRAFFIC_LIGHT_LOCATION: + return port.device + case USBDeviceType.BARCODE_SCANNER: + if vendor_id == BARCODE_SCANNER_VID: + return port.device + raise RuntimeError(f"Could not find usb for device {device}") \ No newline at end of file diff --git a/src/health_server.py b/src/health_server.py deleted file mode 100644 index e57177b..0000000 --- a/src/health_server.py +++ /dev/null @@ -1,94 +0,0 @@ -import json -import logging -import os -import threading -import time -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - -import notifier - - -class _State: - started_at = time.monotonic() - ui_ready = False - last_heartbeat = 0.0 - - -_state = _State() - - -def mark_ui_ready() -> None: - _state.ui_ready = True - heartbeat() - - -def heartbeat() -> None: - _state.last_heartbeat = time.monotonic() - - -def _status(stall_threshold_s: float): - now = time.monotonic() - if not _state.ui_ready: - return False, "ui_not_ready", now - _state.started_at - age = now - _state.last_heartbeat - if age > stall_threshold_s: - return False, "event_loop_stalled", age - return True, "ok", age - - -class _Handler(BaseHTTPRequestHandler): - stall_threshold_s = 5.0 - - def log_message(self, *_a, **_kw): - pass - - def do_GET(self): - if self.path != "/health": - self.send_response(404) - self.end_headers() - return - ok, reason, age = _status(self.stall_threshold_s) - self.send_response(200 if ok else 503) - self.send_header("Content-Type", "application/json") - self.end_headers() - body = { - "status": reason, - "ui_ready": _state.ui_ready, - "heartbeat_age_s": round(age, 2), - "uptime_s": round(time.monotonic() - _state.started_at, 1), - } - self.wfile.write(json.dumps(body).encode()) - - -def start(port: int = 8001) -> None: - srv = ThreadingHTTPServer(("0.0.0.0", port), _Handler) - threading.Thread(target=srv.serve_forever, daemon=True, name="health-http").start() - logging.info("health endpoint listening on :%d", port) - - -def start_watchdog(startup_grace_s: float = 60.0, stall_threshold_s: float = 15.0) -> None: - def loop(): - while True: - time.sleep(2) - now = time.monotonic() - if not _state.ui_ready: - if now - _state.started_at > startup_grace_s: - logging.critical("UI not ready after %.0fs, exiting", startup_grace_s) - notifier.notify_critical( - "Kiosk UI failed to start", - f"UI not ready after {startup_grace_s:.0f}s; process exiting for restart.", - blocking=True, - ) - os._exit(2) - continue - age = now - _state.last_heartbeat - if age > stall_threshold_s: - logging.critical("Qt event loop stalled for %.1fs, exiting", age) - notifier.notify_critical( - "Kiosk event loop stalled", - f"Qt event loop stalled for {age:.1f}s; process exiting for restart.", - blocking=True, - ) - os._exit(3) - - threading.Thread(target=loop, daemon=True, name="health-watchdog").start() diff --git a/src/main.py b/src/main.py index 71abc40..4568947 100644 --- a/src/main.py +++ b/src/main.py @@ -1,185 +1,89 @@ -import sys +from __future__ import annotations + import logging -import argparse -import os -from sys import stdout +import signal +import sys from PyQt6.QtWidgets import QApplication -from PyQt6.QtCore import QTimer -import health_server -from window import CheckInWindow -from dispatcher import MainThreadDispatcher -from controllers.navigation_controller import NavigationController +from controllers.account_controller import AccountController +from controllers.api_controller import APIController from controllers.barcode_scanner_controller import BarcodeScannerController -from hardware.barcode_scanner import BarcodeScanner from controllers.check_in_controller import CheckInController -from controllers.account_controller import AccountController -from controllers.rfid_reader_controller import RfidReaderController -from hardware.rfid_reader import Reader -from views.create_account_manual import CreateAccountManual -from views.create_account_no_pid import CreateAccountNoPid -from views.create_account_review import CreateAccountReview -from views.check_in_manual import CheckInManual -from hardware.usb_ports import get_usb_ids -from app_context import AppContext -from controllers.api_controller import ApiController, ApiUnreachableError +from controllers.health_controller import HealthController, CriticalSystem, CriticalSystemType +from controllers.navigation_controller import NavigationController +from controllers.rfid_reader_controller import RFIDReaderController +from controllers.traffic_light_controller import TrafficLightController +from hardware.usb_ports import USBPortController +from misc.global_config import config +from misc.global_context import GlobalContext, context +from misc.check_in_session import CheckInSession +from window import MainWindow + + +def prepare_boot() -> None: + if config().VERBOSE_LOGGING: + logging.basicConfig(level=logging.DEBUG, stream=sys.stdout) + else: + logging.basicConfig(level=logging.INFO) -API_RETRY_DELAY_S = 10 -API_MONITOR_INTERVAL_S = 15 -HARDWARE_RETRY_DELAY_S = 5 + signal.signal(signal.SIGINT, signal.SIG_DFL) -def clear_and_return(ctx: AppContext): - ctx.nav.back_to_main() - ctx.nav.get_frame(CreateAccountManual).clear_entries() - ctx.nav.get_frame(CreateAccountNoPid).clear_entries() - ctx.nav.get_frame(CreateAccountReview).clear_entries() - ctx.nav.get_frame(CheckInManual).clear_entries() +def initiate_boot() -> None: + global_context = GlobalContext() + global_context.health_controller = HealthController() + global_context.main_window = MainWindow() -if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Makerspace Check-in System", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) - parser.add_argument("-v", "--verbose", action="store_true", help="Increase verbosity (print debug info)") - parser.add_argument("-d", "--dev", action="store_true", help="Enable dev mode with on-screen navigation overlay") - args = parser.parse_args() - - if args.verbose: - logging.basicConfig(level=logging.DEBUG, stream=stdout) - else: - logging.basicConfig(level=logging.INFO) + global_context.api_controller = APIController() - dev_mode = args.dev or os.environ.get("DEV_MODE") == "1" + global_context.navigation_controller = NavigationController() + global_context.check_in_controller = CheckInController() + global_context.account_controller = AccountController() - health_server.start(port=int(os.environ.get("HEALTH_PORT", "8001"))) - health_server.start_watchdog() + global_context.usb_port_controller = USBPortController() + global_context.rfid_reader_controller = RFIDReaderController() + global_context.barcode_scanner_controller = BarcodeScannerController() + global_context.traffic_light_controller = TrafficLightController() - app = QApplication(sys.argv) + global_context.session = CheckInSession() - import signal - signal.signal(signal.SIGINT, signal.SIG_DFL) + global_context.health_controller.register(CriticalSystem.with_monitoring( + CriticalSystemType.API_CONNECTION, + global_context.api_controller.ping, + period_seconds=5 + )) + +def shutdown() -> None: + context().rfid_reader_controller.stop() + +if __name__ == "__main__": + app: QApplication | None = None + try: + prepare_boot() + app = QApplication([]) + assert app + initiate_boot() + context().main_window.on_finish_boot() + app.aboutToQuit.connect(shutdown) + sys.exit(app.exec()) + except Exception as exception: + if app is None or context() is None or context().health_controller is None: + # if this has occurred the system has failed so completely it will be unable to alert that it has failed + logging.error(f"rip: {exception}") + + elif context().main_window is None: + # if this occurred the system failed before the UI loaded + context().health_controller.get_system(CriticalSystemType.SYSTEM_BOOT).mark_unhealthy() + logging.error(f"UI Failed to load: {exception}") - window = CheckInWindow() - window.showFullScreen() - - heartbeat_timer = QTimer() - heartbeat_timer.setInterval(1000) - heartbeat_timer.timeout.connect(health_server.heartbeat) - heartbeat_timer.start() - QTimer.singleShot(0, health_server.mark_ui_ready) - - state = { - "app_initialized": False, - "reader_attached": False, - "monitor_started": False, - "ctx": None, - } - - def monitor_api(): - try: - ApiController.ping() - except ApiUnreachableError as e: - window.show_error( - "Lost connection to API", - str(e), - retry_in=API_RETRY_DELAY_S, - on_retry=monitor_api, - ) - return - if window.is_error_visible(): - window.hide_error() - QTimer.singleShot(API_MONITOR_INTERVAL_S * 1000, monitor_api) - - def build_app_context(): - ctx = AppContext.create(get_usb_ids().traffic_light) - ctx.dispatcher = MainThreadDispatcher() - nav = NavigationController(window, ctx, dev_mode=dev_mode) - ctx.window = window - ctx.nav = nav - ctx.check_in = CheckInController(ctx) - ctx.account = AccountController(ctx) - ctx.traffic_light.request_off() - window.set_escape_handler(lambda: clear_and_return(ctx)) - return ctx - - def on_reader_disconnect(reason): - logging.warning("RFID reader disconnected: %s", reason) - state["reader_attached"] = False - window.show_error( - "Card reader not detected", - reason, - retry_in=HARDWARE_RETRY_DELAY_S, - on_retry=startup, - ) - - def attach_reader(ctx): - usb = get_usb_ids() - reader = Reader(usb.reader) - card_reader = RfidReaderController(ctx) - card_reader.start(reader, on_disconnect=on_reader_disconnect) - ctx.card_reader = card_reader - - if usb.barcode: - ctx.has_barcode_scanner = True - barcode_scanner = BarcodeScanner(usb.barcode) - barcode_controller = BarcodeScannerController(ctx) - barcode_controller.start(barcode_scanner) else: - logging.warning("no barcode scanner found, barcode scanning disabled") - - def shutdown(): - ctx = state.get("ctx") - if ctx is not None and getattr(ctx, "card_reader", None) is not None: - ctx.card_reader.stop() - - app.aboutToQuit.connect(shutdown) - - def startup(): - try: - ApiController.ping() - except ApiUnreachableError as e: - window.show_error( - "Cannot reach API", - str(e), - retry_in=API_RETRY_DELAY_S, - on_retry=startup, - ) - return - - if not state["app_initialized"]: - try: - ctx = build_app_context() - except BaseException as e: - logging.critical("fatal error during kiosk app init", exc_info=True) - window.show_error( - "Kiosk failed to initialize", - f"{type(e).__name__}: {e}", - ) - return - state["ctx"] = ctx - state["app_initialized"] = True - - if not state["reader_attached"]: - try: - attach_reader(state["ctx"]) - except RuntimeError as e: - window.show_error( - "Card reader not detected", - str(e), - retry_in=HARDWARE_RETRY_DELAY_S, - on_retry=startup, - ) - return - state["reader_attached"] = True - - window.hide_error() - logging.info("made it to app start") - if not state["monitor_started"]: - state["monitor_started"] = True - QTimer.singleShot(API_MONITOR_INTERVAL_S * 1000, monitor_api) - - QTimer.singleShot(0, startup) - sys.exit(app.exec()) + # if this occurred something else failed to initialize, and we should retry after a while + context().main_window.show_error("System Boot Failure", str(exception), retry_in=60, on_retry=lambda: sys.exit(1)) + context().health_controller.get_system(CriticalSystemType.SYSTEM_BOOT).mark_unhealthy() + logging.error(f"System Boot Failure: {exception}") + app.exec() # need to start the pyqt ui loop otherwise nothing will render + + sys.exit(1) + diff --git a/src/misc/api_models.py b/src/misc/api_models.py new file mode 100644 index 0000000..1bc9c89 --- /dev/null +++ b/src/misc/api_models.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, TypeAdapter, model_validator + +""" +IT SHOULD BE ENSURED ANY UPDATES TO THIS FILE ARE ALSO MADE AT THE CORRESPONDING LOCATIONS: + - Check-In/src/misc/api_models.py + - check-in-api/src/api_models.py +""" + +class HealthResponse(BaseModel): + status: str + timestamp: str + + +class TrafficLightState(str, Enum): + OFF = "off" + RED = "red" + YELLOW = "yellow" + GREEN = "green" + + +class TrafficLightRequest(BaseModel): + state: TrafficLightState + + +class TrafficLightResponse(BaseModel): + state: TrafficLightState + + +class Student(BaseModel): + first_name: str + last_name: str + email: str + pid: str + + +class StudentResponse(BaseModel): + student: Student + + +class AccountRequest(BaseModel): + rfid: str + barcode: str | None = None + pid: str | None = None + first_name: str | None = None + last_name: str | None = None + email: str | None = None + + @model_validator(mode="after") + def check_inputs(self) -> AccountRequest: + has_lookup = self.barcode or self.pid + has_manual = self.first_name and self.last_name and self.email + if not has_lookup and not has_manual: + raise ValueError("Either barcode, pid, or first/last/email must be provided") + return self + + +class CreateAccountResponse(BaseModel): + status: str + + +class CheckInRequest(BaseModel): + email: str + + +class CheckInStatus(str, Enum): + OK = "ok" + NO_ACCOUNT = "no_account" + NO_WAIVER = "no_waiver" + + +class CheckInNoAccount(BaseModel): + status: CheckInStatus = CheckInStatus.NO_ACCOUNT + + +class CheckInNoWaiver(BaseModel): + status: CheckInStatus = CheckInStatus.NO_WAIVER + name: str + + +class CheckInOk(BaseModel): + status: CheckInStatus = CheckInStatus.OK + name: str + student_id: str + timestamp: str + email: str + first_enr_term: str + last_enr_term: str + + +CheckInResponse = CheckInOk | CheckInNoWaiver | CheckInNoAccount +check_in_response_validator: TypeAdapter[CheckInResponse] = TypeAdapter(CheckInResponse) diff --git a/src/misc/asset.py b/src/misc/asset.py new file mode 100644 index 0000000..5377e54 --- /dev/null +++ b/src/misc/asset.py @@ -0,0 +1,14 @@ +from enum import Enum +from pathlib import Path + +ASSETS_PATH = Path(__file__).parent.parent / "assets" + + +class Asset(Enum): + BACKGROUND = "background_main.png" + QR_WAIVER = "qr_waiver.png" + QR_WEBSITE = "qr_website.png" + FONTS_DIR = "fonts/" + + def get_path(self) -> str: + return str(ASSETS_PATH / self.value) \ No newline at end of file diff --git a/src/misc/check_in_session.py b/src/misc/check_in_session.py new file mode 100644 index 0000000..5249223 --- /dev/null +++ b/src/misc/check_in_session.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import logging +from typing import Literal + +from hardware.traffic_light import TrafficLightState +from misc.api_models import Student +from misc.global_context import context + + +class CheckInSession: + def __init__(self) -> None: + self.rfid: str = "" + self.check_in_method: Literal["rfid", "pid"] = "rfid" + self.check_in_identifier: str = "" + self.pid: str = "" + self.first_name: str = "" + self.last_name: str = "" + self.email: str = "" + + logging.info("session initialized") + + def set_student(self, student: Student) -> None: + self.pid = student.pid + self.first_name = student.first_name + self.last_name = student.last_name + self.email = student.email + + def reset(self) -> None: + self.rfid = "" + self.check_in_method = "rfid" + self.check_in_identifier = "" + self.pid = "" + self.first_name = "" + self.last_name = "" + self.email = "" + context().traffic_light_controller.request_state_async(TrafficLightState.OFF) diff --git a/src/misc/global_config.py b/src/misc/global_config.py new file mode 100644 index 0000000..c8a64a2 --- /dev/null +++ b/src/misc/global_config.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass + +_config: GlobalConfig | None = None + + +def config() -> GlobalConfig: + global _config + if not _config: + _config = GlobalConfig() + return _config + + +def _from_env(key: str, required: bool) -> str: + value = os.environ.get(key) + if value is None: + if required: + raise RuntimeError(f"Missing environment variable: {key}") + else: + return "" + + return value + + +@dataclass +class GlobalConfig: + # hardcoded config values + API_RETRY_DELAY_SECONDS: int = 10 + API_MONITOR_INTERVAL_SECONDS: int = 15 + HARDWARE_RETRY_DELAY_SECONDS: int = 5 + HEALTH_SERVER_PORT: int = 8001 + DISCORD_CRITICAL_ALERT_ROLE_ID = "1509027158209859695" + SCREEN_WIDTH: int = 1280 + SCREEN_HEIGHT: int = 720 + + # values pulled from env + KIOSK_NAME: str = _from_env("KIOSK_NAME", required=True) + HAS_BARCODE_SCANNER: bool = _from_env("HAS_BARCODE_SCANNER", required=True).lower() == "true" + HAS_TRAFFIC_LIGHT: bool = _from_env("HAS_TRAFFIC_LIGHT", required=True).lower() == "true" + CHECK_IN_API_URL: str = _from_env("CHECK_IN_API_URL", required=True) + DISCORD_WEBHOOK_URL: str = _from_env("DISCORD_WEBHOOK_URL", required=False) + DEV_MODE: bool = _from_env("DEV_MODE", required=False).lower() == "true" + VERBOSE_LOGGING: bool = _from_env("VERBOSE_LOGGING", required=False).lower() == "true" \ No newline at end of file diff --git a/src/misc/global_context.py b/src/misc/global_context.py new file mode 100644 index 0000000..fba5df7 --- /dev/null +++ b/src/misc/global_context.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from controllers.navigation_controller import NavigationController + from controllers.check_in_controller import CheckInController + from controllers.account_controller import AccountController + from controllers.rfid_reader_controller import RFIDReaderController + from controllers.traffic_light_controller import TrafficLightController + from controllers.barcode_scanner_controller import BarcodeScannerController + from controllers.health_controller import HealthController + from hardware.usb_ports import USBPortController + from window import MainWindow + from misc.check_in_session import CheckInSession + from controllers.api_controller import APIController + + +_context: GlobalContext + +def context() -> GlobalContext: + return _context + + +class GlobalContext: + health_controller: HealthController + navigation_controller: NavigationController + check_in_controller: CheckInController + account_controller: AccountController + rfid_reader_controller: RFIDReaderController + barcode_scanner_controller: BarcodeScannerController + traffic_light_controller: TrafficLightController + usb_port_controller: USBPortController + main_window: MainWindow + session: CheckInSession + api_controller: APIController + + def __init__(self) -> None: + global _context + _context = self \ No newline at end of file diff --git a/src/misc/timeout.py b/src/misc/timeout.py new file mode 100644 index 0000000..bc92aae --- /dev/null +++ b/src/misc/timeout.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import threading +from typing import Callable, TypeVar + +T = TypeVar("T") + + +def run_with_timeout(func: Callable[[], T], name: str, timeout: float = 2.0) -> T: + result: T | None = None + error: BaseException | None = None + + def run() -> None: + nonlocal result, error + try: + result = func() + except BaseException as exception: + error = exception + + worker = threading.Thread(target=run, daemon=True, name=f"timeout-{name}") + worker.start() + worker.join(timeout) + + if worker.is_alive(): + raise TimeoutError(f"{name} did not complete within {timeout:.0f}s") + if error is not None: + raise error + return result # type: ignore[return-value] diff --git a/src/notifier.py b/src/notifier.py deleted file mode 100644 index 93ae4f3..0000000 --- a/src/notifier.py +++ /dev/null @@ -1,56 +0,0 @@ -import logging -import os -import socket -import threading - -import requests - -DISCORD_PING_ROLE_ID = "1509027158209859695" - -_WEBHOOK = os.environ.get("DISCORD_WEBHOOK_URL") -_HOSTNAME = socket.gethostname() -_lock = threading.Lock() -_last_title = None -_unresolved = False - - -def _post(content, blocking, timeout=5): - if not _WEBHOOK: - return - payload = {"content": content, "allowed_mentions": {"parse": ["roles"]}} - - def send(): - try: - requests.post(_WEBHOOK, json=payload, timeout=timeout) - except Exception: - logging.exception("failed to send Discord notification") - - if blocking: - send() - else: - threading.Thread(target=send, daemon=True, name="discord-notify").start() - - -def notify_critical(title, detail, *, blocking=False): - global _last_title, _unresolved - with _lock: - if title == _last_title: - return - _last_title = title - _unresolved = True - content = ( - f"<@&{DISCORD_PING_ROLE_ID}> ❌ **{title}** — `{_HOSTNAME}`\n" - f"```\n{detail[:1800]}\n```" - ) - _post(content, blocking=blocking) - - -def notify_resolved(): - global _last_title, _unresolved - with _lock: - if not _unresolved: - return - _unresolved = False - title = _last_title - _last_title = None - _post(f"✅ Resolved: **{title}** — `{_HOSTNAME}`", blocking=False) diff --git a/src/views/__init__.py b/src/ui/__init__.py similarity index 100% rename from src/views/__init__.py rename to src/ui/__init__.py diff --git a/src/ui/base.py b/src/ui/base.py new file mode 100644 index 0000000..17d07c0 --- /dev/null +++ b/src/ui/base.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout + +from .components.outline_frame import OutlineFrame +from .components.styled_button import home_button +from ui.theme import OUTER_MARGIN, INNER_MARGIN + +if TYPE_CHECKING: + from controllers.navigation_controller import NavigationController + + +class Screen(QWidget): + def __init__(self, controller: NavigationController) -> None: + super().__init__() + self.controller = controller + + outer = QVBoxLayout(self) + outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) + outer.setSpacing(0) + + outline = OutlineFrame() + outer.addWidget(outline) + + self.content = QVBoxLayout(outline) + self.content.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) + self.content.setSpacing(0) + + self._build(controller) + + def add_home_row(self) -> None: + row = QHBoxLayout() + row.addWidget(home_button(lambda: self.controller.reset_check_in_session())) + row.addStretch() + self.content.addLayout(row) + + def _build(self, controller: NavigationController) -> None: + pass + + def on_show(self) -> None: + pass + + def on_hide(self) -> None: + pass diff --git a/src/views/components/__init__.py b/src/ui/components/__init__.py similarity index 100% rename from src/views/components/__init__.py rename to src/ui/components/__init__.py diff --git a/src/ui/components/label.py b/src/ui/components/label.py new file mode 100644 index 0000000..5d266f4 --- /dev/null +++ b/src/ui/components/label.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QLabel + +from ui.theme import CREAM, app_font + + +def styled_label( + text: str = "", + *, + font_size: int, + bold: bool = False, + align: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignHCenter, + width: int = 1000, +) -> QLabel: + label = QLabel(text) + + label.setFont(app_font(font_size, bold=bold)) + + label.setStyleSheet(f"color: {CREAM}") + label.setAlignment(align) + + label.setWordWrap(True) + label.setFixedWidth(width) + label.setMinimumHeight(label.heightForWidth(width)) + + return label + + +def title_label(text: str) -> QLabel: + return styled_label(text, font_size=80, bold=True) + +def field_label(text: str) -> QLabel: + return styled_label(text, font_size=18) diff --git a/src/views/components/outline_frame.py b/src/ui/components/outline_frame.py similarity index 73% rename from src/views/components/outline_frame.py rename to src/ui/components/outline_frame.py index 53b2eb9..72bf6f0 100644 --- a/src/views/components/outline_frame.py +++ b/src/ui/components/outline_frame.py @@ -1,16 +1,15 @@ -from PyQt6.QtWidgets import QFrame -from PyQt6.QtGui import QPainter, QPen, QColor, QPainterPath +from PyQt6.QtWidgets import QFrame, QWidget +from PyQt6.QtGui import QPainter, QPen, QColor, QPainterPath, QPaintEvent from PyQt6.QtCore import Qt, QRectF class OutlineFrame(QFrame): - def __init__(self, parent=None, radius=20): + def __init__(self, parent: QWidget | None = None, radius: int = 20) -> None: super().__init__(parent) self._radius = radius - self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - def paintEvent(self, event): + def paintEvent(self, event: QPaintEvent | None) -> None: painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) diff --git a/src/views/components/styled_button.py b/src/ui/components/styled_button.py similarity index 74% rename from src/views/components/styled_button.py rename to src/ui/components/styled_button.py index 9a82367..8365df4 100644 --- a/src/views/components/styled_button.py +++ b/src/ui/components/styled_button.py @@ -1,16 +1,27 @@ -from PyQt6.QtWidgets import QPushButton -from PyQt6.QtGui import QPainter, QPainterPath, QColor, QPen, QFont -from PyQt6.QtCore import Qt, QRectF, QRect +from __future__ import annotations + +from collections.abc import Callable + +from PyQt6.QtWidgets import QPushButton, QWidget +from PyQt6.QtGui import QPainter, QPainterPath, QColor, QPen, QPaintEvent, QEnterEvent +from PyQt6.QtCore import Qt, QRectF, QRect, QEvent import qtawesome as qta -from .theme import OUTER_MARGIN, INNER_MARGIN, NAV_BTN_SIZE, NAV_ICON_SIZE +from ui.theme import OUTER_MARGIN, INNER_MARGIN, NAV_BTN_SIZE, NAV_ICON_SIZE, app_font __all__ = ["StyledButton", "home_button", "OUTER_MARGIN", "INNER_MARGIN", "NAV_BTN_SIZE", "NAV_ICON_SIZE"] class StyledButton(QPushButton): - def __init__(self, text="", parent=None, font_size=30, ghost=False, radius=20): + def __init__( + self, + text: str = "", + parent: QWidget | None = None, + font_size: int = 30, + ghost: bool = False, + radius: int = 20, + ) -> None: super().__init__(text, parent) self._font_size = font_size self._ghost = ghost @@ -21,17 +32,17 @@ def __init__(self, text="", parent=None, font_size=30, ghost=False, radius=20): self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setStyleSheet("background: transparent; border: none;") - def enterEvent(self, event): + def enterEvent(self, event: QEnterEvent | None) -> None: self._hovered = True self.update() super().enterEvent(event) - def leaveEvent(self, event): + def leaveEvent(self, event: QEvent | None) -> None: self._hovered = False self.update() super().leaveEvent(event) - def paintEvent(self, event): + def paintEvent(self, event: QPaintEvent | None) -> None: painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) @@ -59,7 +70,7 @@ def paintEvent(self, event): sz.width(), sz.height(), )) else: - painter.setFont(QFont("Montserrat", self._font_size)) + painter.setFont(app_font(self._font_size)) painter.setPen(QColor("#F5F0E6")) painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, self.text()) else: @@ -70,12 +81,12 @@ def paintEvent(self, event): path.addRoundedRect(rect, self._radius, self._radius) painter.fillPath(path, QColor("#E8E4DA") if self._hovered and self.isEnabled() else QColor("#F5F0E6")) - painter.setFont(QFont("Montserrat", self._font_size)) + painter.setFont(app_font(self._font_size)) painter.setPen(QColor("#4EBEEE")) painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, self.text()) -def home_button(on_click): +def home_button(on_click: Callable[[], None]) -> StyledButton: btn = StyledButton(ghost=True) btn.setIcon(qta.icon('fa5s.home', color='#F5F0E6')) btn.setIconSize(NAV_ICON_SIZE) diff --git a/src/ui/components/styled_entry.py b/src/ui/components/styled_entry.py new file mode 100644 index 0000000..36b5c7f --- /dev/null +++ b/src/ui/components/styled_entry.py @@ -0,0 +1,55 @@ +from PyQt6.QtWidgets import QHBoxLayout, QLineEdit, QVBoxLayout, QWidget +from PyQt6.QtCore import Qt + +from .label import field_label +from ui.theme import ACCENT, CREAM, DARK, READONLY_TEXT, app_font + + +class StyledEntry(QLineEdit): + + def __init__(self, parent: QWidget | None = None, font_size: int = 20) -> None: + super().__init__(parent) + self.setMinimumHeight(54) + self.setFont(app_font(font_size)) + self._apply_style(readonly=False) + + def _apply_style(self, readonly: bool) -> None: + text_color = READONLY_TEXT if readonly else CREAM + self.setStyleSheet(f""" + QLineEdit {{ + background-color: rgba(0, 0, 0, 80); + border: 2px solid {CREAM}; + border-radius: 12px; + color: {text_color}; + padding: 6px 14px; + selection-background-color: {ACCENT}; + selection-color: {DARK}; + }} + """) + + def set_readonly(self, readonly: bool) -> None: + self.setReadOnly(readonly) + self._apply_style(readonly) + + +def field_row( + layout: QVBoxLayout, + label_text: str, + *, + max_width: int = 800, + spacing: int = 8, +) -> StyledEntry: + layout.addWidget(field_label(label_text), alignment=Qt.AlignmentFlag.AlignHCenter) + + row = QHBoxLayout() + entry = StyledEntry() + entry.setMaximumWidth(max_width) + row.addStretch() + row.addWidget(entry) + row.addStretch() + layout.addLayout(row) + + if spacing: + layout.addSpacing(spacing) + + return entry diff --git a/src/ui/misc/__init__.py b/src/ui/misc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/misc/dev_overlay.py b/src/ui/misc/dev_overlay.py new file mode 100644 index 0000000..f86ee1e --- /dev/null +++ b/src/ui/misc/dev_overlay.py @@ -0,0 +1,175 @@ +# TODO: this entire class should be reimplemented, this old implementation was a temporary solution that overstayed its welcome + +# from __future__ import annotations +# +# from collections.abc import Callable +# +# from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton +# from PyQt6.QtCore import Qt, QTimer +# +# from misc.global_config import config +# from misc.global_context import context +# from hardware.traffic_light import TrafficLightState +# from ui.views.check_in_rfid import CheckInRFID +# from ui.views.create_account_barcode import CreateAccountBarcode +# from ui.views.create_account_manual import CreateAccountManual +# from ui.views.create_account_no_pid import CreateAccountNoPid +# from ui.views.create_account_review import CreateAccountReview +# from ui.views.sign_waiver import SignWaiver +# from ui.views.check_in_manual import CheckInManual +# from ui.views.qr_codes import QRCodes +# from ui.views.user_welcome import UserWelcome +# from ui.views.transition_screen import TransitionScreen +# from ui.base import Screen +# from controllers.navigation_controller import NavigationController +# +# _DEV_NAME = "Dev User" +# _DEV_EMAIL = "devuser@ucsd.edu" +# _DEV_PID = "A12345678" +# _DEV_RFID = "1a2b3c4d5e6f7g" +# _THANK_MSG = "Thank you for registering" +# +# +# def _sim_no_account_success(nav: NavigationController) -> None: +# context().session.rfid = _DEV_RFID +# if not config().HAS_BARCODE_SCANNER: +# nav.navigate( +# TransitionScreen, +# lambda s: s.setup("Looks like you don't have an account.\nUse the other kiosk to set one up!"), +# ) +# QTimer.singleShot(6000, nav.back_to_main) +# return +# +# def on_done() -> None: +# context().traffic_light_controller.request_state_async(TrafficLightState.GREEN) +# nav.get_frame(UserWelcome).display_name(_DEV_NAME, _THANK_MSG) +# +# nav.go_to_create_account(on_done=on_done) +# +#x +# def _sim_no_account_needs_waiver(nav: NavigationController) -> None: +# context().session.rfid = _DEV_RFID +# if not config().HAS_BARCODE_SCANNER: +# nav.navigate( +# TransitionScreen, +# lambda s: s.setup("Looks like you don't have an account.\nUse the other kiosk to set one up!"), +# ) +# QTimer.singleShot(6000, nav.back_to_main) +# return +# nav.go_to_create_account(on_done=nav.go_to_sign_waiver) +# +# +# def _sim_barcode_swipe(nav: NavigationController) -> None: +# nav.go_to_create_account_review( +# pid=_DEV_PID, +# first_name=_DEV_NAME.split()[0], +# last_name=_DEV_NAME.split()[1], +# email=_DEV_EMAIL, +# ) +# +# +# TRANSITIONS: dict[type[Screen], list[tuple[str, Callable[[NavigationController], None]]]] = { +# CheckInRFID: [ +# ("QR Codes", lambda nav: nav.navigate(QRCodes)), +# ("No ID", lambda nav: nav.go_to_no_id()), +# ("card: success", lambda nav: nav.get_frame(UserWelcome).display_name(_DEV_NAME)), +# ("card: no account [→ success]", _sim_no_account_success), +# ("card: no account [→ waiver]", _sim_no_account_needs_waiver), +# ("card: no waiver", lambda nav: nav.go_to_sign_waiver()), +# ], +# QRCodes: [ +# ("← Main", lambda nav: nav.back_to_main()), +# ], +# CheckInManual: [ +# ("← Main", lambda nav: nav.back_to_main()), +# ("PID: success", lambda nav: nav.get_frame(UserWelcome).display_name(_DEV_NAME)), +# ("PID: no account [→ success]", _sim_no_account_success), +# ("PID: no account [→ waiver]", _sim_no_account_needs_waiver), +# ("PID: no waiver", lambda nav: nav.go_to_sign_waiver()), +# ], +# CreateAccountBarcode: [ +# ("sim barcode swipe", _sim_barcode_swipe), +# ("manual fill", lambda nav: nav.go_to_create_account_manual()), +# ("← Main", lambda nav: nav.back_to_main()), +# ], +# CreateAccountManual: [ +# ("→ review (pid lookup)", lambda nav: context().account_controller.lookup("pid", _DEV_PID)), +# ("→ no-pid screen", lambda nav: nav.go_to_create_account_no_pid()), +# ("← Main", lambda nav: nav.back_to_main()), +# ], +# CreateAccountNoPid: [ +# ("submit", lambda nav: nav.pop()), +# ("← Main", lambda nav: nav.back_to_main()), +# ], +# CreateAccountReview: [ +# ("submit", lambda nav: nav.pop()), +# ("← Main", lambda nav: nav.back_to_main()), +# ], +# SignWaiver: [ +# ("← Main", lambda nav: nav.back_to_main()), +# ], +# } +# +# +# class DevOverlay(QWidget): +# +# def __init__(self, nav: NavigationController) -> None: +# super().__init__(context().main_window.central) +# self._nav = nav +# self._buttons: list[QPushButton] = [] +# +# self.setStyleSheet("QWidget { background-color: #1a1a2e; }") +# +# layout = QVBoxLayout(self) +# layout.setContentsMargins(6, 6, 6, 6) +# layout.setSpacing(2) +# +# header = QLabel("DEV NAV") +# header.setStyleSheet( +# "color: #aaaaaa; font: bold 9pt Courier;" +# "background: transparent; border: none;" +# ) +# header.setAlignment(Qt.AlignmentFlag.AlignHCenter) +# layout.addWidget(header) +# +# self._layout = layout +# +# def update(self, screen_class: type[Screen]) -> None: # type: ignore[override] +# while self._layout.count() > 1: +# item = self._layout.takeAt(1) +# w = item.widget() # type: ignore[union-attr] +# if w: +# w.setParent(None) +# self._buttons.clear() +# +# for label, action in TRANSITIONS.get(screen_class, []): +# btn = QPushButton(label) +# btn.setStyleSheet(""" +# QPushButton { +# background-color: #2a2a4e; +# color: white; +# font: 9pt Courier; +# padding: 3px 6px; +# border: none; +# text-align: left; +# } +# QPushButton:hover { background-color: #4a4a8e; } +# """) +# btn.setCursor(Qt.CursorShape.PointingHandCursor) +# btn.clicked.connect(lambda checked, a=action: a(self._nav)) +# self._layout.addWidget(btn) +# self._buttons.append(btn) +# +# QTimer.singleShot(0, self._refresh) +# +# def _refresh(self) -> None: +# self.adjustSize() +# self._reposition() +# self.raise_() +# self.show() +# +# def _reposition(self) -> None: +# self.move( +# 25, +# config().SCREEN_HEIGHT - self.height() - 25, +# ) diff --git a/src/ui/misc/error_overlay.py b/src/ui/misc/error_overlay.py new file mode 100644 index 0000000..32cee7f --- /dev/null +++ b/src/ui/misc/error_overlay.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from collections.abc import Callable + +from PyQt6.QtWidgets import QWidget, QLabel, QVBoxLayout +from PyQt6.QtCore import QTimer, Qt + +from misc.global_config import config +from ui.components.label import styled_label + + +class ErrorOverlay(QWidget): + def __init__(self, parent: QWidget) -> None: + super().__init__(parent) + print(self.geometry()) + self.setGeometry(0, 0, config().SCREEN_WIDTH, config().SCREEN_HEIGHT) + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self.setStyleSheet("background-color: rgba(0, 0, 0, 110);") + self.hide() + + layout = QVBoxLayout(self) + layout.setContentsMargins(120, 120, 120, 120) + layout.setSpacing(20) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self._heading = QLabel("ERROR", self) + self._heading.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._heading.setStyleSheet("background: transparent; color: #FF6B6B; font: bold 28pt Montserrat; letter-spacing: 6px;") + + self._title = styled_label("", font_size=40, bold=True, align=Qt.AlignmentFlag.AlignCenter) + + self._detail = QLabel("", self) + self._detail.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop) + self._detail.setWordWrap(True) + self._detail.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self._detail.setStyleSheet( + "background-color: rgba(0, 0, 0, 160);" + "color: #E6E1D6;" + "font: 11pt 'Menlo', 'Monaco', 'Courier New', monospace;" + "border: 1px solid rgba(255, 255, 255, 40);" + "border-radius: 6px;" + "padding: 14px 18px;" + ) + self._detail.setFixedWidth(900) + + self._countdown = styled_label("", font_size=16, align=Qt.AlignmentFlag.AlignCenter) + + layout.addWidget(self._heading) + layout.addWidget(self._title, alignment=Qt.AlignmentFlag.AlignHCenter) + layout.addWidget(self._detail, alignment=Qt.AlignmentFlag.AlignHCenter) + layout.addWidget(self._countdown, alignment=Qt.AlignmentFlag.AlignHCenter) + + self._retry_timer = QTimer(self) + self._retry_timer.setInterval(1000) + self._retry_timer.timeout.connect(self._tick_retry) + self._retry_remaining: int = 0 + self._retry_callback: Callable[[], None] | None = None + + def show_error(self, title: str, detail: str, *, retry_in: int | None = None, on_retry: Callable[[], None] | None = None) -> None: + self._title.setText(title) + self._detail.setText(detail) + self._retry_timer.stop() + self._retry_callback = on_retry + if retry_in is not None and on_retry is not None: + self._retry_remaining = int(retry_in) + self._countdown.setText(f"Retrying in {self._retry_remaining}s…") + self._countdown.show() + self._retry_timer.start() + else: + self._countdown.clear() + self._countdown.hide() + self.show() + self.raise_() + + def hide_error(self) -> None: + self._retry_timer.stop() + self._retry_callback = None + self.hide() + + def _tick_retry(self) -> None: + self._retry_remaining -= 1 + if self._retry_remaining <= 0: + self._retry_timer.stop() + self._countdown.setText("Retrying…") + cb = self._retry_callback + self._retry_callback = None + if cb: + cb() + else: + self._countdown.setText(f"Retrying in {self._retry_remaining}s…") diff --git a/src/ui/theme.py b/src/ui/theme.py new file mode 100644 index 0000000..37f8346 --- /dev/null +++ b/src/ui/theme.py @@ -0,0 +1,22 @@ +from PyQt6.QtCore import QSize +from PyQt6.QtGui import QFont + +OUTER_MARGIN: int = 14 +INNER_MARGIN: int = 24 + +NAV_BTN_SIZE: int = 100 +NAV_ICON_SIZE: QSize = QSize(52, 52) + +FONT_FAMILY: str = "Montserrat" + + +def app_font(size: int, *, bold: bool = False) -> QFont: + font = QFont(FONT_FAMILY) + font.setPixelSize(size) # using pixel size (px) over font size (pt) as it is consistent operating systems + font.setBold(bold) + return font + +CREAM: str = "#F5F0E6" +ACCENT: str = "#4EBEEE" +READONLY_TEXT: str = "#C8C0B0" +DARK: str = "#153246" diff --git a/src/ui/views/__init__.py b/src/ui/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/views/check_in_manual.py b/src/ui/views/check_in_manual.py new file mode 100644 index 0000000..e142ce7 --- /dev/null +++ b/src/ui/views/check_in_manual.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PyQt6.QtWidgets import QHBoxLayout +from PyQt6.QtCore import Qt + +from misc.global_context import context +from ui.base import Screen +from ui.components.styled_button import StyledButton +from ui.components.styled_entry import field_row +from ui.components.label import styled_label + +if TYPE_CHECKING: + from controllers.navigation_controller import NavigationController + + +class CheckInManual(Screen): + def _build(self, controller: NavigationController) -> None: + self.add_home_row() + + self.content.addStretch(2) + + instruction = styled_label( + "Enter your UCSD PID below\n" + "to check in", + font_size=36, + ) + self.content.addWidget(instruction, alignment=Qt.AlignmentFlag.AlignHCenter) + + self.content.addStretch(1) + + self.pid_entry = field_row(self.content, "PID", spacing=0) + self.pid_entry.returnPressed.connect(lambda: self._call_check_in()) + + self.content.addStretch(2) + + btn_row = QHBoxLayout() + self.check_in_btn = StyledButton("Check In") + self.check_in_btn.setFixedWidth(349) + self.check_in_btn.setEnabled(False) + self.check_in_btn.clicked.connect(lambda: self._call_check_in()) + self.pid_entry.textChanged.connect(self._update_btn_state) + btn_row.addStretch() + btn_row.addWidget(self.check_in_btn) + btn_row.addStretch() + self.content.addLayout(btn_row) + + def _update_btn_state(self) -> None: + self.check_in_btn.setEnabled(bool(self.pid_entry.text().strip())) + + def on_show(self) -> None: + self.pid_entry.setFocus() + + def on_hide(self) -> None: + self.pid_entry.clearFocus() + self.clear_entries() + + def clear_entries(self) -> None: + self.pid_entry.clear() + + def update_entries(self, pid: str) -> None: + self.pid_entry.setText(pid) + + def _call_check_in(self) -> None: + pid = self.pid_entry.text().strip() + if not pid: + return + self.clear_entries() + context().check_in_controller.check_in("pid", pid) diff --git a/src/ui/views/create_account_barcode.py b/src/ui/views/create_account_barcode.py new file mode 100644 index 0000000..c912455 --- /dev/null +++ b/src/ui/views/create_account_barcode.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PyQt6.QtWidgets import QHBoxLayout +from PyQt6.QtCore import Qt + +from ui.base import Screen +from ui.components.styled_button import StyledButton +from ui.components.label import styled_label +from ui.views.create_account_manual import CreateAccountManual + +if TYPE_CHECKING: + from controllers.navigation_controller import NavigationController + + +class CreateAccountBarcode(Screen): + def _build(self, controller: NavigationController) -> None: + self.add_home_row() + + self.content.addStretch(3) + + title = styled_label("Welcome!", font_size=80, bold=True) + self.content.addWidget(title, alignment=Qt.AlignmentFlag.AlignHCenter) + + self.content.addStretch(2) + + instruction = styled_label( + "To create an account, please place your ID on the barcode scanner or press the button below", + font_size=36, + ) + self.content.addWidget(instruction, alignment=Qt.AlignmentFlag.AlignHCenter) + + self.content.addStretch(8) + + btn_row = QHBoxLayout() + fill_btn = StyledButton("Fill Manually") + fill_btn.setFixedWidth(349) + fill_btn.clicked.connect(lambda: controller.navigate(CreateAccountManual)) + btn_row.addStretch() + btn_row.addWidget(fill_btn) + btn_row.addStretch() + self.content.addLayout(btn_row) + + self.content.addStretch(3) diff --git a/src/ui/views/create_account_manual.py b/src/ui/views/create_account_manual.py new file mode 100644 index 0000000..c115e08 --- /dev/null +++ b/src/ui/views/create_account_manual.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PyQt6.QtWidgets import QHBoxLayout + +from threading import Thread + +from misc.global_context import context +from ui.base import Screen +from ui.components.styled_button import StyledButton +from ui.components.styled_entry import field_row +from ui.views.create_account_review import CreateAccountReview + +if TYPE_CHECKING: + from controllers.navigation_controller import NavigationController + + +class CreateAccountManual(Screen): + def _build(self, controller: NavigationController) -> None: + self.add_home_row() + + self.content.addStretch(3) + + self.pid_entry = field_row(self.content, "PID", spacing=0) + self.pid_entry.returnPressed.connect(self._go_to_review) + + self.content.addStretch(2) + + btn_row = QHBoxLayout() + self.register_btn = StyledButton("Register") + self.register_btn.setFixedWidth(349) + self.register_btn.setEnabled(False) + self.register_btn.clicked.connect(self._go_to_review) + self.pid_entry.textChanged.connect(self._update_btn_state) + btn_row.addStretch() + btn_row.addWidget(self.register_btn) + btn_row.addStretch() + self.content.addLayout(btn_row) + + self.content.addSpacing(12) + + no_pid_row = QHBoxLayout() + no_pid_btn = StyledButton("No PID →") + no_pid_btn.setFixedWidth(349) + no_pid_btn.setMinimumHeight(80) + no_pid_btn.clicked.connect(lambda: controller.navigate(CreateAccountReview)) + no_pid_row.addStretch() + no_pid_row.addWidget(no_pid_btn) + no_pid_row.addStretch() + self.content.addLayout(no_pid_row) + + def _update_btn_state(self) -> None: + self.register_btn.setEnabled(bool(self.pid_entry.text().strip())) + + def on_show(self) -> None: + self.pid_entry.setFocus() + + def on_hide(self) -> None: + self.pid_entry.clearFocus() + self.clear_entries() + + def clear_entries(self) -> None: + self.pid_entry.clear() + + def _go_to_review(self) -> None: + pid = self.pid_entry.text().strip() + self.clear_entries() + def worker() -> None: + student = context().account_controller.lookup("pid", pid) + if student is None: + return + context().session.set_student(student) + context().main_window.main_thread_dispatcher.emit( + lambda: context().navigation_controller.navigate(CreateAccountReview) + ) + + Thread(target=worker, daemon=True).start() diff --git a/src/ui/views/create_account_review.py b/src/ui/views/create_account_review.py new file mode 100644 index 0000000..e5663c9 --- /dev/null +++ b/src/ui/views/create_account_review.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from threading import Thread +from typing import TYPE_CHECKING + +from PyQt6.QtWidgets import QHBoxLayout, QVBoxLayout, QWidget + +from misc.global_context import context +from ui.base import Screen +from ui.components.styled_button import StyledButton +from ui.components.styled_entry import field_row + +if TYPE_CHECKING: + from controllers.navigation_controller import NavigationController + + +class CreateAccountReview(Screen): + def _build(self, controller: NavigationController) -> None: + self.add_home_row() + + self.content.addSpacing(8) + + self.first_name_entry = field_row(self.content, "First Name") + self.last_name_entry = field_row(self.content, "Last Name") + self.email_entry = field_row(self.content, "Email") + + self.pid_container = QWidget() + pid_layout = QVBoxLayout(self.pid_container) + pid_layout.setContentsMargins(0, 0, 0, 0) + pid_layout.setSpacing(0) + self.pid_entry = field_row(pid_layout, "PID") + self.content.addWidget(self.pid_container) + + for entry in (self.first_name_entry, self.last_name_entry, + self.email_entry, self.pid_entry): + entry.returnPressed.connect(self._submit) + entry.textChanged.connect(self._update_btn_state) + + self.content.addStretch(1) + + btn_row = QHBoxLayout() + self.register_btn = StyledButton("Register") + self.register_btn.setFixedWidth(349) + self.register_btn.setEnabled(False) + self.register_btn.clicked.connect(self._submit) + btn_row.addStretch() + btn_row.addWidget(self.register_btn) + btn_row.addStretch() + self.content.addLayout(btn_row) + + def _update_btn_state(self) -> None: + entries = [self.first_name_entry, self.last_name_entry, self.email_entry] + if self.pid_container.isVisible(): + entries.append(self.pid_entry) + self.register_btn.setEnabled(all(e.text().strip() for e in entries)) + + def on_show(self) -> None: + session = context().session + self.first_name_entry.setText(session.first_name) + self.last_name_entry.setText(session.last_name) + self.email_entry.setText(session.email) + has_pid = bool(session.pid) + self.pid_container.setVisible(has_pid) + if has_pid: + self.pid_entry.setText(session.pid.upper()) + self.pid_entry.set_readonly(True) + self._update_btn_state() + self.first_name_entry.setFocus() + + def on_hide(self) -> None: + for entry in (self.first_name_entry, self.last_name_entry, + self.email_entry, self.pid_entry): + entry.clearFocus() + self.clear_entries() + + def clear_entries(self) -> None: + for entry in (self.first_name_entry, self.last_name_entry, + self.email_entry, self.pid_entry): + entry.clear() + self.pid_entry.set_readonly(False) + + def _submit(self) -> None: + first = self.first_name_entry.text().strip() + last = self.last_name_entry.text().strip() + email = self.email_entry.text().strip() + has_pid = self.pid_container.isVisible() + pid = self.pid_entry.text().strip().upper() if has_pid else None + if not all([first, last, email] + ([pid] if has_pid else [])): + return + self.clear_entries() + + def worker() -> None: + if has_pid: + success = context().account_controller.create_account(pid=pid) + else: + success = context().account_controller.create_account(first_name=first, last_name=last, email=email) + if success: + context().check_in_controller.check_in( + context().session.check_in_method, + context().session.check_in_identifier, + welcome_message="Thank you for registering", + ) + + Thread(target=worker, daemon=True).start() diff --git a/src/ui/views/home_screen.py b/src/ui/views/home_screen.py new file mode 100644 index 0000000..0696fe7 --- /dev/null +++ b/src/ui/views/home_screen.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PyQt6.QtWidgets import QHBoxLayout +from PyQt6.QtCore import Qt +import qtawesome as qta + +from ui.base import Screen +from ui.components.styled_button import StyledButton, NAV_BTN_SIZE, NAV_ICON_SIZE +from ui.components.label import styled_label, title_label +from ui.views.check_in_manual import CheckInManual +from ui.views.qr_codes import QRCodes + +if TYPE_CHECKING: + from controllers.navigation_controller import NavigationController + + +class HomeScreen(Screen): + def _build(self, controller: NavigationController) -> None: + top_row = QHBoxLayout() + top_row.setContentsMargins(0, 0, 0, 0) + + qr_btn = StyledButton(ghost=True) + qr_btn.setIcon(qta.icon('mdi.qrcode-scan', color='#F5F0E6')) + qr_btn.setIconSize(NAV_ICON_SIZE) + qr_btn.setFixedSize(NAV_BTN_SIZE, NAV_BTN_SIZE) + qr_btn.clicked.connect(lambda: controller.navigate(QRCodes)) + + no_id_btn = StyledButton("No ID", font_size=20, ghost=True) + no_id_btn.setFixedSize(NAV_BTN_SIZE, NAV_BTN_SIZE) + no_id_btn.clicked.connect(lambda: controller.navigate(CheckInManual)) + + top_row.addWidget(qr_btn) + top_row.addStretch() + top_row.addWidget(no_id_btn) + self.content.addLayout(top_row) + + self.content.addStretch(2) + + title = title_label("UCSD Makerspace") + self.content.addWidget(title, alignment=Qt.AlignmentFlag.AlignHCenter) + + subtitle = styled_label("Welcome Desk", font_size=55) + self.content.addWidget(subtitle, alignment=Qt.AlignmentFlag.AlignHCenter) + + self.content.addStretch(3) + + instruction = styled_label("Please tap ID on the blue box to start", font_size=24) + self.content.addWidget(instruction, alignment=Qt.AlignmentFlag.AlignHCenter) diff --git a/src/ui/views/qr_codes.py b/src/ui/views/qr_codes.py new file mode 100644 index 0000000..fc82c5d --- /dev/null +++ b/src/ui/views/qr_codes.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QPixmap +from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel + +from misc.asset import Asset +from ui.base import Screen +from ui.components.label import styled_label + +if TYPE_CHECKING: + from controllers.navigation_controller import NavigationController + + +class QRCodes(Screen): + def _build(self, controller: NavigationController) -> None: + self.add_home_row() + + self.content.addStretch(1) + + qr_row = QHBoxLayout() + qr_row.setSpacing(80) + + def _qr_col(image_path: str, caption: str) -> QVBoxLayout: + col = QVBoxLayout() + col.setSpacing(12) + img = QLabel() + px = QPixmap(str(image_path)) + img.setPixmap(px) + img.setAlignment(Qt.AlignmentFlag.AlignHCenter) + img.setStyleSheet("background: transparent; border: none;") + lbl = styled_label(caption, font_size=30, width=px.width()) + col.addWidget(img) + col.addWidget(lbl, alignment=Qt.AlignmentFlag.AlignHCenter) + return col + + qr_row.addStretch() + qr_row.addLayout(_qr_col(Asset.QR_WEBSITE.get_path(), "Website")) + qr_row.addLayout(_qr_col(Asset.QR_WAIVER.get_path(), "Waiver")) + qr_row.addStretch() + self.content.addLayout(qr_row) + + self.content.addStretch(1) diff --git a/src/ui/views/sign_waiver.py b/src/ui/views/sign_waiver.py new file mode 100644 index 0000000..d3fa52f --- /dev/null +++ b/src/ui/views/sign_waiver.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PyQt6.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel +from PyQt6.QtGui import QPixmap +from PyQt6.QtCore import Qt + +from misc.asset import Asset +from ui.base import Screen +from ui.components.styled_button import StyledButton +from ui.components.label import styled_label + +if TYPE_CHECKING: + from controllers.navigation_controller import NavigationController + + +class SignWaiver(Screen): + def _build(self, controller: NavigationController) -> None: + content = QHBoxLayout() + content.setContentsMargins(50, 0, 50, 0) + content.setSpacing(20) + + left = QVBoxLayout() + left.setSpacing(0) + + left.addStretch(5) + + instruction = styled_label( + "Please scan the QR code on the right and sign the waiver", + font_size=36, + width=500 + ) + instruction.setAlignment(Qt.AlignmentFlag.AlignRight) + left.addWidget(instruction, alignment=Qt.AlignmentFlag.AlignHCenter) + + left.addStretch(7) + + right = QVBoxLayout() + right.setSpacing(0) + right.addStretch() + + qr_px = QPixmap(Asset.QR_WAIVER.get_path()) + qr_px = qr_px.scaled(320, 320, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + qr_label = QLabel() + qr_label.setPixmap(qr_px) + qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + qr_label.setStyleSheet("background: transparent; border: none;") + right.addWidget(qr_label) + + right.addSpacing(24) + + done_btn = StyledButton("Done Scanning") + done_btn.setFixedWidth(280) + done_btn.clicked.connect(lambda: controller.reset_check_in_session()) + right.addWidget(done_btn, alignment=Qt.AlignmentFlag.AlignHCenter) + + right.addStretch() + + content.addLayout(left, stretch=1) + content.addLayout(right, stretch=1) + + self.content.addLayout(content) diff --git a/src/ui/views/transition_screen.py b/src/ui/views/transition_screen.py new file mode 100644 index 0000000..8c82096 --- /dev/null +++ b/src/ui/views/transition_screen.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PyQt6.QtCore import Qt + +from ui.base import Screen +from ui.components.label import styled_label + +if TYPE_CHECKING: + from controllers.navigation_controller import NavigationController + + +class TransitionScreen(Screen): + def _build(self, controller: NavigationController) -> None: + self.content.addStretch() + + self._msg_label = styled_label("", font_size=48, width=800) + self.content.addWidget(self._msg_label, alignment=Qt.AlignmentFlag.AlignHCenter) + + self.content.addStretch() + + def set_message(self, message: str) -> None: + self._msg_label.setText(message) diff --git a/src/ui/views/user_welcome.py b/src/ui/views/user_welcome.py new file mode 100644 index 0000000..abb7b59 --- /dev/null +++ b/src/ui/views/user_welcome.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PyQt6.QtWidgets import QLabel, QVBoxLayout +from PyQt6.QtCore import QTimer, Qt + +from ui.base import Screen +from ui.components.label import styled_label + +if TYPE_CHECKING: + from controllers.navigation_controller import NavigationController + + +class UserWelcome(Screen): + def _build(self, controller: NavigationController) -> None: + self._last_name: str | None = None + self._active_labels: set[QLabel] = set() + + self.content.addStretch() + + self._msg_label = styled_label("Welcome back", font_size=38) + self.content.addWidget(self._msg_label, alignment=Qt.AlignmentFlag.AlignHCenter) + + self.content.addSpacing(8) + + self._names_layout = QVBoxLayout() + self._names_layout.setContentsMargins(0, 0, 0, 0) + self._names_layout.setSpacing(0) + self.content.addLayout(self._names_layout) + + self.content.addStretch() + + def on_hide(self) -> None: + self._active_labels.clear() + while self._names_layout.count(): + item = self._names_layout.takeAt(0) + if item is not None: + widget = item.widget() + if widget is not None: + widget.deleteLater() + self._msg_label.setText("Welcome back") + self._last_name = None + + def display_name(self, name: str, message: str = "Welcome back") -> None: + if name == self._last_name: + return + + self._last_name = name + self._msg_label.setText(message) + self.controller.navigate(UserWelcome) + + label = styled_label(name, font_size=70, bold=True) + self._names_layout.addWidget(label, alignment=Qt.AlignmentFlag.AlignHCenter) + self._active_labels.add(label) + + QTimer.singleShot(3000, lambda: self._remove_name(label)) + + def _remove_name(self, label: QLabel) -> None: + if label not in self._active_labels: + return + self._active_labels.discard(label) + self._names_layout.removeWidget(label) + label.deleteLater() + + if not self._active_labels: + self._last_name = None + self.controller.reset_check_in_session() diff --git a/src/views/base.py b/src/views/base.py deleted file mode 100644 index 25934bc..0000000 --- a/src/views/base.py +++ /dev/null @@ -1,20 +0,0 @@ -from PyQt6.QtWidgets import QWidget - - -class Screen(QWidget): - def __init__(self, controller): - super().__init__() - self.controller = controller - self._build(controller) - - def _build(self, controller): - """Subclasses build their UI here instead of in __init__.""" - pass - - def on_show(self): - """Called by NavigationController when this screen becomes visible.""" - pass - - def on_hide(self): - """Called by NavigationController just before this screen is hidden.""" - pass diff --git a/src/views/check_in_manual.py b/src/views/check_in_manual.py deleted file mode 100644 index 25d60a5..0000000 --- a/src/views/check_in_manual.py +++ /dev/null @@ -1,95 +0,0 @@ -from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel -from PyQt6.QtCore import Qt -from .base import Screen -from .components.outline_frame import OutlineFrame -from .components.styled_button import StyledButton, home_button, INNER_MARGIN, OUTER_MARGIN -from .components.styled_entry import StyledEntry - - -class CheckInManual(Screen): - def _build(self, controller): - outer = QVBoxLayout(self) - outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) - outer.setSpacing(0) - - outline = OutlineFrame() - outer.addWidget(outline) - - inner = QVBoxLayout(outline) - inner.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) - inner.setSpacing(0) - - top_row = QHBoxLayout() - top_row.addWidget(home_button(lambda: controller.back_to_main())) - top_row.addStretch() - inner.addLayout(top_row) - - inner.addStretch(2) - - instruction = QLabel( - "Enter your UCSD PID below\n" - "to check in" - ) - instruction.setStyleSheet( - "color: #F5F0E6; font: 36pt Montserrat;" - "background: transparent; border: none;" - ) - instruction.setAlignment(Qt.AlignmentFlag.AlignHCenter) - instruction.setWordWrap(True) - inner.addWidget(instruction) - - inner.addStretch(1) - - pid_label = QLabel("PID") - pid_label.setStyleSheet( - "color: #F5F0E6; font: 18pt Montserrat;" - "background: transparent; border: none;" - ) - pid_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) - inner.addWidget(pid_label) - - entry_row = QHBoxLayout() - self.pid_entry = StyledEntry() - self.pid_entry.setMaximumWidth(800) - self.pid_entry.returnPressed.connect(lambda: self._call_check_in(controller)) - entry_row.addStretch() - entry_row.addWidget(self.pid_entry) - entry_row.addStretch() - inner.addLayout(entry_row) - - inner.addStretch(2) - - btn_row = QHBoxLayout() - self.check_in_btn = StyledButton("Check In") - self.check_in_btn.setFixedWidth(349) - self.check_in_btn.setEnabled(False) - self.check_in_btn.clicked.connect(lambda: self._call_check_in(controller)) - self.pid_entry.textChanged.connect(self._update_btn_state) - btn_row.addStretch() - btn_row.addWidget(self.check_in_btn) - btn_row.addStretch() - inner.addLayout(btn_row) - - def _update_btn_state(self): - self.check_in_btn.setEnabled(bool(self.pid_entry.text().strip())) - - def on_show(self): - self.pid_entry.setFocus() - - def on_hide(self): - self.pid_entry.clearFocus() - - def clear_entries(self): - self.pid_entry.clear() - - def update_entries(self, pid): - self.pid_entry.setText(pid) - - def _call_check_in(self, controller): - pid = self.pid_entry.text().strip() - if not pid: - return - controller.show_status("PLEASE WAIT: LOADING...") - self.clear_entries() - self.controller.ctx.check_in.handle_by_pid(pid) - controller.hide_status() diff --git a/src/views/check_in_rfid.py b/src/views/check_in_rfid.py deleted file mode 100644 index 49c4157..0000000 --- a/src/views/check_in_rfid.py +++ /dev/null @@ -1,67 +0,0 @@ -from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel -from PyQt6.QtCore import Qt -import qtawesome as qta -from .base import Screen -from .components.outline_frame import OutlineFrame -from .components.styled_button import StyledButton, NAV_BTN_SIZE, NAV_ICON_SIZE, INNER_MARGIN, OUTER_MARGIN -from .qr_codes import QRCodes - - -class CheckInRFID(Screen): - def _build(self, controller): - outer = QVBoxLayout(self) - outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) - outer.setSpacing(0) - - outline = OutlineFrame() - outer.addWidget(outline) - - inner = QVBoxLayout(outline) - inner.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) - inner.setSpacing(0) - - top_row = QHBoxLayout() - top_row.setContentsMargins(0, 0, 0, 0) - - qr_btn = StyledButton(ghost=True) - qr_btn.setIcon(qta.icon('mdi.qrcode-scan', color='#F5F0E6')) - qr_btn.setIconSize(NAV_ICON_SIZE) - qr_btn.setFixedSize(NAV_BTN_SIZE, NAV_BTN_SIZE) - qr_btn.clicked.connect(lambda: controller.show_frame(QRCodes)) - - no_id_btn = StyledButton("No ID", font_size=20, ghost=True) - no_id_btn.setFixedSize(NAV_BTN_SIZE, NAV_BTN_SIZE) - no_id_btn.clicked.connect(lambda: controller.go_to_no_id()) - - top_row.addWidget(qr_btn) - top_row.addStretch() - top_row.addWidget(no_id_btn) - inner.addLayout(top_row) - - inner.addStretch(2) - - title = QLabel("UCSD Makerspace") - title.setStyleSheet( - "color: #F5F0E6; font: bold 80pt Montserrat;" - "background: transparent; border: none;" - ) - title.setAlignment(Qt.AlignmentFlag.AlignHCenter) - inner.addWidget(title) - - subtitle = QLabel("Welcome Desk") - subtitle.setStyleSheet( - "color: #F5F0E6; font: 55pt Montserrat;" - "background: transparent; border: none;" - ) - subtitle.setAlignment(Qt.AlignmentFlag.AlignHCenter) - inner.addWidget(subtitle) - - inner.addStretch(3) - - instruction = QLabel("Please tap ID on the blue box to start") - instruction.setStyleSheet( - "color: #F5F0E6; font: 24pt Montserrat;" - "background: transparent; border: none;" - ) - instruction.setAlignment(Qt.AlignmentFlag.AlignHCenter) - inner.addWidget(instruction) diff --git a/src/views/components/canvas_entry.py b/src/views/components/canvas_entry.py deleted file mode 100644 index b295008..0000000 --- a/src/views/components/canvas_entry.py +++ /dev/null @@ -1,80 +0,0 @@ -import tkinter as tk - -_focused = None - - -class CanvasEntry: - def __init__(self, canvas, x, y, w, h, font, fg="#F5F0E6"): - self.canvas = canvas - self._x = x - self._y = y - - canvas.configure(insertbackground=fg, insertontime=600, insertofftime=400) - - self._hit_id = canvas.create_rectangle( - x - w / 2, y - h / 2, x + w / 2, y + h / 2, - fill="", outline="", state="hidden", - ) - self._text_id = canvas.create_text( - x, y, text="", fill=fg, font=font, - anchor="center", state="hidden", - ) - - canvas.tag_bind(self._hit_id, "", self._on_click) - canvas.tag_bind(self._text_id, "", self._on_click) - - @property - def item_ids(self): - return [self._hit_id, self._text_id] - - def _on_click(self, event=None): - if getattr(self, '_readonly', False): - return - global _focused - if _focused and _focused is not self: - _focused._blur() - _focused = self - self.canvas.focus_set() - self.canvas.focus(self._text_id) - self.canvas.bind("", _dispatch_key) - if event: - idx = self.canvas.index(self._text_id, f"@{event.x},{event.y}") - self.canvas.icursor(self._text_id, idx) - else: - self.canvas.icursor(self._text_id, tk.END) - - def _blur(self): - global _focused - if _focused is self: - _focused = None - self.canvas.focus("") - - @classmethod - def blur_all(cls): - global _focused - if _focused: - _focused._blur() - - def get(self): - return self.canvas.itemcget(self._text_id, "text") - - def delete(self, start, end=None): - self.canvas.dchars(self._text_id, 0, tk.END) - - def insert(self, index, text): - self.canvas.insert(self._text_id, index, text) - - def set_readonly(self, readonly: bool): - self._readonly = readonly - color = "#C8C0B0" if readonly else "#F5F0E6" - self.canvas.itemconfigure(self._text_id, fill=color) - - -def _dispatch_key(event): - if _focused: - if event.keysym == "BackSpace": - idx = _focused.canvas.index(_focused._text_id, tk.INSERT) - if idx > 0: - _focused.canvas.dchars(_focused._text_id, idx - 1, idx - 1) - elif event.char and event.char.isprintable(): - _focused.canvas.insert(_focused._text_id, tk.INSERT, event.char) diff --git a/src/views/components/dev_overlay.py b/src/views/components/dev_overlay.py deleted file mode 100644 index 3771c9a..0000000 --- a/src/views/components/dev_overlay.py +++ /dev/null @@ -1,164 +0,0 @@ -from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton -from PyQt6.QtCore import Qt, QTimer - -from views.check_in_rfid import CheckInRFID -from views.create_account_barcode import CreateAccountBarcode -from views.create_account_manual import CreateAccountManual -from views.create_account_no_pid import CreateAccountNoPid -from views.create_account_review import CreateAccountReview -from views.sign_waiver import SignWaiver -from views.check_in_manual import CheckInManual -from views.qr_codes import QRCodes -from views.user_welcome import UserWelcome -from views.transition_screen import TransitionScreen - -_DEV_NAME = "Dev User" -_DEV_EMAIL = "devuser@ucsd.edu" -_DEV_PID = "A12345678" -_DEV_RFID = "1a2b3c4d5e6f7g" -_THANK_MSG = "Thank you for registering" - - -def _sim_no_account_success(nav): - nav.ctx.rfid = _DEV_RFID - if not nav.ctx.has_barcode_scanner: - nav.get_frame(TransitionScreen).display( - "Looks like you don't have an account.\nUse the other kiosk to set one up!" - ) - QTimer.singleShot(6000, nav.back_to_main) - return - - def on_done(): - nav.ctx.traffic_light.request_green() - nav.get_frame(UserWelcome).display_name(_DEV_NAME, _THANK_MSG) - - nav.go_to_create_account(on_done=on_done) - - -def _sim_no_account_needs_waiver(nav): - nav.ctx.rfid = _DEV_RFID - if not nav.ctx.has_barcode_scanner: - nav.get_frame(TransitionScreen).display( - "Looks like you don't have an account.\nUse the other kiosk to set one up!" - ) - QTimer.singleShot(6000, nav.back_to_main) - return - nav.go_to_create_account(on_done=nav.go_to_sign_waiver) - - -def _sim_barcode_swipe(nav): - nav.go_to_create_account_review( - pid=_DEV_PID, - first_name=_DEV_NAME.split()[0], - last_name=_DEV_NAME.split()[1], - email=_DEV_EMAIL, - ) - - -TRANSITIONS = { - CheckInRFID: [ - ("QR Codes", lambda nav: nav.show_frame(QRCodes)), - ("No ID", lambda nav: nav.go_to_no_id()), - ("card: success", lambda nav: nav.get_frame(UserWelcome).display_name(_DEV_NAME)), - ("card: no account [→ success]", _sim_no_account_success), - ("card: no account [→ waiver]", _sim_no_account_needs_waiver), - ("card: no waiver", lambda nav: nav.go_to_sign_waiver()), - ], - QRCodes: [ - ("← Main", lambda nav: nav.back_to_main()), - ], - CheckInManual: [ - ("← Main", lambda nav: nav.back_to_main()), - ("PID: success", lambda nav: nav.get_frame(UserWelcome).display_name(_DEV_NAME)), - ("PID: no account [→ success]", _sim_no_account_success), - ("PID: no account [→ waiver]", _sim_no_account_needs_waiver), - ("PID: no waiver", lambda nav: nav.go_to_sign_waiver()), - ], - CreateAccountBarcode: [ - ("sim barcode swipe", _sim_barcode_swipe), - ("manual fill", lambda nav: nav.go_to_create_account_manual()), - ("← Main", lambda nav: nav.back_to_main()), - ], - CreateAccountManual: [ - ("→ review (pid lookup)", lambda nav: nav.ctx.account.go_to_review_from_pid(_DEV_PID)), - ("→ no-pid screen", lambda nav: nav.go_to_create_account_no_pid()), - ("← Main", lambda nav: nav.back_to_main()), - ], - CreateAccountNoPid: [ - ("submit", lambda nav: nav.pop()), - ("← Main", lambda nav: nav.back_to_main()), - ], - CreateAccountReview: [ - ("submit", lambda nav: nav.pop()), - ("← Main", lambda nav: nav.back_to_main()), - ], - SignWaiver: [ - ("← Main", lambda nav: nav.back_to_main()), - ], -} - - -class DevOverlay(QWidget): - - def __init__(self, window, nav): - super().__init__(window.central) - self._nav = nav - self._stacked = window.stacked - self._buttons: list[QPushButton] = [] - - self.setStyleSheet("QWidget { background-color: #1a1a2e; }") - - layout = QVBoxLayout(self) - layout.setContentsMargins(6, 6, 6, 6) - layout.setSpacing(2) - - header = QLabel("DEV NAV") - header.setStyleSheet( - "color: #aaaaaa; font: bold 9pt Courier;" - "background: transparent; border: none;" - ) - header.setAlignment(Qt.AlignmentFlag.AlignHCenter) - layout.addWidget(header) - - self._layout = layout - - def update(self, screen_class): - while self._layout.count() > 1: - item = self._layout.takeAt(1) - w = item.widget() - if w: - w.setParent(None) - self._buttons.clear() - - for label, action in TRANSITIONS.get(screen_class, []): - btn = QPushButton(label) - btn.setStyleSheet(""" - QPushButton { - background-color: #2a2a4e; - color: white; - font: 9pt Courier; - padding: 3px 6px; - border: none; - text-align: left; - } - QPushButton:hover { background-color: #4a4a8e; } - """) - btn.setCursor(Qt.CursorShape.PointingHandCursor) - btn.clicked.connect(lambda checked, a=action: a(self._nav)) - self._layout.addWidget(btn) - self._buttons.append(btn) - - QTimer.singleShot(0, self._refresh) - - def _refresh(self): - self.adjustSize() - self._reposition() - self.raise_() - self.show() - - def _reposition(self): - s = self._stacked - self.move( - s.x() + s.width() - self.width() - 10, - s.y() + s.height() - self.height() - 10, - ) diff --git a/src/views/components/styled_entry.py b/src/views/components/styled_entry.py deleted file mode 100644 index 4eb44c3..0000000 --- a/src/views/components/styled_entry.py +++ /dev/null @@ -1,30 +0,0 @@ -from PyQt6.QtWidgets import QLineEdit -from PyQt6.QtCore import Qt - - -class StyledEntry(QLineEdit): - - def __init__(self, parent=None, font_size=20): - super().__init__(parent) - self._font_size = font_size - self.setMinimumHeight(54) - self._apply_style(readonly=False) - - def _apply_style(self, readonly: bool): - text_color = "#C8C0B0" if readonly else "#F5F0E6" - self.setStyleSheet(f""" - QLineEdit {{ - background-color: rgba(0, 0, 0, 80); - border: 2px solid #F5F0E6; - border-radius: 12px; - color: {text_color}; - font: {self._font_size}pt Montserrat; - padding: 6px 14px; - selection-background-color: #4EBEEE; - selection-color: #153246; - }} - """) - - def set_readonly(self, readonly: bool): - self.setReadOnly(readonly) - self._apply_style(readonly) diff --git a/src/views/components/theme.py b/src/views/components/theme.py deleted file mode 100644 index 7f736c9..0000000 --- a/src/views/components/theme.py +++ /dev/null @@ -1,7 +0,0 @@ -from PyQt6.QtCore import QSize - -OUTER_MARGIN = 14 -INNER_MARGIN = 24 - -NAV_BTN_SIZE = 100 -NAV_ICON_SIZE = QSize(52, 52) diff --git a/src/views/create_account_barcode.py b/src/views/create_account_barcode.py deleted file mode 100644 index f7ae479..0000000 --- a/src/views/create_account_barcode.py +++ /dev/null @@ -1,45 +0,0 @@ -from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel -from PyQt6.QtCore import Qt -from .base import Screen -from .components.outline_frame import OutlineFrame -from .components.styled_button import StyledButton, home_button, INNER_MARGIN, OUTER_MARGIN - - -class CreateAccountBarcode(Screen): - def _build(self, controller): - outer = QVBoxLayout(self) - outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) - outer.setSpacing(0) - - outline = OutlineFrame() - outer.addWidget(outline) - - inner = QVBoxLayout(outline) - inner.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) - inner.setSpacing(0) - - top_row = QHBoxLayout() - top_row.addWidget(home_button(lambda: controller.back_to_main())) - top_row.addStretch() - inner.addLayout(top_row) - - inner.addStretch(3) - - instruction = QLabel("Please scan your ID barcode") - instruction.setStyleSheet( - "color: #F5F0E6; font: 36pt Montserrat;" - "background: transparent; border: none;" - ) - instruction.setAlignment(Qt.AlignmentFlag.AlignHCenter) - inner.addWidget(instruction) - - inner.addStretch(3) - - btn_row = QHBoxLayout() - fill_btn = StyledButton("Fill Manually") - fill_btn.setFixedWidth(349) - fill_btn.clicked.connect(lambda: controller.go_to_create_account_manual()) - btn_row.addStretch() - btn_row.addWidget(fill_btn) - btn_row.addStretch() - inner.addLayout(btn_row) diff --git a/src/views/create_account_manual.py b/src/views/create_account_manual.py deleted file mode 100644 index 707202c..0000000 --- a/src/views/create_account_manual.py +++ /dev/null @@ -1,86 +0,0 @@ -from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel -from PyQt6.QtCore import Qt -from .base import Screen -from .components.outline_frame import OutlineFrame -from .components.styled_button import StyledButton, home_button, INNER_MARGIN, OUTER_MARGIN -from .components.styled_entry import StyledEntry - - -class CreateAccountManual(Screen): - def _build(self, controller): - outer = QVBoxLayout(self) - outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) - outer.setSpacing(0) - - outline = OutlineFrame() - outer.addWidget(outline) - - inner = QVBoxLayout(outline) - inner.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) - inner.setSpacing(0) - - top_row = QHBoxLayout() - top_row.addWidget(home_button(lambda: controller.back_to_main())) - top_row.addStretch() - inner.addLayout(top_row) - - inner.addStretch(3) - - pid_label = QLabel("PID") - pid_label.setStyleSheet( - "color: #F5F0E6; font: 18pt Montserrat;" - "background: transparent; border: none;" - ) - pid_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) - inner.addWidget(pid_label) - - entry_row = QHBoxLayout() - self.pid_entry = StyledEntry() - self.pid_entry.setMaximumWidth(800) - self.pid_entry.returnPressed.connect(self._go_to_review) - entry_row.addStretch() - entry_row.addWidget(self.pid_entry) - entry_row.addStretch() - inner.addLayout(entry_row) - - inner.addStretch(2) - - btn_row = QHBoxLayout() - self.register_btn = StyledButton("Register") - self.register_btn.setFixedWidth(349) - self.register_btn.setEnabled(False) - self.register_btn.clicked.connect(self._go_to_review) - self.pid_entry.textChanged.connect(self._update_btn_state) - btn_row.addStretch() - btn_row.addWidget(self.register_btn) - btn_row.addStretch() - inner.addLayout(btn_row) - - inner.addSpacing(12) - - no_pid_row = QHBoxLayout() - no_pid_btn = StyledButton("No PID →") - no_pid_btn.setFixedWidth(349) - no_pid_btn.setMinimumHeight(80) - no_pid_btn.clicked.connect(lambda: controller.go_to_create_account_no_pid()) - no_pid_row.addStretch() - no_pid_row.addWidget(no_pid_btn) - no_pid_row.addStretch() - inner.addLayout(no_pid_row) - - def _update_btn_state(self): - self.register_btn.setEnabled(bool(self.pid_entry.text().strip())) - - def on_show(self): - self.pid_entry.setFocus() - - def on_hide(self): - self.pid_entry.clearFocus() - - def clear_entries(self): - self.pid_entry.clear() - - def _go_to_review(self): - pid = self.pid_entry.text().strip() - self.clear_entries() - self.controller.ctx.account.go_to_review_from_pid(pid) diff --git a/src/views/create_account_no_pid.py b/src/views/create_account_no_pid.py deleted file mode 100644 index a2182da..0000000 --- a/src/views/create_account_no_pid.py +++ /dev/null @@ -1,97 +0,0 @@ -from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel -from PyQt6.QtCore import Qt -import logging -from .base import Screen -from .components.outline_frame import OutlineFrame -from .components.styled_button import StyledButton, home_button, INNER_MARGIN, OUTER_MARGIN -from .components.styled_entry import StyledEntry - - -class CreateAccountNoPid(Screen): - def _build(self, controller): - outer = QVBoxLayout(self) - outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) - outer.setSpacing(0) - - outline = OutlineFrame() - outer.addWidget(outline) - - inner = QVBoxLayout(outline) - inner.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) - inner.setSpacing(0) - - top_row = QHBoxLayout() - top_row.addWidget(home_button(lambda: controller.back_to_main())) - top_row.addStretch() - inner.addLayout(top_row) - - inner.addStretch(1) - - def _field_row(label_text): - lbl = QLabel(label_text) - lbl.setStyleSheet( - "color: #F5F0E6; font: 18pt Montserrat;" - "background: transparent; border: none;" - ) - lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) - inner.addWidget(lbl) - - row = QHBoxLayout() - entry = StyledEntry() - entry.setMaximumWidth(800) - row.addStretch() - row.addWidget(entry) - row.addStretch() - inner.addLayout(row) - inner.addSpacing(10) - return entry - - self.first_name_entry = _field_row("First Name") - self.last_name_entry = _field_row("Last Name") - self.email_entry = _field_row("Email") - - for entry in (self.first_name_entry, self.last_name_entry, self.email_entry): - entry.returnPressed.connect(self._submit) - entry.textChanged.connect(self._update_btn_state) - - inner.addStretch(1) - - btn_row = QHBoxLayout() - self.register_btn = StyledButton("Register") - self.register_btn.setFixedWidth(349) - self.register_btn.setEnabled(False) - self.register_btn.clicked.connect(self._submit) - btn_row.addStretch() - btn_row.addWidget(self.register_btn) - btn_row.addStretch() - inner.addLayout(btn_row) - - def _update_btn_state(self): - self.register_btn.setEnabled(all( - e.text().strip() for e in (self.first_name_entry, self.last_name_entry, self.email_entry) - )) - - def on_show(self): - self.first_name_entry.setFocus() - - def on_hide(self): - for entry in (self.first_name_entry, self.last_name_entry, self.email_entry): - entry.clearFocus() - - def clear_entries(self): - for entry in (self.first_name_entry, self.last_name_entry, self.email_entry): - entry.clear() - - def _submit(self): - first = self.first_name_entry.text().strip() - last = self.last_name_entry.text().strip() - email = self.email_entry.text().strip() - if not all([first, last, email]): - return - self.clear_entries() - try: - self.controller.ctx.account.create_account_from_review( - first_name=first, last_name=last, email=email, pid="" - ) - except Exception: - logging.warning("error occurred trying to create a user account", exc_info=True) diff --git a/src/views/create_account_review.py b/src/views/create_account_review.py deleted file mode 100644 index db033e7..0000000 --- a/src/views/create_account_review.py +++ /dev/null @@ -1,117 +0,0 @@ -from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel -from PyQt6.QtCore import Qt -import logging -from .base import Screen -from .components.outline_frame import OutlineFrame -from .components.styled_button import StyledButton, home_button, INNER_MARGIN, OUTER_MARGIN -from .components.styled_entry import StyledEntry - - -class CreateAccountReview(Screen): - def _build(self, controller): - outer = QVBoxLayout(self) - outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) - outer.setSpacing(0) - - outline = OutlineFrame() - outer.addWidget(outline) - - inner = QVBoxLayout(outline) - inner.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) - inner.setSpacing(0) - - top_row = QHBoxLayout() - top_row.addWidget(home_button(lambda: controller.back_to_main())) - top_row.addStretch() - inner.addLayout(top_row) - - inner.addSpacing(8) - - def _field_row(label_text): - lbl = QLabel(label_text) - lbl.setStyleSheet( - "color: #F5F0E6; font: 18pt Montserrat;" - "background: transparent; border: none;" - ) - lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) - inner.addWidget(lbl) - - row = QHBoxLayout() - entry = StyledEntry() - entry.setMaximumWidth(800) - row.addStretch() - row.addWidget(entry) - row.addStretch() - inner.addLayout(row) - inner.addSpacing(8) - return entry - - self.first_name_entry = _field_row("First Name") - self.last_name_entry = _field_row("Last Name") - self.email_entry = _field_row("Email") - self.pid_entry = _field_row("PID") - - for entry in (self.first_name_entry, self.last_name_entry, - self.email_entry, self.pid_entry): - entry.returnPressed.connect(self._submit) - entry.textChanged.connect(self._update_btn_state) - - inner.addStretch(1) - - btn_row = QHBoxLayout() - self.register_btn = StyledButton("Register") - self.register_btn.setFixedWidth(349) - self.register_btn.setEnabled(False) - self.register_btn.clicked.connect(self._submit) - btn_row.addStretch() - btn_row.addWidget(self.register_btn) - btn_row.addStretch() - inner.addLayout(btn_row) - - def setup(self, first_name="", last_name="", email="", pid="", pid_locked=False): - self.clear_entries() - if first_name: - self.first_name_entry.setText(first_name) - if last_name: - self.last_name_entry.setText(last_name) - if email: - self.email_entry.setText(email) - if pid: - self.pid_entry.setText(pid.upper()) - self.pid_entry.set_readonly(pid_locked) - self._update_btn_state() - - def _update_btn_state(self): - self.register_btn.setEnabled(all( - e.text().strip() for e in (self.first_name_entry, self.last_name_entry, - self.email_entry, self.pid_entry) - )) - - def on_show(self): - self.first_name_entry.setFocus() - - def on_hide(self): - for entry in (self.first_name_entry, self.last_name_entry, - self.email_entry, self.pid_entry): - entry.clearFocus() - - def clear_entries(self): - for entry in (self.first_name_entry, self.last_name_entry, - self.email_entry, self.pid_entry): - entry.clear() - self.pid_entry.set_readonly(False) - - def _submit(self): - first = self.first_name_entry.text().strip() - last = self.last_name_entry.text().strip() - email = self.email_entry.text().strip() - pid = self.pid_entry.text().strip().upper() - if not all([first, last, email, pid]): - return - self.clear_entries() - try: - self.controller.ctx.account.create_account_from_review( - first_name=first, last_name=last, email=email, pid=pid - ) - except Exception: - logging.warning("error occurred trying to create a user account", exc_info=True) diff --git a/src/views/qr_codes.py b/src/views/qr_codes.py deleted file mode 100644 index 823e77b..0000000 --- a/src/views/qr_codes.py +++ /dev/null @@ -1,59 +0,0 @@ -from pathlib import Path -from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel -from PyQt6.QtGui import QPixmap -from PyQt6.QtCore import Qt -from .base import Screen -from .components.outline_frame import OutlineFrame -from .components.styled_button import home_button, INNER_MARGIN, OUTER_MARGIN - -ASSETS_PATH = Path(__file__).parent.parent / "assets" / "qr_codes" - - -class QRCodes(Screen): - def _build(self, controller): - outer = QVBoxLayout(self) - outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) - outer.setSpacing(0) - - outline = OutlineFrame() - outer.addWidget(outline) - - inner = QVBoxLayout(outline) - inner.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) - inner.setSpacing(0) - - top_row = QHBoxLayout() - top_row.addWidget(home_button(lambda: controller.back_to_main())) - top_row.addStretch() - inner.addLayout(top_row) - - inner.addStretch(1) - - qr_row = QHBoxLayout() - qr_row.setSpacing(80) - - def _qr_col(image_path, caption): - col = QVBoxLayout() - col.setSpacing(12) - img = QLabel() - px = QPixmap(str(image_path)) - img.setPixmap(px) - img.setAlignment(Qt.AlignmentFlag.AlignHCenter) - img.setStyleSheet("background: transparent; border: none;") - lbl = QLabel(caption) - lbl.setStyleSheet( - "color: #F5F0E6; font: 30pt Montserrat;" - "background: transparent; border: none;" - ) - lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) - col.addWidget(img) - col.addWidget(lbl) - return col - - qr_row.addStretch() - qr_row.addLayout(_qr_col(ASSETS_PATH / "qr_website.png", "Website")) - qr_row.addLayout(_qr_col(ASSETS_PATH / "qr_waiver.png", "Waiver")) - qr_row.addStretch() - inner.addLayout(qr_row) - - inner.addStretch(1) diff --git a/src/views/sign_waiver.py b/src/views/sign_waiver.py deleted file mode 100644 index f012d20..0000000 --- a/src/views/sign_waiver.py +++ /dev/null @@ -1,72 +0,0 @@ -from pathlib import Path -from PyQt6.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel -from PyQt6.QtGui import QPixmap -from PyQt6.QtCore import Qt -from .base import Screen -from .components.outline_frame import OutlineFrame -from .components.styled_button import StyledButton, OUTER_MARGIN, INNER_MARGIN - -ASSETS_PATH = Path(__file__).parent.parent / "assets" / "sign_waiver" - - -class SignWaiver(Screen): - def _build(self, controller): - outer = QVBoxLayout(self) - outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) - outer.setSpacing(0) - - outline = OutlineFrame() - outer.addWidget(outline) - - root = QVBoxLayout(outline) - root.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) - root.setSpacing(0) - - content = QHBoxLayout() - content.setContentsMargins(50, 0, 50, 0) - content.setSpacing(20) - - left = QVBoxLayout() - left.setSpacing(0) - - left.addStretch(1) - - instruction = QLabel( - "Please scan the QR code\non the right and sign the waiver" - ) - instruction.setStyleSheet( - "color: #F5F0E6; font: 36pt Montserrat;" - "background: transparent; border: none;" - ) - instruction.setAlignment(Qt.AlignmentFlag.AlignHCenter) - instruction.setWordWrap(True) - left.addWidget(instruction) - - left.addStretch(2) - - content.addLayout(left, stretch=1) - - right = QVBoxLayout() - right.setSpacing(0) - right.addStretch() - - qr_px = QPixmap(str(ASSETS_PATH / "qr_waiver.png")) - qr_px = qr_px.scaled(320, 320, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) - qr_label = QLabel() - qr_label.setPixmap(qr_px) - qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - qr_label.setStyleSheet("background: transparent; border: none;") - right.addWidget(qr_label) - - right.addSpacing(24) - - done_btn = StyledButton("Done Scanning") - done_btn.setFixedWidth(280) - done_btn.clicked.connect(lambda: controller.back_to_main()) - right.addWidget(done_btn, alignment=Qt.AlignmentFlag.AlignHCenter) - - right.addStretch() - - content.addLayout(right, stretch=1) - - root.addLayout(content) diff --git a/src/views/transition_screen.py b/src/views/transition_screen.py deleted file mode 100644 index 4130371..0000000 --- a/src/views/transition_screen.py +++ /dev/null @@ -1,35 +0,0 @@ -from PyQt6.QtWidgets import QVBoxLayout, QLabel -from PyQt6.QtCore import Qt -from .base import Screen -from .components.outline_frame import OutlineFrame -from .components.theme import INNER_MARGIN, OUTER_MARGIN - - -class TransitionScreen(Screen): - def _build(self, controller): - outer = QVBoxLayout(self) - outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) - outer.setSpacing(0) - - outline = OutlineFrame() - outer.addWidget(outline) - - inner = QVBoxLayout(outline) - inner.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) - - inner.addStretch() - - self._msg_label = QLabel("") - self._msg_label.setStyleSheet( - "color: #F5F0E6; font: 48pt Montserrat;" - "background: transparent; border: none;" - ) - self._msg_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) - self._msg_label.setWordWrap(True) - inner.addWidget(self._msg_label) - - inner.addStretch() - - def display(self, message): - self._msg_label.setText(message) - self.controller.show_frame(TransitionScreen) diff --git a/src/views/user_welcome.py b/src/views/user_welcome.py deleted file mode 100644 index bdd977b..0000000 --- a/src/views/user_welcome.py +++ /dev/null @@ -1,80 +0,0 @@ -from PyQt6.QtWidgets import QVBoxLayout, QLabel -from PyQt6.QtCore import Qt, QTimer -from .base import Screen -from .components.outline_frame import OutlineFrame -from .components.theme import INNER_MARGIN, OUTER_MARGIN - - -class UserWelcome(Screen): - def _build(self, controller): - self._last_name = None - self._active_labels: set = set() - - outer = QVBoxLayout(self) - outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) - outer.setSpacing(0) - - outline = OutlineFrame() - outer.addWidget(outline) - - inner = QVBoxLayout(outline) - inner.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) - inner.setSpacing(0) - - inner.addStretch() - - self._msg_label = QLabel("Welcome back") - self._msg_label.setStyleSheet( - "color: #F5F0E6; font: 38pt Montserrat;" - "background: transparent; border: none;" - ) - self._msg_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) - inner.addWidget(self._msg_label) - - inner.addSpacing(8) - - self._names_layout = QVBoxLayout() - self._names_layout.setContentsMargins(0, 0, 0, 0) - self._names_layout.setSpacing(0) - inner.addLayout(self._names_layout) - - inner.addStretch() - - def on_hide(self): - self._active_labels.clear() - while self._names_layout.count(): - item = self._names_layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() - self._msg_label.setText("Welcome back") - self._last_name = None - - def display_name(self, name, message="Welcome back"): - if name == self._last_name: - return - - self._last_name = name - self._msg_label.setText(message) - self.controller.show_frame(UserWelcome) - - label = QLabel(name) - label.setStyleSheet( - "color: #F5F0E6; font: bold 70pt Montserrat;" - "background: transparent; border: none;" - ) - label.setAlignment(Qt.AlignmentFlag.AlignHCenter) - self._names_layout.addWidget(label) - self._active_labels.add(label) - - QTimer.singleShot(3000, lambda: self._remove_name(label)) - - def _remove_name(self, label): - if label not in self._active_labels: - return - self._active_labels.discard(label) - self._names_layout.removeWidget(label) - label.deleteLater() - - if not self._active_labels: - self._last_name = None - self.controller.back_to_main() diff --git a/src/window.py b/src/window.py index c1e6abb..e67f7ca 100644 --- a/src/window.py +++ b/src/window.py @@ -1,136 +1,105 @@ +import logging +from collections.abc import Callable from pathlib import Path -from PyQt6.QtWidgets import QMainWindow, QWidget, QStackedWidget, QLabel, QVBoxLayout -from PyQt6.QtGui import QFontDatabase, QPainter, QPixmap, QColor -from PyQt6.QtCore import QTimer, Qt -import notifier +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QFontDatabase, QPainter, QPixmap, QColor, QPaintEvent, QKeyEvent +from PyQt6.QtWidgets import QMainWindow, QStackedWidget, QApplication, QWidget, QVBoxLayout +from pyqttoast import Toast, ToastPosition, ToastPreset -ASSETS_PATH = Path(__file__).parent / "assets" / "shared" +from misc.asset import Asset +from misc.global_config import config +from misc.global_context import context +from ui.misc.error_overlay import ErrorOverlay +from ui.components.label import title_label +from ui.theme import app_font -class _RootWidget(QWidget): +class MainWindow(QMainWindow): + main_thread_dispatcher = pyqtSignal(object) - def __init__(self, parent=None): - super().__init__(parent) - bg_path = ASSETS_PATH / "background_main.png" - self._bg = QPixmap(str(bg_path)) if bg_path.exists() else QPixmap() - - def paintEvent(self, event): - painter = QPainter(self) - painter.fillRect(self.rect(), QColor("#153246")) - if not self._bg.isNull(): - x = (self.width() - self._bg.width()) // 2 - y = (self.height() - self._bg.height()) // 2 - painter.drawPixmap(x, y, self._bg) - - -class CheckInWindow(QMainWindow): - def __init__(self): + def __init__(self) -> None: super().__init__() - self.setWindowTitle("Check-In") - self.setFixedSize(1280, 720) + self.main_thread_dispatcher.connect(lambda fn: fn()) + self.setWindowTitle("Check In") + self.setFixedSize(config().SCREEN_WIDTH, config().SCREEN_HEIGHT) - fonts_dir = Path(__file__).parent.parent / "fonts" - if fonts_dir.exists(): - for font_file in fonts_dir.glob("*.ttf"): - QFontDatabase.addApplicationFont(str(font_file)) + if config().DEV_MODE: + # TODO: this is just some temporary code that opens the ui on the screen I want it to + self.setGeometry(QApplication.screens()[1].geometry()) - self.central = _RootWidget() - self.setCentralWidget(self.central) + fonts_dir = Path(Asset.FONTS_DIR.get_path()) + for font_file in fonts_dir.glob("*.ttf"): + QFontDatabase.addApplicationFont(str(font_file)) - self.stacked = QStackedWidget(self.central) - self.stacked.setGeometry(0, 0, 1280, 720) - self.stacked.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - self.stacked.setStyleSheet("background: transparent;") - - self._error = QWidget(self.central) - self._error.setGeometry(0, 0, 1280, 720) - self._error.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - self._error.setStyleSheet("background-color: rgba(0, 0, 0, 110);") - self._error.hide() - layout = QVBoxLayout(self._error) - layout.setContentsMargins(120, 120, 120, 120) - layout.setSpacing(20) - layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._error_heading = QLabel("ERROR", self._error) - self._error_heading.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._error_heading.setStyleSheet("background: transparent; color: #FF6B6B; font: bold 28pt Montserrat; letter-spacing: 6px;") - self._error_title = QLabel("", self._error) - self._error_title.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._error_title.setWordWrap(True) - self._error_title.setStyleSheet("background: transparent; color: #F5F0E6; font: bold 40pt Montserrat;") - self._error_detail = QLabel("", self._error) - self._error_detail.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop) - self._error_detail.setWordWrap(True) - self._error_detail.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - self._error_detail.setStyleSheet( - "background-color: rgba(0, 0, 0, 160);" - "color: #E6E1D6;" - "font: 11pt 'Menlo', 'Monaco', 'Courier New', monospace;" - "border: 1px solid rgba(255, 255, 255, 40);" - "border-radius: 6px;" - "padding: 14px 18px;" - ) - self._error_detail.setFixedWidth(900) - self._error_countdown = QLabel("", self._error) - self._error_countdown.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._error_countdown.setStyleSheet("background: transparent; color: #F5F0E6; font: 16pt Montserrat;") - layout.addWidget(self._error_heading) - layout.addWidget(self._error_title) - layout.addWidget(self._error_detail, alignment=Qt.AlignmentFlag.AlignHCenter) - layout.addWidget(self._error_countdown) - - self._retry_timer = QTimer(self) - self._retry_timer.setInterval(1000) - self._retry_timer.timeout.connect(self._tick_retry) - self._retry_remaining = 0 - self._retry_callback = None - - self._escape_handler = None - - def show_error(self, title, detail, *, retry_in=None, on_retry=None): - self._error_title.setText(title) - self._error_detail.setText(detail) - self._retry_timer.stop() - self._retry_callback = on_retry - if retry_in is not None and on_retry is not None: - self._retry_remaining = int(retry_in) - self._error_countdown.setText(f"Retrying in {self._retry_remaining}s…") - self._error_countdown.show() - self._retry_timer.start() - else: - self._error_countdown.clear() - self._error_countdown.hide() - self._error.show() - self._error.raise_() - notifier.notify_critical(title, detail) - - def hide_error(self): - self._retry_timer.stop() - self._retry_callback = None - self._error.hide() - notifier.notify_resolved() - - def is_error_visible(self): - return self._error.isVisible() + self.central = QStackedWidget() + self.setCentralWidget(self.central) - def _tick_retry(self): - self._retry_remaining -= 1 - if self._retry_remaining <= 0: - self._retry_timer.stop() - self._error_countdown.setText("Retrying…") - cb = self._retry_callback - self._retry_callback = None - if cb: - cb() - else: - self._error_countdown.setText(f"Retrying in {self._retry_remaining}s…") + self._error = ErrorOverlay(self.central) + + Toast.setPositionRelativeToWidget(self.central) + Toast.setPosition(ToastPosition.BOTTOM_RIGHT) + Toast.setMaximumOnScreen(3) + + # simple widget to display booting text when booting + self._boot = QWidget(self.central) + self._boot.setGeometry(0, 0, config().SCREEN_WIDTH, config().SCREEN_HEIGHT) + boot_layout = QVBoxLayout(self._boot) + boot_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + boot_layout.addWidget(title_label("Booting..."), alignment=Qt.AlignmentFlag.AlignHCenter) + + self.showFullScreen() + + # force process ui events before main loop starts to render "booting" screen + QApplication.processEvents() + + logging.info("main window initialized") + + def on_finish_boot(self) -> None: + self._boot.hide() + + def show_error( + self, title: + str, detail: + str, + *, + retry_in: int | None = None, + on_retry: Callable[[], None] | None = None + ) -> None: + self._boot.hide() + self._error.show_error(title, detail, retry_in=retry_in, on_retry=on_retry) + + def hide_error(self) -> None: + self._error.hide_error() + + def show_toast_async(self, title: str, text: str = "", toast_preset: ToastPreset = ToastPreset.INFORMATION) -> None: + self.main_thread_dispatcher.emit(lambda: self._show_toast(title, text, toast_preset)) + + def _show_toast(self, title: str, text: str = "", toast_preset: ToastPreset = ToastPreset.INFORMATION) -> None: + toast = Toast(self) + toast.setTitle(title) + if text: + toast.setText(text) + toast.applyPreset(toast_preset) + toast.setDuration(7_000) + toast.setShowCloseButton(False) + toast.setBorderRadius(10) + toast.setMaximumWidth(400) + toast.setTitleFont(app_font(18, bold=True)) + toast.setTextFont(app_font(14)) + toast.setResetDurationOnHover(False) + toast.show() + + def paintEvent(self, event: QPaintEvent | None) -> None: + painter = QPainter(self) + painter.fillRect(self.rect(), QColor("#153246")) + painter.drawPixmap(0, 0, QPixmap(Asset.BACKGROUND.get_path())) - def set_escape_handler(self, fn): - self._escape_handler = fn + def is_error_visible(self) -> bool: + return self._error.isVisible() - def keyPressEvent(self, event): - if event.key() == Qt.Key.Key_Escape and self._escape_handler: - self._escape_handler() + def keyPressEvent(self, event: QKeyEvent | None) -> None: + if event and event.key() == Qt.Key.Key_Escape: + context().navigation_controller.reset_check_in_session() else: super().keyPressEvent(event)