diff --git a/README.md b/README.md index b7da231..49bead4 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ | 📋 **Theo dõi deadline** | Lấy bài tập, quiz, điểm danh từ `courses.ut.edu.vn` và `thnn.ut.edu.vn` qua Moodle WS hoặc phiên web an toàn khi site không cấp WS | | 📊 **Theo dõi điểm** | Giám sát thay đổi điểm theo thời gian thực, thông báo khi có điểm mới | | 🔔 **Cảnh báo thông minh** | Phân loại `Khẩn cấp` · `Sắp hạn` · `An toàn` · `Quá hạn` | -| 📅 **Lịch học** | Xem lịch học theo tuần với deadline trực quan | +| 📅 **Lịch học hôm nay** | Đồng bộ lớp học từ UTH Portal với giờ học, tiết, phòng, cơ sở và trạng thái theo thời gian thực | | ⚡ **Hiệu suất cao** | Startup ~4s, parallel API, grade N+1 optimization | | 📱 **Đa nền tảng** | Windows MSI/EXE · Android APK · iOS IPA · Web browser | | 🎨 **6 Theme** | Midnight Blue · Ocean Teal · Sakura Pink · Nord Frost · Monokai Pro · Solarized Dark | diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 0c6a222..1d5dde8 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -38,8 +38,9 @@ the local native-fault file. It also does not upload a name, email address, username, account ID, password, token, sesskey, cookie, MoodleSession, Authorization value, absolute home path, -course name, activity title, submission, grade, file content, or any other -Moodle data. Unknown fields are rejected before a report is written to disk. +course name, activity title, class schedule, room, campus, submission, grade, +file content, or any other Moodle/Portal data. Unknown fields are rejected +before a report is written to disk. ## Local files, limits, and deletion diff --git a/docs/api/portal.md b/docs/api/portal.md index 1f4f32c..6ae1428 100644 --- a/docs/api/portal.md +++ b/docs/api/portal.md @@ -3,9 +3,10 @@ > **Status**: Maintained reference. Captures and credentials must remain outside > Git; only redacted endpoint contracts belong in this document. -> **Source**: `https://portal.ut.edu.vn` +> **Frontend**: `https://portal.ut.edu.vn` +> **API base**: `https://portal-publicapp.ut.edu.vn/api/v1` > **Method**: Chrome DevTools network traffic capture (live session) -> **Date**: 2026-06-22 +> **Last verified**: 2026-08-11 > **Auth**: JWT Bearer Token (HS256, 30-day expiry) > **SPA Framework**: React Router > **Backend**: Spring Boot (Java) — inferred from response headers @@ -36,8 +37,7 @@ ### `POST /api/v1/user/login` -**Query Params:** -- `g-recaptcha-response` (string, required) — reCAPTCHA v2/v3 token +**Query Params:** none in the verified native-client flow. **Request Headers:** ``` @@ -95,7 +95,9 @@ Referer: https://portal.ut.edu.vn/ **Notes:** - Token hết hạn sau **30 ngày** (`exp - iat = 2592000s`) - `body` field chứa role: `"sv"` = sinh viên, có thể có `"gv"` = giảng viên -- reCAPTCHA token có thể lấy từ browser session hoặc reCAPTCHA Enterprise API +- Live native-client verification on 2026-08-11 succeeded without a cookie, + browser token, or reCAPTCHA query value. Treat browser-only challenge fields + as capability-driven rather than part of the native contract. - Algorithm: HS256 (HMAC-SHA256) --- @@ -260,6 +262,45 @@ Referer: https://portal.ut.edu.vn/ ## 📅 4. Lịch học (Schedule) +### `GET /api/v1/lichhoc/ngay?date={YYYY-MM-DD}` + +**Description:** Nguồn chính xác cho lịch học của một ngày. UTHelper dùng endpoint +này cho card "Lịch học hôm nay" vì response có trực tiếp môn, giờ bắt đầu/kết +thúc, tiết, phòng, cơ sở, ghi chú và trạng thái tạm ngưng. + +**Authentication:** `Authorization: Bearer {jwt_token}`. Capture và live probe +đều thành công mà không cần cookie hoặc `X-Browser-Token`. + +**Redacted response contract:** + +```json +{ + "success": true, + "status": 200, + "body": [ + { + "id": 123, + "tenMonHoc": "Tên môn học", + "maLopHocPhan": "Mã lớp học phần", + "tenPhong": "Phòng học", + "coSoToDisplay": "Cơ sở", + "tuTiet": 1, + "denTiet": 3, + "tuGio": "07:00", + "denGio": "09:30", + "isTamNgung": false, + "ghiChu": "" + } + ] +} +``` + +### `GET /api/v1/lichhoc/tuan?date={YYYY-MM-DD}` + +**Description:** Lịch tuần; thêm `ngayBatDauHoc` và `giangVien` (có thể null). +Endpoint này phù hợp cho màn hình tuần, nhưng card hôm nay ưu tiên `/ngay` để +tránh lọc và suy diễn ngày ở client. + ### `GET /api/v1/lichhoc/thang?date={YYYY-MM-DD}` **Description:** Lịch học theo tháng (dùng ngày đầu tháng) @@ -297,6 +338,9 @@ Referer: https://portal.ut.edu.vn/ **Params:** `date` — Ngày bất kỳ trong tuần, format `YYYY-MM-DD` +Legacy-compatible variant of the weekly contract; the verified backend also +exposes the shorter `/lichhoc/tuan` route. + ### `GET /api/v1/lichhoc/songayhoc` **Description:** Tổng số ngày đã học (attendance tracking) diff --git a/docs/architecture/refactoring-log.md b/docs/architecture/refactoring-log.md index 4fb0962..18ea671 100644 --- a/docs/architecture/refactoring-log.md +++ b/docs/architecture/refactoring-log.md @@ -1289,3 +1289,82 @@ rg -n "Wait-Process" scripts/test_windows_single_instance_e2e.ps1 background-extension suite passes **1291 tests with 25 skipped in 27.35 seconds**; repository-wide Ruff, bytecode compilation, and `git diff --check` pass. + +## Quiz attempt badge normalization (2026-08-11) + +- Moodle quiz detail responses now distinguish an authoritative empty attempt + list from an unavailable attempt endpoint. An empty list maps to `Chưa làm`; + finished, in-progress, overdue, abandoned, and unknown non-empty attempt + states receive explicit normalized progress states. +- `DataOrchestrator` promotes that normalized state to the existing activity + status field, while the dashboard badge presenter keeps assignment wording + unchanged and renders quiz-specific labels. +- A live read-only check against the reported quiz confirmed the original API + response contained zero attempts while the previous UI retained `unknown`. + Regression coverage now includes empty/error responses, all known attempt + states, data-layer promotion, and badge wording. +- Verification: 61 focused adapter/orchestrator/presenter/UI tests pass. Ruff, + bytecode compilation, and `git diff --check` pass. The full local suite has + **1293 passed and 25 skipped**; its seven failures are the same baseline + environment gaps for the optional iOS bridge and diagnostics SDK. + +## Native startup frame and Portal daily schedule (2026-08-11) + +- Windows now starts Flet with the native host hidden, applies the final + 420×720 geometry, and publishes a branded splash with an animated progress + ring before importing and constructing the full dashboard. The prior visible + 1280×720 empty Flutter frame is no longer part of the startup lifecycle. +- "Lịch học hôm nay" no longer filters Moodle quiz/assignment deadlines. A + separate Portal boundary authenticates in memory and reads the verified + `/lichhoc/ngay` contract from `portal-publicapp.ut.edu.vn`, preserving subject, + class code, start/end time, periods, room, campus, cancellation, and note. +- An account-scoped atomic cache distinguishes an authoritative empty day from + missing data. The coordinator fetches when today's cache is absent at app + open and refreshes while the app is alive at 00:00 and 06:00. The disclosure + shows explicit loading/auth/error/empty states and derives upcoming, + in-progress, finished, or cancelled status from the current local time. +- Live verification returned two current-day sessions with complete subject, + time, room, and campus data and round-tripped the redacted schedule cache. + Native Windows smoke observed 47 consecutive first-start frames at exactly + 420×720 and captured the branded splash before the dashboard; a second native + smoke rendered both live sessions in the expanded card. Smoke-owned scripts, + appdata, logs, and screenshots were deleted before commit. +- The live account identifier and password prefix previously embedded as grep + markers in an archived implementation plan were replaced with synthetic + markers; no supplied credential or live room/class identifier remains in the + tracked tree. +- Verification: **1303 tests passed with 25 skipped**; `ruff check src tests` + and `git diff --check` passed. + +## Windows Burn installer branding (2026-08-11) + +- The WiX Bundle now uses the application ICO as `IconSourceFile`, which gives + the generated bootstrapper EXE, Explorer entry, Programs and Features entry, + and installer title bar the UTHelper identity instead of the WiX default. +- WixStdBA now uses the application PNG as `LogoFile`, replacing its generic + content logo. The application ICO was rebuilt as a transparent square, + multi-resolution resource with 16, 24, 32, 48, 64, 128, and 256 pixel frames. +- A regression contract checks both WiX bindings and the required Windows shell + icon sizes. A real local WiX 7 Bundle build completed without warnings; shell + resource extraction and a captured WixStdBA window confirmed the exterior + EXE icon, title-bar icon, and content logo. +- Verification: **1284 tests passed with 25 skipped** under the same source paths + as CI; `ruff check src tests` and `git diff --check` passed. + +## v2.3.0 minor release preparation (2026-08-11) + +- The released `v2.2.12` tree was compared with the integrated `develop` + branch under Semantic Versioning 2.0.0. This release adds the user-facing + Portal daily schedule with startup/00:00/06:00 refresh behavior, explicit + schedule loading states, and room/campus/session status presentation. +- The remaining changes are backward-compatible fixes: branded Windows + installer surfaces, a correctly sized branded startup frame, and normalized + quiz attempt badges. No public contract was removed or incompatibly changed. + The new compatible feature therefore advances the minor version to `2.3.0` + rather than using another patch or a major release. +- `pyproject.toml` remains the sole authored version source. Release metadata + resolves `v2.3.0` to monotonic build number `2003000`; runtime and native + package versions continue to be generated from that source. +- Verification: the complete suite passes **1321 tests with 25 skipped**; + repository-wide Ruff, bytecode compilation, release tag/build metadata + validation, and `git diff --check` pass. diff --git a/docs/archive/implementation-plans/2026-08-04-moodle-submission-workflow.md b/docs/archive/implementation-plans/2026-08-04-moodle-submission-workflow.md index b6ec3d9..b33a07b 100644 --- a/docs/archive/implementation-plans/2026-08-04-moodle-submission-workflow.md +++ b/docs/archive/implementation-plans/2026-08-04-moodle-submission-workflow.md @@ -769,7 +769,7 @@ contract. - [ ] **Step 6: Scan for secrets/authenticated URLs and commit** -Run: `rg -n "NoBoi|080206011901|wstoken=|token=[A-Za-z0-9]{10,}" src tests docs REFAC_KNOWLEDGE.md` +Run: `rg -n "KNOWN_PASSWORD_MARKER|KNOWN_STUDENT_ID|wstoken=|token=[A-Za-z0-9]{10,}" src tests docs` Expected: no newly introduced credential or authenticated URL. Existing generic parameter names such as `wstoken` in source are reviewed manually and are not secret diff --git a/docs/guides/windows-packaging.md b/docs/guides/windows-packaging.md index f897b29..0216f8e 100644 --- a/docs/guides/windows-packaging.md +++ b/docs/guides/windows-packaging.md @@ -65,6 +65,13 @@ machine-scoped WiX 7 MSI and a Burn bootstrapper EXE. Review and accept the WiX 7 OSMF EULA before setting the required process variable; the build refuses to restore or execute WiX otherwise. +Burn branding is sourced from the application assets rather than duplicated in +the packaging directory. `src/assets/icon.ico` supplies the executable, +Explorer, Programs and Features, and title-bar icon through `IconSourceFile`; +`src/assets/icon.png` supplies the WixStdBA content logo through `LogoFile`. +Keep the ICO multi-resolution, including 16, 32, 48, and 256 pixel square +frames, so Windows can select a native frame at each shell DPI. + ```powershell $env:WIX_EULA_ACCEPTED = "wix7" .\scripts\build_installer.ps1 -BundleDir build\windows -OutputDir dist diff --git a/packaging/windows/Bundle.wxs b/packaging/windows/Bundle.wxs index e1d8fdc..4a0832e 100644 --- a/packaging/windows/Bundle.wxs +++ b/packaging/windows/Bundle.wxs @@ -1,11 +1,13 @@ diff --git a/packaging/windows/UTHelper.Bundle.wixproj b/packaging/windows/UTHelper.Bundle.wixproj index 3f6df72..96cbcf9 100644 --- a/packaging/windows/UTHelper.Bundle.wixproj +++ b/packaging/windows/UTHelper.Bundle.wixproj @@ -4,7 +4,7 @@ false x64 UTHelper-Setup-$(Version) - Version=$(Version);MsiPath=$(MsiPath) + Version=$(Version);MsiPath=$(MsiPath);BundleIconPath=$(MSBuildProjectDirectory)\..\..\src\assets\icon.ico;BundleLogoPath=$(MSBuildProjectDirectory)\..\..\src\assets\icon.png diff --git a/pyproject.toml b/pyproject.toml index e91e902..e13cd87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "uthelper" -version = "2.2.12" +version = "2.3.0" description = "UTHelper — Ứng dụng theo dõi bài tập và deadline UTH E-learning" readme = "README.md" requires-python = ">=3.11" diff --git a/src/assets/icon.ico b/src/assets/icon.ico index 6431549..bbe98b1 100644 Binary files a/src/assets/icon.ico and b/src/assets/icon.ico differ diff --git a/src/config.py b/src/config.py index c5592fb..926662c 100644 --- a/src/config.py +++ b/src/config.py @@ -204,7 +204,7 @@ class Settings(BaseModel): # Địa chỉ mấy trang web của trường mình MOODLE_BASE_URL: str = "https://courses.ut.edu.vn" MOODLE_LOGIN_URL: str = "https://courses.ut.edu.vn/login/index.php" - PORTAL_API_BASE: str = "https://portal.ut.edu.vn/api/v1" + PORTAL_API_BASE: str = "https://portal-publicapp.ut.edu.vn/api/v1" # Cài đặt chung của ứng dụng THEME: str = Field(default="midnight_blue", description="Theme preset: midnight_blue, ocean_teal, sakura_pink, nord_frost, monokai_pro, solarized_dark") diff --git a/src/core/data_orchestrator.py b/src/core/data_orchestrator.py index 238898b..c53d1b7 100644 --- a/src/core/data_orchestrator.py +++ b/src/core/data_orchestrator.py @@ -584,6 +584,8 @@ def _fetch_detail_via_ws(self, activity_data: Dict[str, Any]) -> Optional[Dict[s details['open_time'] = ws_details['open_time'] if ws_details.get('quiz_info'): details['quiz_info'] = ws_details['quiz_info'] + if ws_details.get('quiz_attempt_status'): + details['quiz_attempt_status'] = ws_details['quiz_attempt_status'] if ws_details.get('attempts_allowed'): details['attempts_allowed'] = ws_details['attempts_allowed'] if ws_details.get('time_limit'): @@ -593,7 +595,10 @@ def _fetch_detail_via_ws(self, activity_data: Dict[str, Any]) -> Optional[Dict[s # Cập nhật submission_status ở top level nếu có status_data = ws_details.get('status_data', {}) - if 'Trạng thái nộp bài' in status_data: + quiz_attempt_status = ws_details.get('quiz_attempt_status') + if activity_type == 'quiz' and quiz_attempt_status: + result['submission_status'] = quiz_attempt_status + elif 'Trạng thái nộp bài' in status_data: result['submission_status'] = status_data['Trạng thái nộp bài'] elif 'Trạng thái' in status_data: result['submission_status'] = status_data['Trạng thái'] diff --git a/src/core/portal_client.py b/src/core/portal_client.py new file mode 100644 index 0000000..99461c4 --- /dev/null +++ b/src/core/portal_client.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import gzip +import json +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Callable +from datetime import date +from typing import Any + +from core.today_schedule import ( + ClassSession, + ScheduleAuthenticationError, + parse_portal_day, +) + + +class PortalError(RuntimeError): + """Base error for the authenticated UTH Portal boundary.""" + + +class PortalAuthenticationError(PortalError, ScheduleAuthenticationError): + """Portal rejected the supplied student identity or returned no token.""" + + +class PortalProtocolError(PortalError): + """Portal returned a response outside the verified JSON contract.""" + + +class _PortalHttpError(PortalError): + def __init__(self, status: int): + super().__init__(f"Portal HTTP {status}") + self.status = status + + +class PortalClient: + """Small stateless HTTP adapter for Portal authentication and daily classes. + + Credentials are supplied for an individual fetch and are never retained. + The short-lived Bearer token is kept in memory only for the current client. + """ + + def __init__( + self, + base_url: str, + *, + timeout: float = 15, + opener: Callable[..., Any] = urllib.request.urlopen, + ) -> None: + self._base_url = base_url.rstrip("/") + self._timeout = max(1.0, float(timeout)) + self._opener = opener + self._token = "" + + def clear_token(self) -> None: + self._token = "" + + def _request_json( + self, + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + token: str = "", + query: dict[str, str] | None = None, + ) -> dict[str, Any]: + url = f"{self._base_url}/{path.lstrip('/')}" + if query: + url = f"{url}?{urllib.parse.urlencode(query)}" + raw_body = json.dumps(body).encode("utf-8") if body is not None else None + headers = { + "Accept": "application/json", + "Accept-Encoding": "gzip", + "User-Agent": "UTHelper/PortalSchedule", + } + if raw_body is not None: + headers["Content-Type"] = "application/json" + if token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request( + url, + data=raw_body, + headers=headers, + method=method, + ) + try: + with self._opener(request, timeout=self._timeout) as response: + raw = response.read() + if response.headers.get("Content-Encoding") == "gzip": + raw = gzip.decompress(raw) + except urllib.error.HTTPError as exc: + raise _PortalHttpError(exc.code) from exc + except (urllib.error.URLError, TimeoutError, ConnectionError) as exc: + raise PortalError("Portal network request failed") from exc + try: + payload = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + raise PortalProtocolError("Portal response was not JSON") from exc + if not isinstance(payload, dict): + raise PortalProtocolError("Portal response envelope was not an object") + return payload + + def _login(self, username: str, password: str) -> str: + if not username or not password: + raise PortalAuthenticationError("Portal credentials are unavailable") + payload = self._request_json( + "POST", + "user/login", + body={"username": username, "password": password}, + ) + token = payload.get("token") if payload.get("success") else "" + if not isinstance(token, str) or not token: + self.clear_token() + raise PortalAuthenticationError("Portal login was rejected") + self._token = token + return token + + def fetch_day( + self, target_date: date, username: str, password: str + ) -> tuple[ClassSession, ...]: + token = self._token or self._login(username, password) + try: + payload = self._request_json( + "GET", + "lichhoc/ngay", + token=token, + query={"date": target_date.isoformat()}, + ) + except _PortalHttpError as exc: + if exc.status != 401: + raise + token = self._login(username, password) + payload = self._request_json( + "GET", + "lichhoc/ngay", + token=token, + query={"date": target_date.isoformat()}, + ) + refreshed_token = payload.get("token") + if isinstance(refreshed_token, str) and refreshed_token: + self._token = refreshed_token + try: + return parse_portal_day(payload, target_date) + except ValueError as exc: + raise PortalProtocolError("Portal schedule contract was invalid") from exc diff --git a/src/core/today_schedule.py b/src/core/today_schedule.py new file mode 100644 index 0000000..f5588b9 --- /dev/null +++ b/src/core/today_schedule.py @@ -0,0 +1,356 @@ +from __future__ import annotations + +import asyncio +import hashlib +import logging +from collections.abc import Callable +from dataclasses import dataclass +from datetime import date, datetime, time, timedelta +from enum import Enum +from pathlib import Path +from typing import Any + +from core.safe_file_io import SafeFileIO + +logger = logging.getLogger(__name__) + + +class SchedulePhase(str, Enum): + UPCOMING = "upcoming" + IN_PROGRESS = "in_progress" + FINISHED = "finished" + CANCELLED = "cancelled" + + +class ScheduleLoadStatus(str, Enum): + IDLE = "idle" + LOADING = "loading" + READY = "ready" + AUTH_REQUIRED = "auth_required" + ERROR = "error" + + +class ScheduleAuthenticationError(RuntimeError): + """Credential rejection recognized by schedule coordinators.""" + + +@dataclass(frozen=True) +class ClassSession: + session_id: str + subject: str + course_code: str + start_at: datetime + end_at: datetime + room: str + campus: str + period_start: int | None + period_end: int | None + cancelled: bool + note: str + + def phase_at(self, now: datetime) -> SchedulePhase: + if self.cancelled: + return SchedulePhase.CANCELLED + if now < self.start_at: + return SchedulePhase.UPCOMING + if now < self.end_at: + return SchedulePhase.IN_PROGRESS + return SchedulePhase.FINISHED + + def to_dict(self) -> dict[str, Any]: + return { + "session_id": self.session_id, + "subject": self.subject, + "course_code": self.course_code, + "start_at": self.start_at.isoformat(), + "end_at": self.end_at.isoformat(), + "room": self.room, + "campus": self.campus, + "period_start": self.period_start, + "period_end": self.period_end, + "cancelled": self.cancelled, + "note": self.note, + } + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> ClassSession: + return cls( + session_id=str(payload.get("session_id", "")), + subject=str(payload.get("subject", "")), + course_code=str(payload.get("course_code", "")), + start_at=datetime.fromisoformat(str(payload["start_at"])), + end_at=datetime.fromisoformat(str(payload["end_at"])), + room=str(payload.get("room", "")), + campus=str(payload.get("campus", "")), + period_start=_optional_int(payload.get("period_start")), + period_end=_optional_int(payload.get("period_end")), + cancelled=bool(payload.get("cancelled", False)), + note=str(payload.get("note", "")), + ) + + +@dataclass(frozen=True) +class TodayScheduleSnapshot: + schedule_date: date + sessions: tuple[ClassSession, ...] + fetched_at: datetime + + +@dataclass(frozen=True) +class TodayScheduleViewState: + status: ScheduleLoadStatus + snapshot: TodayScheduleSnapshot | None = None + from_cache: bool = False + error_code: str = "" + + +def _optional_int(value: Any) -> int | None: + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _parse_clock(value: Any) -> time | None: + if not isinstance(value, str): + return None + normalized = value.strip() + for pattern in ("%H:%M", "%H:%M:%S"): + try: + return datetime.strptime(normalized, pattern).time() + except ValueError: + continue + return None + + +def parse_portal_day( + payload: Any, target_date: date +) -> tuple[ClassSession, ...]: + if not isinstance(payload, dict) or payload.get("success") is not True: + raise ValueError("unsuccessful Portal schedule envelope") + rows = payload.get("body") + if not isinstance(rows, list): + raise ValueError("Portal schedule body is not a list") + sessions: list[ClassSession] = [] + for row in rows: + if not isinstance(row, dict): + continue + start_clock = _parse_clock(row.get("tuGio")) + end_clock = _parse_clock(row.get("denGio")) + subject = str(row.get("tenMonHoc") or row.get("nameToDisplay") or "").strip() + if not subject or start_clock is None or end_clock is None: + continue + start_at = datetime.combine(target_date, start_clock) + end_at = datetime.combine(target_date, end_clock) + if end_at <= start_at: + end_at += timedelta(days=1) + session_id = str( + row.get("id") + or f"{row.get('maLopHocPhan', '')}|{start_at.isoformat()}|{subject}" + ) + sessions.append( + ClassSession( + session_id=session_id, + subject=subject, + course_code=str(row.get("maLopHocPhan") or "").strip(), + start_at=start_at, + end_at=end_at, + room=str(row.get("tenPhong") or row.get("roomToDisplay") or "").strip(), + campus=str(row.get("coSoToDisplay") or "").strip(), + period_start=_optional_int(row.get("tuTiet")), + period_end=_optional_int(row.get("denTiet")), + cancelled=bool(row.get("isTamNgung", False)), + note=str(row.get("ghiChu") or row.get("noteToDisplay") or "").strip(), + ) + ) + sessions.sort(key=lambda item: (item.start_at, item.subject.casefold())) + return tuple(sessions) + + +class TodayScheduleCache: + SCHEMA_VERSION = 1 + + def __init__(self, cache_dir: Path | None = None, namespace: str = "") -> None: + if cache_dir is None: + from config import _USER_DATA_DIR + + cache_dir = _USER_DATA_DIR + digest = hashlib.sha256(namespace.strip().casefold().encode("utf-8")).hexdigest()[:12] + self._path = cache_dir / f"today_schedule_{digest}.json" + + def load_for(self, target_date: date) -> TodayScheduleSnapshot | None: + payload = SafeFileIO.read_json_safe(self._path, dict) + if not isinstance(payload, dict) or payload.get("version") != self.SCHEMA_VERSION: + return None + if payload.get("schedule_date") != target_date.isoformat(): + return None + rows = payload.get("sessions") + if not isinstance(rows, list): + return None + try: + sessions = tuple( + ClassSession.from_dict(row) for row in rows if isinstance(row, dict) + ) + fetched_at = datetime.fromisoformat(str(payload["fetched_at"])) + except (KeyError, TypeError, ValueError): + logger.warning("Today schedule cache is invalid") + return None + return TodayScheduleSnapshot(target_date, sessions, fetched_at) + + def save(self, snapshot: TodayScheduleSnapshot) -> bool: + payload = { + "version": self.SCHEMA_VERSION, + "schedule_date": snapshot.schedule_date.isoformat(), + "fetched_at": snapshot.fetched_at.isoformat(), + "count": len(snapshot.sessions), + "sessions": [session.to_dict() for session in snapshot.sessions], + } + return bool(SafeFileIO.write_json_atomic(self._path, payload)) + + +def next_schedule_refresh_at(now: datetime) -> datetime: + midnight = datetime.combine(now.date(), time(0, 0)) + six_am = datetime.combine(now.date(), time(6, 0)) + if now < six_am: + return six_am + return midnight + timedelta(days=1) + + +class TodayScheduleCoordinator: + """Own daily Portal cache, startup fetch, and 00:00/06:00 refreshes.""" + + def __init__( + self, + *, + fetch_day: Callable[[date, str, str], tuple[ClassSession, ...]], + cache: TodayScheduleCache, + credentials_provider: Callable[[], tuple[str, str]], + state_sink: Callable[[TodayScheduleViewState], None], + now_provider: Callable[[], datetime] = datetime.now, + ) -> None: + self._fetch_day = fetch_day + self._cache = cache + self._credentials_provider = credentials_provider + self._state_sink = state_sink + self._now_provider = now_provider + self._guard = asyncio.Lock() + self._wake_event = asyncio.Event() + self._closed = False + self.state = TodayScheduleViewState(ScheduleLoadStatus.IDLE) + + def _publish(self, state: TodayScheduleViewState) -> None: + self.state = state + self._state_sink(state) + + def cached_state(self) -> TodayScheduleViewState: + snapshot = self._cache.load_for(self._now_provider().date()) + if snapshot is None: + return TodayScheduleViewState(ScheduleLoadStatus.IDLE) + return TodayScheduleViewState( + ScheduleLoadStatus.READY, + snapshot=snapshot, + from_cache=True, + ) + + async def ensure_today(self) -> TodayScheduleViewState: + cached = self._cache.load_for(self._now_provider().date()) + if cached is not None: + state = TodayScheduleViewState( + ScheduleLoadStatus.READY, + snapshot=cached, + from_cache=True, + ) + self._publish(state) + return state + return await self.refresh("startup") + + async def refresh(self, trigger: str = "manual") -> TodayScheduleViewState: + async with self._guard: + target_date = self._now_provider().date() + existing = self._cache.load_for(target_date) + # Startup and a fast first expansion can race before either task + # observes the cache. The second task re-checks under the guard and + # publishes the completed first result instead of logging in twice. + if trigger == "startup" and existing is not None: + state = TodayScheduleViewState( + ScheduleLoadStatus.READY, + snapshot=existing, + from_cache=True, + ) + self._publish(state) + return state + username, password = self._credentials_provider() + if not username or not password: + state = TodayScheduleViewState( + ScheduleLoadStatus.AUTH_REQUIRED, + snapshot=existing, + from_cache=existing is not None, + ) + self._publish(state) + return state + self._publish( + TodayScheduleViewState( + ScheduleLoadStatus.LOADING, + snapshot=existing, + from_cache=existing is not None, + ) + ) + try: + sessions = await asyncio.to_thread( + self._fetch_day, + target_date, + username, + password, + ) + snapshot = TodayScheduleSnapshot( + target_date, + tuple(sessions), + self._now_provider(), + ) + self._cache.save(snapshot) + state = TodayScheduleViewState( + ScheduleLoadStatus.READY, + snapshot=snapshot, + ) + except Exception as exc: + auth_failure = isinstance(exc, ScheduleAuthenticationError) + logger.warning( + "Portal schedule refresh failed (%s)", + "authentication" if auth_failure else "request", + ) + state = TodayScheduleViewState( + ScheduleLoadStatus.AUTH_REQUIRED if auth_failure else ScheduleLoadStatus.ERROR, + snapshot=existing, + from_cache=existing is not None, + error_code="authentication" if auth_failure else "request_failed", + ) + self._publish(state) + return state + + def replace_cache(self, cache: TodayScheduleCache) -> None: + self._cache = cache + self._wake_event.set() + + def close(self) -> None: + self._closed = True + self._wake_event.set() + + async def run(self) -> None: + try: + await self.ensure_today() + while not self._closed: + now = self._now_provider() + delay = max( + 0.0, + (next_schedule_refresh_at(now) - now).total_seconds(), + ) + self._wake_event.clear() + try: + await asyncio.wait_for(self._wake_event.wait(), timeout=delay) + except asyncio.TimeoutError: + await self.refresh("scheduled") + except asyncio.CancelledError: + self.close() + raise diff --git a/src/core/ws_functions.py b/src/core/ws_functions.py index 0fc9a45..ed4c19d 100644 --- a/src/core/ws_functions.py +++ b/src/core/ws_functions.py @@ -299,6 +299,7 @@ def get_assign_details_via_ws( 'course_full_name': str, 'open_time': str (ISO), 'quiz_info': list, + 'quiz_attempt_status': str | None, 'attempts_allowed': str, 'time_limit': str, } @@ -309,6 +310,7 @@ def get_assign_details_via_ws( 'course_full_name': '', 'open_time': None, 'quiz_info': [], + 'quiz_attempt_status': None, 'attempts_allowed': None, 'time_limit': None, } @@ -500,6 +502,12 @@ def _get_quiz_detail( # Attempts if quiz_id: attempts = get_quiz_attempts(call_api, quiz_id) + if attempts is not None: + if not attempts: + # An authoritative empty list is different from an API error: + # the student has not started this quiz yet. + details['quiz_attempt_status'] = 'not_submitted' + if attempts: for att in attempts: state = att.get('state', '') @@ -513,7 +521,13 @@ def _get_quiz_detail( # Status data last = attempts[-1] - last_state = last.get('state', '') + last_state = str(last.get('state', '') or '').lower() + details['quiz_attempt_status'] = { + 'finished': 'submitted', + 'inprogress': 'in_progress', + 'overdue': 'overdue', + 'abandoned': 'abandoned', + }.get(last_state, 'attempted') details['status_data']['Trạng thái'] = { 'finished': 'Đã hoàn thành', 'inprogress': 'Đang làm', diff --git a/src/gui/app_controller.py b/src/gui/app_controller.py index f505716..ca1f1c0 100644 --- a/src/gui/app_controller.py +++ b/src/gui/app_controller.py @@ -1,6 +1,5 @@ import os import sys -from math import pi # Patch path for direct execution / Flet preview compatibility _project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) @@ -8,7 +7,7 @@ sys.path.insert(0, _project_root) import flet as ft -from datetime import date, datetime +from datetime import datetime from core.time_utils import parse_datetime import asyncio import logging @@ -23,6 +22,13 @@ from core.data_orchestrator import DataOrchestrator from core.moodle_service import MoodleService from core.moodle_sites import moodle_site_from_origin +from core.portal_client import PortalClient +from core.today_schedule import ( + ScheduleLoadStatus, + TodayScheduleCache, + TodayScheduleCoordinator, + TodayScheduleViewState, +) from core.use_cases.submission_workflow import SubmissionWorkflow from core.activity_time_policy import ActivityTimePolicy from core.sync_coordinator import ActivitySyncCoordinator, FetchOutcome, parse_timestamp @@ -42,8 +48,8 @@ from gui.components.settings_view import SettingsView from gui.components.calendar_view import CalendarView from gui.components.grade_overview_view import GradeOverviewView +from gui.components.today_schedule_panel import TodaySchedulePanel from gui.view_manager import ViewManager -from gui.core.utils import clean_course_name, format_deadline, get_type_color, get_urgency_color from gui.controllers.startup_visibility import ( is_autostart_launch, should_hide_startup_window, @@ -172,6 +178,22 @@ def __init__( else None ) self._data_cache = DataCache(namespace=cache_namespace) + schedule_namespace = ( + f"{settings.PORTAL_API_BASE}|{settings.UTH_USERNAME}" + if settings.UTH_USERNAME + else f"{settings.PORTAL_API_BASE}|anonymous" + ) + self._portal_client = PortalClient(settings.PORTAL_API_BASE) + self._today_schedule_coordinator = TodayScheduleCoordinator( + fetch_day=self._portal_client.fetch_day, + cache=TodayScheduleCache(namespace=schedule_namespace), + credentials_provider=lambda: ( + settings.UTH_USERNAME, + settings.UTH_PASSWORD, + ), + state_sink=self._on_today_schedule_state, + ) + self._today_schedule_state = self._today_schedule_coordinator.cached_state() self._settings_identity = (settings.UTH_USERNAME, settings.UTH_PASSWORD) self._notification_milestones_snapshot = tuple( settings.NOTIFY_MILESTONES_MINUTES or [] @@ -225,6 +247,7 @@ def __init__( self._safe_run_task(self._pulse_loop_async) self._safe_run_task(self._countdown_loop_async) self._safe_run_task(self._sync_coordinator.run) + self._safe_run_task(self._today_schedule_coordinator.run) if self._android_background and self._android_background.available: self._safe_run_task(self._initialize_android_background) self._tray_balloon_shown = False # H-01: only show once @@ -241,6 +264,8 @@ async def _show_login_dialog(self): async def _on_login_success(): # UX-5: Show success snackbar after dialog closes self._show_snackbar("Đăng nhập thành công! Đang tải dữ liệu...", ft.Icons.CHECK_CIRCLE_ROUNDED, C.SAFE) + self._portal_client.clear_token() + self._safe_run_task(self._today_schedule_coordinator.refresh, "login") await self._load_data_async() await show_login_dialog(self.page, self.orchestrator, _on_login_success) @@ -678,91 +703,11 @@ def _init_banner_and_states(self): alignment=ft.Alignment(0, 0), expand=True, visible=False, ) - self._today_schedule_expanded = False - self._today_schedule_toggle_icon = ft.Icon( - ft.Icons.CHEVRON_RIGHT_ROUNDED, - size=18, - color=C.TEXT_SECONDARY, - rotate=ft.Rotate(angle=0, alignment=ft.Alignment.CENTER), - animate_rotation=ft.Animation(duration=260, curve=ft.AnimationCurve.EASE_OUT_CUBIC), - ) - self._today_schedule_count = ft.Text("", size=11, color=C.TEXT_SECONDARY, expand=True) - self._today_schedule_items_column = ft.Column(controls=[], spacing=6) - self._today_schedule_empty = ft.Container( - content=ft.Column( - controls=[ - ft.Icon(ft.Icons.CALENDAR_MONTH_ROUNDED, size=24, color=C.BORDER), - ft.Text("Không có hoạt động nào đến hạn hôm nay", size=11, color=C.TEXT_SECONDARY), - ], - horizontal_alignment=ft.CrossAxisAlignment.CENTER, - spacing=4, - ), - alignment=ft.Alignment(0, 0), - padding=ft.Padding.only(top=4, bottom=4), - ) - self._today_schedule_body = ft.Container( - content=ft.Column( - controls=[ - ft.Container(height=1, bgcolor=C.BORDER), - self._today_schedule_empty, - self._today_schedule_items_column, - ], - spacing=8, - ), - padding=ft.Padding.only(left=12, right=12, top=0, bottom=12), - visible=False, - ) - self._today_schedule_header = ft.Container( - content=ft.Row( - controls=[ - ft.Icon(ft.Icons.CALENDAR_TODAY_ROUNDED, size=18, color=C.ACCENT), - ft.Column( - controls=[ - ft.Text( - "Lịch học hôm nay", - size=13, - weight=ft.FontWeight.W_600, - color=C.TEXT_PRIMARY, - ), - self._today_schedule_count, - ], - spacing=1, - expand=True, - ), - self._today_schedule_toggle_icon, - ], - spacing=8, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ), - padding=ft.Padding.only(left=12, right=12, top=12, bottom=12), - on_click=lambda _: self._toggle_today_schedule(), - ink=True, + self._today_schedule_component = TodaySchedulePanel( + on_need_data=self._request_today_schedule, ) - self._today_schedule_bar = ft.Container( - width=3, - border_radius=ft.BorderRadius.only(top_left=10, bottom_left=10), - bgcolor=C.ACCENT, - ) - self.today_schedule_panel = ft.Container( - content=ft.Row( - controls=[ - self._today_schedule_bar, - ft.Container( - content=ft.Column( - controls=[self._today_schedule_header, self._today_schedule_body], - spacing=0, - ), - expand=True, - ), - ], - spacing=0, - ), - margin=ft.Margin(left=10, right=10, top=0, bottom=0), - bgcolor=C.SURFACE, - border=ft.Border.all(1, C.BORDER), - border_radius=10, - ) - self._refresh_today_schedule_panel([]) + self.today_schedule_panel = self._today_schedule_component.control + self._today_schedule_component.set_state(self._today_schedule_state) def _init_views_and_transitions(self): header_container = ft.Container( @@ -874,6 +819,8 @@ def _init_views_and_transitions(self): ) main_stack = ft.Stack(controls=[self.dashboard, self.calendar_view, self.grade_overview_view, self.detail_view, self.settings_view], expand=True) + # Replace the startup splash atomically after the real UI is complete. + self.page.controls.clear() if platform_utils.IS_MOBILE: self.page.add(ft.SafeArea(content=main_stack, expand=True)) else: @@ -1059,7 +1006,6 @@ def _on_course_select(e, c_name): is_empty = (len(filtered_items) == 0 and not self.loading_bar.visible) self.empty_state.visible = is_empty self.error_state.visible = False - self._refresh_today_schedule_panel(base) # P2/P6: Contextual empty state messaging if is_empty: @@ -1122,99 +1068,23 @@ def _on_course_select(e, c_name): self.cards_column.controls = render_cards self.page.update() - def _get_today_schedule_items(self, activities: list[dict] | None = None) -> list[dict]: - """Return activities that fall on today's date, sorted by deadline time.""" - source = activities if activities is not None else list(self.all_data) - today = date.today() - timed_items: list[tuple[datetime, dict]] = [] - for activity in source: - deadline_str = activity.get("deadline", "") - deadline_dt = parse_datetime(deadline_str) - if not deadline_dt or deadline_dt.year >= 2099: - continue - if deadline_dt.date() != today: - continue - timed_items.append((deadline_dt, activity)) - - timed_items.sort(key=lambda item: (item[0], item[1].get("title", ""))) - return [activity for _, activity in timed_items] - - def _make_today_schedule_item(self, activity: dict) -> ft.Container: - deadline_str = activity.get("deadline", "") - deadline_dt = parse_datetime(deadline_str) - time_text = deadline_dt.strftime("%H:%M") if deadline_dt else "Hôm nay" - course_name = clean_course_name( - activity.get("course_name", "") - or activity.get("course", "") - or activity.get("details", {}).get("course_full_name", "") - ) - title = activity.get("title", "Không có tiêu đề") - urgency_color = get_urgency_color(activity.get("urgency", "safe")) - type_color = get_type_color(activity.get("type", "other")) - - return ft.Container( - content=ft.Row( - controls=[ - ft.Container(width=3, height=34, bgcolor=type_color or urgency_color, border_radius=3), - ft.Column( - controls=[ - ft.Text( - title, - size=12, - weight=ft.FontWeight.W_600, - color=C.TEXT_PRIMARY, - max_lines=1, - overflow=ft.TextOverflow.ELLIPSIS, - ), - ft.Text( - f"{course_name} · {format_deadline(deadline_str)}" if course_name else format_deadline(deadline_str), - size=10, - color=C.TEXT_SECONDARY, - max_lines=1, - overflow=ft.TextOverflow.ELLIPSIS, - ), - ], - spacing=2, - expand=True, - ), - ft.Container( - content=ft.Text(time_text, size=10, color=type_color or urgency_color, weight=ft.FontWeight.W_600), - padding=ft.Padding.symmetric(horizontal=8, vertical=4), - border=ft.Border.all(1, type_color or urgency_color), - border_radius=999, - ), - ], - spacing=8, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ), - bgcolor=C.BG, - border=ft.Border.all(1, C.BORDER), - border_radius=8, - padding=ft.Padding.only(left=10, right=10, top=8, bottom=8), - ink=True, - on_click=lambda _: self._show_detail(activity), - ) - - def _toggle_today_schedule(self): - self._today_schedule_expanded = not self._today_schedule_expanded - self._refresh_today_schedule_panel() - - def _refresh_today_schedule_panel(self, activities: list[dict] | None = None): - items = self._get_today_schedule_items(activities if activities is not None else list(self.all_data)) - count = len(items) - - self._today_schedule_toggle_icon.rotate = ft.Rotate( - angle=pi / 2 if self._today_schedule_expanded else 0, - alignment=ft.Alignment.CENTER, - ) - self._today_schedule_bar.bgcolor = C.ACCENT if self._today_schedule_expanded else C.BORDER - self._today_schedule_count.value = f"{count} mục" if count else "Không có lịch học hôm nay" + def _request_today_schedule(self) -> None: + coordinator = self._today_schedule_coordinator + if coordinator.state.status is ScheduleLoadStatus.ERROR: + self._safe_run_task(coordinator.refresh, "retry") + else: + self._safe_run_task(coordinator.ensure_today) - body_items = [self._make_today_schedule_item(item) for item in items] - self._today_schedule_items_column.controls = body_items - self._today_schedule_empty.visible = len(body_items) == 0 - self._today_schedule_items_column.visible = len(body_items) > 0 - self._today_schedule_body.visible = self._today_schedule_expanded + def _on_today_schedule_state(self, state: TodayScheduleViewState) -> None: + self._today_schedule_state = state + component = getattr(self, "_today_schedule_component", None) + if component is None: + return + component.set_state(state) + try: + self.page.update() + except Exception: + logger.debug("Today schedule UI update skipped", exc_info=True) def _update_footer(self): self._refresh_ui() @@ -1951,46 +1821,9 @@ def _rebuild_colors(self): import logging as _fb_log _fb_log.getLogger(__name__).debug("Ignored exception", exc_info=True) - # Today schedule card follows the same live theme refresh path. - if hasattr(self, 'today_schedule_panel'): - self.today_schedule_panel.bgcolor = _C.SURFACE - self.today_schedule_panel.border = ft.Border.all(1, _C.BORDER) - if hasattr(self, '_today_schedule_bar'): - self._today_schedule_bar.bgcolor = _C.ACCENT if self._today_schedule_expanded else _C.BORDER - if hasattr(self, '_today_schedule_header'): - try: - header_row = self._today_schedule_header.content - header_row.controls[0].color = _C.ACCENT - header_row.controls[1].controls[0].color = _C.TEXT_PRIMARY - header_row.controls[1].controls[1].color = _C.TEXT_SECONDARY - header_row.controls[2].color = _C.TEXT_SECONDARY - self._today_schedule_header.bgcolor = _C.SURFACE - except Exception: - import logging as _fb_log - _fb_log.getLogger(__name__).debug("Ignored exception", exc_info=True) - if hasattr(self, '_today_schedule_empty'): - try: - empty_col = self._today_schedule_empty.content - empty_col.controls[0].color = _C.BORDER - empty_col.controls[1].color = _C.TEXT_SECONDARY - except Exception: - import logging as _fb_log - _fb_log.getLogger(__name__).debug("Ignored exception", exc_info=True) - if hasattr(self, '_today_schedule_body'): - self._today_schedule_body.bgcolor = _C.SURFACE - try: - body_col = self._today_schedule_body.content - body_col.controls[0].color = _C.BORDER - except Exception: - import logging as _fb_log - _fb_log.getLogger(__name__).debug("Ignored exception", exc_info=True) - - # Rebuild schedule items so their type/urgency colors follow the new theme. - try: - self._refresh_today_schedule_panel(list(self.all_data)) - except Exception: - import logging as _fb_log - _fb_log.getLogger(__name__).debug("Ignored exception", exc_info=True) + component = getattr(self, "_today_schedule_component", None) + if component is not None: + component.refresh_theme() # Icon buttons buttons_to_update = [self.calendar_btn, self.grades_btn, self.refresh_btn, self.settings_btn] @@ -2300,6 +2133,20 @@ def _on_settings_saved(self): else None ) self._data_cache = DataCache(namespace=cache_namespace) + schedule_namespace = ( + f"{settings.PORTAL_API_BASE}|{settings.UTH_USERNAME}" + if settings.UTH_USERNAME + else f"{settings.PORTAL_API_BASE}|anonymous" + ) + self._portal_client.clear_token() + self._today_schedule_coordinator.replace_cache( + TodayScheduleCache(namespace=schedule_namespace) + ) + self._today_schedule_state = TodayScheduleViewState( + ScheduleLoadStatus.IDLE + ) + self._today_schedule_component.set_state(self._today_schedule_state) + self._safe_run_task(self._today_schedule_coordinator.ensure_today) self._settings_identity = new_identity load_theme_from_settings() @@ -2468,12 +2315,15 @@ async def _countdown_loop_async(self): try: changed = self._refresh_activity_time_state() if self.dashboard.visible: + schedule_changed = self._today_schedule_component.refresh_time_state() if changed: self._render_cards_only() continue with self._cards_lock: cards_snapshot = list(self.active_cards) self._countdown_cards_once(cards_snapshot) + if schedule_changed: + self.page.update() elif self.calendar_view.visible: with self._data_lock: calendar_snapshot = list(self.all_data) @@ -2503,6 +2353,9 @@ def _on_disconnect(self, e): coordinator = getattr(self, "_sync_coordinator", None) if coordinator is not None: coordinator.close() + schedule_coordinator = getattr(self, "_today_schedule_coordinator", None) + if schedule_coordinator is not None: + schedule_coordinator.close() update_coordinator = getattr(self, "_update_coordinator", None) if update_coordinator is not None: update_coordinator.shutdown(timeout_seconds=5.0) diff --git a/src/gui/components/today_schedule_panel.py b/src/gui/components/today_schedule_panel.py new file mode 100644 index 0000000..11982f2 --- /dev/null +++ b/src/gui/components/today_schedule_panel.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Callable + +import flet as ft + +from core.today_schedule import ( + ClassSession, + ScheduleLoadStatus, + SchedulePhase, + TodayScheduleViewState, +) +from gui.core.theme import C + + +_PHASE_PRESENTATION = { + SchedulePhase.UPCOMING: ("Chưa đến giờ", lambda: C.ACCENT), + SchedulePhase.IN_PROGRESS: ("Đang học", lambda: C.SAFE), + SchedulePhase.FINISHED: ("Đã kết thúc", lambda: C.TEXT_SECONDARY), + SchedulePhase.CANCELLED: ("Tạm ngưng", lambda: C.CRITICAL), +} + + +class TodaySchedulePanel: + """Compact Portal class-schedule disclosure for the dashboard.""" + + def __init__(self, *, on_need_data: Callable[[], None]) -> None: + self._on_need_data = on_need_data + self.expanded = False + self.state = TodayScheduleViewState(ScheduleLoadStatus.IDLE) + self._rendered_phases: tuple[SchedulePhase, ...] = () + self._rendered_at = datetime.now() + + self.toggle_icon = ft.Icon( + ft.Icons.CHEVRON_RIGHT_ROUNDED, + size=18, + color=C.TEXT_SECONDARY, + rotate=ft.Rotate(angle=0, alignment=ft.Alignment.CENTER), + animate_rotation=ft.Animation( + duration=260, + curve=ft.AnimationCurve.EASE_OUT_CUBIC, + ), + ) + self.subtitle = ft.Text("Đang chuẩn bị lịch học...", size=11, color=C.TEXT_SECONDARY) + self.item_column = ft.Column(controls=[], spacing=8) + self.loading = ft.Container( + content=ft.Row( + controls=[ + ft.ProgressRing(width=16, height=16, stroke_width=2, color=C.ACCENT), + ft.Text("Đang lấy lịch học hôm nay...", size=11, color=C.TEXT_SECONDARY), + ], + spacing=8, + alignment=ft.MainAxisAlignment.CENTER, + ), + padding=ft.Padding.symmetric(vertical=10), + visible=False, + ) + self.empty = self._message_state( + ft.Icons.EVENT_AVAILABLE_ROUNDED, + "Hôm nay không có lịch học", + ) + self.auth_required = self._message_state( + ft.Icons.LOCK_OUTLINE_ROUNDED, + "Đăng nhập để đồng bộ lịch học", + ) + self.error = self._message_state( + ft.Icons.CLOUD_OFF_ROUNDED, + "Không thể lấy lịch học. Chạm để thử lại.", + ) + self.body = ft.Container( + content=ft.Column( + controls=[ + ft.Container(height=1, bgcolor=C.BORDER), + self.loading, + self.auth_required, + self.error, + self.empty, + self.item_column, + ], + spacing=8, + ), + padding=ft.Padding.only(left=12, right=12, top=0, bottom=12), + visible=False, + ) + self.header = ft.Container( + content=ft.Row( + controls=[ + ft.Icon(ft.Icons.CALENDAR_TODAY_ROUNDED, size=18, color=C.ACCENT), + ft.Column( + controls=[ + ft.Text( + "Lịch học hôm nay", + size=13, + weight=ft.FontWeight.W_600, + color=C.TEXT_PRIMARY, + ), + self.subtitle, + ], + spacing=1, + expand=True, + ), + self.toggle_icon, + ], + spacing=8, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + padding=ft.Padding.only(left=12, right=12, top=12, bottom=12), + on_click=lambda _: self.toggle(), + ink=True, + ) + self.accent_bar = ft.Container( + width=3, + border_radius=ft.BorderRadius.only(top_left=10, bottom_left=10), + bgcolor=C.BORDER, + ) + self.control = ft.Container( + content=ft.Row( + controls=[ + self.accent_bar, + ft.Container( + content=ft.Column( + controls=[self.header, self.body], + spacing=0, + ), + expand=True, + ), + ], + spacing=0, + ), + margin=ft.Margin(left=10, right=10, top=0, bottom=0), + bgcolor=C.SURFACE, + border=ft.Border.all(1, C.BORDER), + border_radius=10, + ) + self.set_state(self.state) + + @staticmethod + def _message_state(icon: str, message: str) -> ft.Container: + return ft.Container( + content=ft.Column( + controls=[ + ft.Icon(icon, size=24, color=C.BORDER), + ft.Text( + message, + size=11, + color=C.TEXT_SECONDARY, + text_align=ft.TextAlign.CENTER, + ), + ], + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + spacing=4, + ), + alignment=ft.Alignment(0, 0), + padding=ft.Padding.symmetric(vertical=8), + visible=False, + ) + + def toggle(self) -> None: + self.expanded = not self.expanded + self.toggle_icon.rotate = ft.Rotate( + angle=3.141592653589793 / 2 if self.expanded else 0, + alignment=ft.Alignment.CENTER, + ) + self.accent_bar.bgcolor = C.ACCENT if self.expanded else C.BORDER + self.body.visible = self.expanded + if self.expanded and self.state.status in { + ScheduleLoadStatus.IDLE, + ScheduleLoadStatus.ERROR, + }: + self._on_need_data() + + def set_state( + self, + state: TodayScheduleViewState, + *, + now: datetime | None = None, + ) -> None: + self.state = state + self._rendered_at = now or datetime.now() + snapshot = state.snapshot + sessions = snapshot.sessions if snapshot is not None else () + count = len(sessions) + self.loading.visible = state.status is ScheduleLoadStatus.LOADING + self.auth_required.visible = state.status is ScheduleLoadStatus.AUTH_REQUIRED and not sessions + self.error.visible = state.status is ScheduleLoadStatus.ERROR and not sessions + self.empty.visible = state.status is ScheduleLoadStatus.READY and count == 0 + self.item_column.visible = count > 0 + self.item_column.controls = [ + self._session_card(session, self._rendered_at) for session in sessions + ] + self._rendered_phases = tuple( + session.phase_at(self._rendered_at) for session in sessions + ) + if state.status is ScheduleLoadStatus.LOADING: + self.subtitle.value = ( + f"Đang cập nhật {count} buổi học..." + if count + else "Đang lấy lịch học từ Portal..." + ) + elif state.status is ScheduleLoadStatus.AUTH_REQUIRED: + self.subtitle.value = "Cần đăng nhập Portal" + elif state.status is ScheduleLoadStatus.ERROR: + self.subtitle.value = ( + f"{count} buổi học · chưa thể cập nhật" + if count + else "Không thể tải lịch học" + ) + elif state.status is ScheduleLoadStatus.READY: + self.subtitle.value = f"{count} buổi học" if count else "Hôm nay không có lịch" + else: + self.subtitle.value = "Đang chuẩn bị lịch học..." + self.body.visible = self.expanded + + def refresh_time_state(self, now: datetime | None = None) -> bool: + snapshot = self.state.snapshot + if snapshot is None or not snapshot.sessions: + return False + current = now or datetime.now() + phases = tuple(session.phase_at(current) for session in snapshot.sessions) + if phases == self._rendered_phases: + return False + self.set_state(self.state, now=current) + return True + + def refresh_theme(self) -> None: + self.control.bgcolor = C.SURFACE + self.control.border = ft.Border.all(1, C.BORDER) + self.accent_bar.bgcolor = C.ACCENT if self.expanded else C.BORDER + self.subtitle.color = C.TEXT_SECONDARY + self.toggle_icon.color = C.TEXT_SECONDARY + self.set_state(self.state, now=self._rendered_at) + + @staticmethod + def _session_card(session: ClassSession, now: datetime) -> ft.Container: + phase = session.phase_at(now) + status_text, color_provider = _PHASE_PRESENTATION[phase] + status_color = color_provider() + time_text = f"{session.start_at:%H:%M} – {session.end_at:%H:%M}" + if session.period_start is not None and session.period_end is not None: + time_text += f" · Tiết {session.period_start}–{session.period_end}" + locations = [] + if session.room: + locations.append(f"Phòng {session.room}") + if session.campus: + locations.append(session.campus) + location_text = " · ".join(locations) or "Chưa cập nhật phòng/cơ sở" + metadata = [ + ft.Row( + controls=[ + ft.Icon(ft.Icons.ACCESS_TIME_ROUNDED, size=13, color=C.TEXT_SECONDARY), + ft.Text(time_text, size=10, color=C.TEXT_SECONDARY), + ], + spacing=5, + ), + ft.Row( + controls=[ + ft.Icon(ft.Icons.LOCATION_ON_OUTLINED, size=13, color=C.TEXT_SECONDARY), + ft.Text( + location_text, + size=10, + color=C.TEXT_SECONDARY, + max_lines=1, + overflow=ft.TextOverflow.ELLIPSIS, + ), + ], + spacing=5, + ), + ] + if session.course_code: + metadata.insert( + 0, + ft.Text(session.course_code, size=9, color=C.TEXT_SECONDARY), + ) + return ft.Container( + content=ft.Row( + controls=[ + ft.Container( + width=3, + height=62, + bgcolor=status_color, + border_radius=3, + ), + ft.Column( + controls=[ + ft.Text( + session.subject, + size=12, + weight=ft.FontWeight.W_600, + color=C.TEXT_PRIMARY, + max_lines=2, + overflow=ft.TextOverflow.ELLIPSIS, + ), + *metadata, + ], + spacing=2, + expand=True, + ), + ft.Container( + content=ft.Text( + status_text, + size=9, + color=status_color, + weight=ft.FontWeight.W_600, + ), + padding=ft.Padding.symmetric(horizontal=7, vertical=4), + border=ft.Border.all(1, status_color), + border_radius=999, + ), + ], + spacing=8, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + bgcolor=C.BG, + border=ft.Border.all(1, C.BORDER), + border_radius=8, + padding=ft.Padding.only(left=10, right=10, top=8, bottom=8), + ) diff --git a/src/gui/core/utils.py b/src/gui/core/utils.py index 13246df..3e3f064 100644 --- a/src/gui/core/utils.py +++ b/src/gui/core/utils.py @@ -228,6 +228,15 @@ def get_submission_badge(data: dict): return ("Đã nộp" if act_type != "quiz" else "Đã làm"), C.SAFE if ss == "not_submitted": return ("Chưa nộp" if act_type != "quiz" else "Chưa làm"), C.TEXT_SECONDARY + if act_type == "quiz": + if ss == "in_progress": + return "Đang làm", C.WARNING + if ss == "overdue": + return "Quá hạn", C.CRITICAL + if ss == "abandoned": + return "Chưa hoàn thành", C.WARNING + if ss == "attempted": + return "Đã bắt đầu", C.WARNING submission = "" grading = "" diff --git a/src/main.py b/src/main.py index b35373c..add553b 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,7 @@ import logging import os import sys +import asyncio from pathlib import Path from diagnostics.logging_setup import ( @@ -154,13 +155,87 @@ def _is_source_checkout(module_path: Path) -> bool: return False +def _show_startup_screen( + page, + ft, + *, + publish: bool, + compact_desktop: bool = True, +) -> None: + """Publish the first frame only after it has final geometry and branding.""" + if compact_desktop: + page.window.width = 420 + page.window.height = 720 + page.window.min_width = 420 + page.window.max_width = 420 + page.window.min_height = 720 + page.window.max_height = 720 + page.window.resizable = False + page.window.icon = "icon.ico" + page.title = "UTHelper" + page.bgcolor = "#0B1120" + page.padding = 0 + page.spacing = 0 + page.theme_mode = ft.ThemeMode.DARK + page.controls.clear() + page.add( + ft.Container( + content=ft.Column( + controls=[ + ft.Image( + src="icon.png", + width=92, + height=92, + fit=ft.BoxFit.CONTAIN, + ), + ft.Text( + "UTHelper", + size=24, + weight=ft.FontWeight.BOLD, + color="#F8FAFC", + ), + ft.Text( + "Trợ lý học tập UTH", + size=12, + color="#94A3B8", + ), + ft.Container(height=18), + ft.ProgressRing( + width=28, + height=28, + stroke_width=3, + color="#3B82F6", + ), + ft.Text( + "Đang khởi động UTHelper...", + size=12, + color="#CBD5E1", + ), + ], + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + alignment=ft.MainAxisAlignment.CENTER, + spacing=8, + expand=True, + ), + alignment=ft.Alignment(0, 0), + expand=True, + bgcolor="#0B1120", + ) + ) + if compact_desktop: + page.window.visible = publish + page.update() + + def main() -> int: web_mode = _is_web_mode(sys.argv, os.environ) result = None development = _is_source_checkout(Path(__file__)) - if sys.platform == "win32" and not web_mode: + desktop_windows = sys.platform == "win32" and not web_mode + autostart_launch = desktop_windows and is_autostart_launch() + if desktop_windows: result = bootstrap_windows_instance( - autostart_launch=is_autostart_launch(), + autostart_launch=autostart_launch, release_channel="stable", development=development, ) @@ -184,13 +259,26 @@ def main() -> int: active_page = None - def _app_target(page: ft.Page): + async def _app_target(page: ft.Page): nonlocal active_page active_page = page if runtime is not None: runtime.attach_page(page) runtime.mark_phase(AppPhase.GUI) try: + publish_startup = not autostart_launch or bool( + result and result.force_visible + ) + _show_startup_screen( + page, + ft, + publish=publish_startup, + compact_desktop=desktop_windows, + ) + if publish_startup: + # Yield to the Flet transport so Flutter paints and animates the + # branded first frame before synchronous GUI construction. + await asyncio.sleep(0.6) logger.info("Starting app imports...") # Flet compatibility shim (MUST run before any GUI imports) @@ -249,6 +337,10 @@ def _app_target(page: ft.Page): if web_mode: run_kwargs["view"] = ft.AppView.WEB_BROWSER run_kwargs["port"] = web_port + elif desktop_windows: + # Keep Flutter's default 1280x720 host hidden until the first UTHelper + # frame has its final compact geometry, logo, and progress indicator. + run_kwargs["view"] = ft.AppView.FLET_APP_HIDDEN elif os.environ.get("FLET_SERVER_PORT"): # Flet >= 0.82 workaround removed to test if it's causing the issue in 0.85.3 pass diff --git a/tests/test_activity_badges.py b/tests/test_activity_badges.py new file mode 100644 index 0000000..a0380b3 --- /dev/null +++ b/tests/test_activity_badges.py @@ -0,0 +1,55 @@ +import os +import sys + +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src"))) + +from gui.core.theme import C +from gui.core.utils import get_submission_badge +from gui.components.activity_card import ActivityCard + + +@pytest.mark.parametrize( + ("status", "expected_label", "expected_color"), + [ + ("not_submitted", "Chưa làm", C.TEXT_SECONDARY), + ("submitted", "Đã làm", C.SAFE), + ("in_progress", "Đang làm", C.WARNING), + ("overdue", "Quá hạn", C.CRITICAL), + ("abandoned", "Chưa hoàn thành", C.WARNING), + ("attempted", "Đã bắt đầu", C.WARNING), + ], +) +def test_quiz_attempt_badges(status, expected_label, expected_color): + assert get_submission_badge( + {"type": "quiz", "submission_status": status, "details": {}} + ) == (expected_label, expected_color) + + +def test_unknown_quiz_status_does_not_invent_a_badge(): + assert get_submission_badge( + {"type": "quiz", "submission_status": "unknown", "details": {}} + ) is None + + +def test_assignment_badge_wording_is_unchanged(): + assert get_submission_badge( + {"type": "assignment", "submission_status": "not_submitted", "details": {}} + ) == ("Chưa nộp", C.TEXT_SECONDARY) + + +def test_activity_card_renders_quiz_not_started_badge(): + card = ActivityCard( + { + "id": "quiz-1", + "type": "quiz", + "title": "Quiz", + "submission_status": "not_submitted", + "details": {}, + }, + on_tap=lambda _data: None, + ) + + assert len(card._optional_rows.controls) == 1 + assert card._optional_rows.controls[0].content.value == "Chưa làm" diff --git a/tests/test_data_orchestrator_service_boundary.py b/tests/test_data_orchestrator_service_boundary.py index c4b8320..5bacc46 100644 --- a/tests/test_data_orchestrator_service_boundary.py +++ b/tests/test_data_orchestrator_service_boundary.py @@ -56,3 +56,27 @@ def test_fetch_detail_via_ws_uses_moodle_service_boundary(): assert fake_service.detail_calls == [(77, 12, "assign")] assert result["details"]["description_html"] == "

Chi tiết

" assert result["submission_status"] == "Đã nộp" + + +def test_fetch_quiz_detail_promotes_normalized_attempt_status(): + orchestrator = DataOrchestrator() + fake_service = _FakeMoodleService() + fake_service.get_assign_details_via_ws = lambda **_kwargs: { + "status_data": {}, + "quiz_info": [], + "quiz_attempt_status": "not_submitted", + } + orchestrator.moodle_service = fake_service + + result = orchestrator._fetch_detail_via_ws( + { + "url": "https://example.test/mod/quiz/view.php?id=77", + "type": "quiz", + "course_id": "12", + "submission_status": "unknown", + "details": {}, + } + ) + + assert result["submission_status"] == "not_submitted" + assert result["details"]["quiz_attempt_status"] == "not_submitted" diff --git a/tests/test_gui_app_controller.py b/tests/test_gui_app_controller.py index c59beb4..290e8a5 100644 --- a/tests/test_gui_app_controller.py +++ b/tests/test_gui_app_controller.py @@ -1,13 +1,13 @@ import os import sys import asyncio -from datetime import date from pathlib import Path from types import SimpleNamespace sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src"))) import gui.app_controller as app_controller_module +from core.today_schedule import ScheduleLoadStatus, TodayScheduleViewState from core.update_models import ReleasePackage from gui.app_controller import ( AppController, @@ -74,38 +74,38 @@ def test_countdown_tick_batches_page_update_without_per_card_updates(): assert controller.page.update_count == 1 -def test_get_today_schedule_items_filters_and_sorts_today(monkeypatch): - class _FixedDate(date): - @classmethod - def today(cls): - return cls(2026, 8, 5) +def test_today_schedule_retry_requests_portal_refresh_instead_of_moodle_data(): + controller = AppController.__new__(AppController) + calls = [] - monkeypatch.setattr(app_controller_module, "date", _FixedDate) + def refresh(trigger): + return None - controller = AppController.__new__(AppController) - controller.all_data = [ - {"title": "Bài muộn", "deadline": "2026-08-05T15:00:00", "urgency": "warning"}, - {"title": "Bài sớm", "deadline": "2026-08-05T09:00:00", "urgency": "critical"}, - {"title": "Bài mai", "deadline": "2026-08-06T09:00:00"}, - {"title": "Không hạn", "deadline": ""}, - ] + controller._today_schedule_coordinator = SimpleNamespace( + state=TodayScheduleViewState(ScheduleLoadStatus.ERROR), + refresh=refresh, + ensure_today=lambda: None, + ) + controller._safe_run_task = lambda handler, *args: calls.append((handler, args)) - items = controller._get_today_schedule_items() + controller._request_today_schedule() - assert [item["title"] for item in items] == ["Bài sớm", "Bài muộn"] + assert calls == [(refresh, ("retry",))] -def test_toggle_today_schedule_flips_state_and_refreshes(): +def test_today_schedule_state_updates_component_and_page_once(): controller = AppController.__new__(AppController) - controller._today_schedule_expanded = False calls = [] + controller._today_schedule_component = SimpleNamespace( + set_state=lambda state: calls.append(("state", state)) + ) + controller.page = SimpleNamespace(update=lambda: calls.append(("update",))) + state = TodayScheduleViewState(ScheduleLoadStatus.LOADING) - controller._refresh_today_schedule_panel = lambda activities=None: calls.append(controller._today_schedule_expanded) - - controller._toggle_today_schedule() + controller._on_today_schedule_state(state) - assert controller._today_schedule_expanded is True - assert calls == [True] + assert controller._today_schedule_state is state + assert calls == [("state", state), ("update",)] def test_android_build_number_is_deterministic_and_strict(): diff --git a/tests/test_main_single_instance.py b/tests/test_main_single_instance.py index 74bb59d..143e19b 100644 --- a/tests/test_main_single_instance.py +++ b/tests/test_main_single_instance.py @@ -1,6 +1,8 @@ from __future__ import annotations +import asyncio import builtins +import inspect import sys from pathlib import Path from types import SimpleNamespace @@ -58,13 +60,18 @@ def _install_flet_runner(monkeypatch, *, invoke_target: bool = False, error=None def run(**kwargs) -> None: calls.append(kwargs) if invoke_target: - kwargs["main"](object()) + result = kwargs["main"](object()) + if inspect.isawaitable(result): + asyncio.run(result) if error is not None: raise error fake_flet = SimpleNamespace( Page=object, - AppView=SimpleNamespace(WEB_BROWSER="web-browser"), + AppView=SimpleNamespace( + WEB_BROWSER="web-browser", + FLET_APP_HIDDEN="flet-app-hidden", + ), run=run, ) monkeypatch.setitem(sys.modules, "flet", fake_flet) @@ -126,7 +133,8 @@ def bootstrap(**kwargs): assert events == ["bootstrap"] assert len(calls) == 1 - assert set(calls[0]) == {"main", "assets_dir"} + assert set(calls[0]) == {"main", "assets_dir", "view"} + assert calls[0]["view"] == "flet-app-hidden" assert "target" not in calls[0] @@ -188,6 +196,7 @@ def test_primary_passes_bootstrap_dependencies_to_desktop_composition(monkeypatc broker = _BrokerSpy() result = SimpleNamespace(exit_code=None, broker=broker, force_visible=True) _install_flet_runner(monkeypatch, invoke_target=True) + startup_calls: list[tuple[object, bool]] = [] monkeypatch.delenv("FLET_WEB", raising=False) monkeypatch.setattr(application.sys, "platform", "win32") monkeypatch.setattr(application.sys, "argv", ["main.py"]) @@ -199,6 +208,13 @@ def test_primary_passes_bootstrap_dependencies_to_desktop_composition(monkeypatc ) monkeypatch.setattr(application, "is_autostart_launch", lambda: False, raising=False) monkeypatch.setattr(application, "_is_source_checkout", lambda path: True, raising=False) + monkeypatch.setattr( + application, + "_show_startup_screen", + lambda page, _ft, *, publish, compact_desktop: startup_calls.append( + (page, publish) + ), + ) desktop_module = SimpleNamespace( main=lambda page, *, activation_broker, force_visible: app_calls.append( (page, activation_broker, force_visible) @@ -210,6 +226,7 @@ def test_primary_passes_bootstrap_dependencies_to_desktop_composition(monkeypatc assert len(app_calls) == 1 assert app_calls[0][1:] == (broker, True) + assert startup_calls == [(app_calls[0][0], True)] assert broker.close_calls == [1.0] diff --git a/tests/test_portal_client.py b/tests/test_portal_client.py new file mode 100644 index 0000000..1d2d724 --- /dev/null +++ b/tests/test_portal_client.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import gzip +import json +from datetime import date +from email.message import Message +from urllib.error import HTTPError + +from core.portal_client import PortalAuthenticationError, PortalClient + + +class _Response: + def __init__(self, payload: dict, status: int = 200, *, compressed: bool = False): + raw = json.dumps(payload).encode("utf-8") + self._body = gzip.compress(raw) if compressed else raw + self.status = status + self.headers = Message() + if compressed: + self.headers["Content-Encoding"] = "gzip" + + def read(self): + return self._body + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + +def test_portal_client_logs_in_then_fetches_daily_schedule_with_bearer_only(): + requests = [] + + def opener(request, timeout): + requests.append((request, timeout)) + if request.full_url.endswith("/user/login"): + return _Response({"success": True, "token": "jwt-token"}, compressed=True) + return _Response( + { + "success": True, + "body": [ + { + "id": 1, + "tenMonHoc": "Cơ sở dữ liệu", + "tuGio": "07:00", + "denGio": "09:30", + } + ], + } + ) + + client = PortalClient( + "https://portal-publicapp.ut.edu.vn/api/v1", + opener=opener, + timeout=9, + ) + + sessions = client.fetch_day(date(2026, 8, 11), "student", "secret") + + assert len(sessions) == 1 + login_request, login_timeout = requests[0] + assert login_request.method == "POST" + assert login_timeout == 9 + assert json.loads(login_request.data) == { + "username": "student", + "password": "secret", + } + schedule_request, schedule_timeout = requests[1] + assert schedule_timeout == 9 + assert schedule_request.full_url.endswith("/lichhoc/ngay?date=2026-08-11") + assert schedule_request.get_header("Authorization") == "Bearer jwt-token" + assert schedule_request.get_header("Cookie") is None + assert schedule_request.get_header("X-browser-token") is None + + +def test_portal_client_reauthenticates_once_after_unauthorized_schedule(): + paths = [] + login_count = 0 + + def opener(request, timeout): + del timeout + nonlocal login_count + paths.append(request.full_url) + if request.full_url.endswith("/user/login"): + login_count += 1 + return _Response({"success": True, "token": f"token-{login_count}"}) + if login_count == 1: + raise HTTPError(request.full_url, 401, "unauthorized", {}, None) + return _Response({"success": True, "body": []}) + + client = PortalClient("https://portal-publicapp.ut.edu.vn/api/v1", opener=opener) + + assert client.fetch_day(date(2026, 8, 11), "student", "secret") == () + assert login_count == 2 + assert sum(path.endswith("/user/login") for path in paths) == 2 + + +def test_portal_client_rejects_login_without_token(): + client = PortalClient( + "https://portal-publicapp.ut.edu.vn/api/v1", + opener=lambda *_args, **_kwargs: _Response( + {"success": False, "message": "invalid"} + ), + ) + + try: + client.fetch_day(date(2026, 8, 11), "student", "secret") + except PortalAuthenticationError: + pass + else: + raise AssertionError("missing Portal token was accepted") diff --git a/tests/test_release_hardening.py b/tests/test_release_hardening.py index 3d2b543..e86e488 100644 --- a/tests/test_release_hardening.py +++ b/tests/test_release_hardening.py @@ -1,5 +1,6 @@ from pathlib import Path import re +import struct import tomllib @@ -10,6 +11,18 @@ def _read(relative_path: str) -> str: return (ROOT / relative_path).read_text(encoding="utf-8") +def _ico_sizes(relative_path: str) -> set[tuple[int, int]]: + data = (ROOT / relative_path).read_bytes() + reserved, image_type, image_count = struct.unpack_from("= 6 + (16 * image_count) + sizes: set[tuple[int, int]] = set() + for index in range(image_count): + width, height = struct.unpack_from("BB", data, 6 + (16 * index)) + sizes.add((width or 256, height or 256)) + return sizes + + def test_release_workflow_pins_expected_artifact_and_signing_certificates(): workflow = _read(".github/workflows/release.yml") @@ -283,6 +296,21 @@ def test_wix_authoring_has_stable_upgrade_codes_and_exact_msi_chain(): assert "x64" in bundle_project +def test_wix_bundle_uses_application_branding_for_ui_and_executable(): + bundle = _read("packaging/windows/Bundle.wxs") + bundle_project = _read("packaging/windows/UTHelper.Bundle.wixproj") + + assert 'IconSourceFile="$(BundleIconPath)"' in bundle + assert 'LogoFile="$(BundleLogoPath)"' in bundle + assert "BundleIconPath=$(MSBuildProjectDirectory)" in bundle_project + assert r"src\assets\icon.ico" in bundle_project + assert "BundleLogoPath=$(MSBuildProjectDirectory)" in bundle_project + assert r"src\assets\icon.png" in bundle_project + + required_shell_sizes = {(16, 16), (32, 32), (48, 48), (256, 256)} + assert required_shell_sizes <= _ico_sizes("src/assets/icon.ico") + + def test_burn_signing_detaches_signs_reattaches_and_signs_outer_bundle(): script = _read("scripts/sign_windows_release.ps1") diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index c407a50..8bff184 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -24,8 +24,8 @@ def test_project_version_is_only_authored_version(tmp_path): ) -def test_this_release_bumps_the_single_authored_version(): - assert read_project_version(ROOT / "pyproject.toml") == "2.2.12" +def test_this_feature_release_bumps_the_single_authored_version(): + assert read_project_version(ROOT / "pyproject.toml") == "2.3.0" def test_runtime_version_is_generated_from_the_single_authored_version(tmp_path): diff --git a/tests/test_startup_visibility.py b/tests/test_startup_visibility.py index 50ded63..dbe7b5d 100644 --- a/tests/test_startup_visibility.py +++ b/tests/test_startup_visibility.py @@ -1,7 +1,11 @@ from pathlib import Path +from types import SimpleNamespace +import flet as ft import pytest +from src.main import _show_startup_screen + from gui.controllers.startup_visibility import ( is_autostart_launch, should_hide_startup_window, @@ -54,6 +58,73 @@ def test_alias_matching_is_case_insensitive(): ) +def test_startup_screen_publishes_compact_window_with_logo_and_animation(): + class Page: + def __init__(self): + self.window = SimpleNamespace( + width=None, + height=None, + min_width=None, + max_width=None, + resizable=None, + icon=None, + visible=False, + ) + self.controls = [] + self.title = "" + self.bgcolor = "" + self.padding = None + self.spacing = None + self.theme_mode = None + self.update_calls = 0 + + def add(self, *controls): + self.controls.extend(controls) + + def update(self): + self.update_calls += 1 + + def walk(control): + yield control + content = getattr(control, "content", None) + if content is not None: + yield from walk(content) + for child in getattr(control, "controls", None) or []: + yield from walk(child) + + page = Page() + + _show_startup_screen(page, ft, publish=True) + + assert (page.window.width, page.window.height) == (420, 720) + assert (page.window.min_width, page.window.max_width) == (420, 420) + assert page.window.resizable is False + assert page.window.icon == "icon.ico" + assert page.window.visible is True + assert page.title == "UTHelper" + assert page.update_calls == 1 + controls = list(walk(page.controls[0])) + assert any(isinstance(control, ft.Image) and control.src == "icon.png" for control in controls) + assert any(isinstance(control, ft.ProgressRing) for control in controls) + assert any( + isinstance(control, ft.Text) and control.value == "Đang khởi động UTHelper..." + for control in controls + ) + + +def test_startup_screen_stays_hidden_for_tray_autostart(): + page = SimpleNamespace( + window=SimpleNamespace(visible=False), + controls=[], + add=lambda *controls: page.controls.extend(controls), + update=lambda: None, + ) + + _show_startup_screen(page, ft, publish=False) + + assert page.window.visible is False + + def test_tray_setup_failure_is_explicit(monkeypatch): import gui.tray as tray diff --git a/tests/test_today_schedule.py b/tests/test_today_schedule.py new file mode 100644 index 0000000..7231ea7 --- /dev/null +++ b/tests/test_today_schedule.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import asyncio +import time as time_module +from datetime import date, datetime, time + +from core.today_schedule import ( + ClassSession, + ScheduleLoadStatus, + SchedulePhase, + TodayScheduleCache, + TodayScheduleCoordinator, + TodayScheduleSnapshot, + next_schedule_refresh_at, + parse_portal_day, +) + + +def _session( + *, + start: datetime = datetime(2026, 8, 11, 7, 0), + end: datetime = datetime(2026, 8, 11, 9, 30), + cancelled: bool = False, +) -> ClassSession: + return ClassSession( + session_id="123", + subject="Hệ quản trị cơ sở dữ liệu", + course_code="CNS_CS1", + start_at=start, + end_at=end, + room="A.101", + campus="Cơ sở 1", + period_start=1, + period_end=3, + cancelled=cancelled, + note="", + ) + + +def test_parse_portal_day_preserves_academic_time_and_location_fields(): + payload = { + "success": True, + "body": [ + { + "id": 123, + "tenMonHoc": "Hệ quản trị cơ sở dữ liệu", + "maLopHocPhan": "CNS_CS1", + "tenPhong": "A.101", + "coSoToDisplay": "Cơ sở 1", + "tuTiet": 1, + "denTiet": 3, + "tuGio": "07:00", + "denGio": "09:30", + "isTamNgung": False, + "ghiChu": "Mang theo laptop", + }, + { + "id": 456, + "tenMonHoc": "Mạng máy tính", + "maLopHocPhan": "NET_01", + "roomToDisplay": "B.202", + "coSoToDisplay": "Cơ sở 2", + "tuTiet": 7, + "denTiet": 9, + "tuGio": "13:00:00", + "denGio": "15:30:00", + "isTamNgung": True, + }, + ], + } + + sessions = parse_portal_day(payload, date(2026, 8, 11)) + + assert [item.subject for item in sessions] == [ + "Hệ quản trị cơ sở dữ liệu", + "Mạng máy tính", + ] + assert sessions[0].start_at == datetime(2026, 8, 11, 7, 0) + assert sessions[0].end_at == datetime(2026, 8, 11, 9, 30) + assert sessions[0].room == "A.101" + assert sessions[0].campus == "Cơ sở 1" + assert (sessions[0].period_start, sessions[0].period_end) == (1, 3) + assert sessions[0].note == "Mang theo laptop" + assert sessions[1].cancelled is True + + +def test_parse_portal_day_rejects_unsuccessful_or_malformed_envelopes(): + for payload in ( + {"success": False, "body": []}, + {"success": True, "body": {}}, + [], + ): + try: + parse_portal_day(payload, date(2026, 8, 11)) + except ValueError: + pass + else: + raise AssertionError("invalid Portal envelope was accepted") + + +def test_parse_portal_day_skips_invalid_rows_but_keeps_a_valid_empty_day(): + payload = { + "success": True, + "body": [ + {"id": 1, "tenMonHoc": "Thiếu giờ", "tuGio": "", "denGio": ""}, + {"id": 2, "tenMonHoc": "Sai giờ", "tuGio": "xx", "denGio": "yy"}, + ], + } + + assert parse_portal_day(payload, date(2026, 8, 11)) == () + assert parse_portal_day({"success": True, "body": []}, date(2026, 8, 11)) == () + + +def test_class_session_phase_boundaries_and_cancellation_priority(): + session = _session() + + assert session.phase_at(datetime(2026, 8, 11, 6, 59, 59)) is SchedulePhase.UPCOMING + assert session.phase_at(datetime(2026, 8, 11, 7, 0)) is SchedulePhase.IN_PROGRESS + assert session.phase_at(datetime(2026, 8, 11, 9, 29, 59)) is SchedulePhase.IN_PROGRESS + assert session.phase_at(datetime(2026, 8, 11, 9, 30)) is SchedulePhase.FINISHED + assert _session(cancelled=True).phase_at(datetime(2026, 8, 11, 8, 0)) is SchedulePhase.CANCELLED + + +def test_today_schedule_cache_treats_an_empty_day_as_authoritative(tmp_path): + cache = TodayScheduleCache(cache_dir=tmp_path, namespace="portal|student") + snapshot = TodayScheduleSnapshot( + schedule_date=date(2026, 8, 11), + sessions=(), + fetched_at=datetime(2026, 8, 11, 6, 0), + ) + + assert cache.save(snapshot) is True + assert cache.load_for(date(2026, 8, 11)) == snapshot + assert cache.load_for(date(2026, 8, 12)) is None + + +def test_today_schedule_cache_round_trips_sessions(tmp_path): + cache = TodayScheduleCache(cache_dir=tmp_path, namespace="portal|student") + snapshot = TodayScheduleSnapshot( + schedule_date=date(2026, 8, 11), + sessions=(_session(),), + fetched_at=datetime(2026, 8, 11, 0, 0), + ) + + assert cache.save(snapshot) is True + assert cache.load_for(date(2026, 8, 11)) == snapshot + + +def test_next_schedule_refresh_uses_midnight_and_six_am_boundaries(): + assert next_schedule_refresh_at(datetime(2026, 8, 11, 0, 0)) == datetime( + 2026, 8, 11, 6, 0 + ) + assert next_schedule_refresh_at(datetime(2026, 8, 11, 5, 59)) == datetime( + 2026, 8, 11, 6, 0 + ) + assert next_schedule_refresh_at(datetime(2026, 8, 11, 6, 0)) == datetime( + 2026, 8, 12, 0, 0 + ) + assert next_schedule_refresh_at(datetime(2026, 8, 11, 23, 59)) == datetime( + 2026, 8, 12, 0, 0 + ) + + +def test_coordinator_fetches_on_open_only_when_today_has_no_cache(tmp_path): + now = datetime(2026, 8, 11, 8, 0) + cache = TodayScheduleCache(cache_dir=tmp_path, namespace="portal|student") + calls: list[tuple[date, str, str]] = [] + states = [] + + def fetcher(target_date, username, password): + calls.append((target_date, username, password)) + return (_session(),) + + coordinator = TodayScheduleCoordinator( + fetch_day=fetcher, + cache=cache, + credentials_provider=lambda: ("student", "secret"), + state_sink=states.append, + now_provider=lambda: now, + ) + + asyncio.run(coordinator.ensure_today()) + asyncio.run(coordinator.ensure_today()) + + assert calls == [(date(2026, 8, 11), "student", "secret")] + assert [state.status for state in states[:2]] == [ + ScheduleLoadStatus.LOADING, + ScheduleLoadStatus.READY, + ] + assert states[-1].snapshot.sessions == (_session(),) + + +def test_coordinator_publishes_cached_data_without_network_on_open(tmp_path): + now = datetime(2026, 8, 11, 8, 0) + cache = TodayScheduleCache(cache_dir=tmp_path, namespace="portal|student") + snapshot = TodayScheduleSnapshot( + schedule_date=now.date(), + sessions=(_session(),), + fetched_at=datetime.combine(now.date(), time(0, 0)), + ) + cache.save(snapshot) + states = [] + coordinator = TodayScheduleCoordinator( + fetch_day=lambda *_: (_ for _ in ()).throw(AssertionError("network called")), + cache=cache, + credentials_provider=lambda: ("student", "secret"), + state_sink=states.append, + now_provider=lambda: now, + ) + + asyncio.run(coordinator.ensure_today()) + + assert states[-1].status is ScheduleLoadStatus.READY + assert states[-1].snapshot == snapshot + assert states[-1].from_cache is True + + +def test_coordinator_exposes_auth_required_without_calling_network(tmp_path): + states = [] + coordinator = TodayScheduleCoordinator( + fetch_day=lambda *_: (_ for _ in ()).throw(AssertionError("network called")), + cache=TodayScheduleCache(cache_dir=tmp_path, namespace="anonymous"), + credentials_provider=lambda: ("", ""), + state_sink=states.append, + now_provider=lambda: datetime(2026, 8, 11, 8, 0), + ) + + asyncio.run(coordinator.ensure_today()) + + assert states[-1].status is ScheduleLoadStatus.AUTH_REQUIRED + + +def test_concurrent_startup_and_fast_expand_coalesce_to_one_portal_fetch(tmp_path): + calls = [] + + def fetcher(*_args): + calls.append("fetch") + time_module.sleep(0.05) + return (_session(),) + + coordinator = TodayScheduleCoordinator( + fetch_day=fetcher, + cache=TodayScheduleCache(cache_dir=tmp_path, namespace="portal|student"), + credentials_provider=lambda: ("student", "secret"), + state_sink=lambda _state: None, + now_provider=lambda: datetime(2026, 8, 11, 8, 0), + ) + + async def run_both(): + await asyncio.gather( + coordinator.ensure_today(), + coordinator.ensure_today(), + ) + + asyncio.run(run_both()) + + assert calls == ["fetch"] diff --git a/tests/test_today_schedule_panel.py b/tests/test_today_schedule_panel.py new file mode 100644 index 0000000..d9a4e40 --- /dev/null +++ b/tests/test_today_schedule_panel.py @@ -0,0 +1,106 @@ +from datetime import date, datetime + +import flet as ft + +from core.today_schedule import ( + ClassSession, + ScheduleLoadStatus, + TodayScheduleSnapshot, + TodayScheduleViewState, +) +from gui.components.today_schedule_panel import TodaySchedulePanel + + +def _state(*, status=ScheduleLoadStatus.READY, sessions=()): + snapshot = TodayScheduleSnapshot( + schedule_date=date(2026, 8, 11), + sessions=tuple(sessions), + fetched_at=datetime(2026, 8, 11, 6, 0), + ) + return TodayScheduleViewState(status, snapshot=snapshot) + + +def _session(*, cancelled=False): + return ClassSession( + session_id="1", + subject="Hệ quản trị cơ sở dữ liệu", + course_code="CNS_CS1", + start_at=datetime(2026, 8, 11, 7, 0), + end_at=datetime(2026, 8, 11, 9, 30), + room="A.101", + campus="Cơ sở 1", + period_start=1, + period_end=3, + cancelled=cancelled, + note="", + ) + + +def _texts(control): + values = [] + + def walk(item): + if isinstance(item, ft.Text): + values.append(item.value) + content = getattr(item, "content", None) + if content is not None: + walk(content) + for child in getattr(item, "controls", None) or []: + walk(child) + + walk(control) + return values + + +def test_panel_shows_explicit_loading_when_opened_before_fetch_finishes(): + refresh_calls = [] + panel = TodaySchedulePanel(on_need_data=lambda: refresh_calls.append("fetch")) + panel.set_state(TodayScheduleViewState(ScheduleLoadStatus.LOADING)) + + panel.toggle() + + assert panel.expanded is True + assert panel.subtitle.value == "Đang lấy lịch học từ Portal..." + assert panel.loading.visible is True + assert panel.body.visible is True + assert refresh_calls == [] + + +def test_panel_renders_subject_time_period_room_campus_and_live_status(): + panel = TodaySchedulePanel(on_need_data=lambda: None) + panel.set_state( + _state(sessions=(_session(),)), + now=datetime(2026, 8, 11, 8, 0), + ) + panel.toggle() + + text = _texts(panel.item_column.controls[0]) + + assert panel.subtitle.value == "1 buổi học" + assert "Hệ quản trị cơ sở dữ liệu" in text + assert "07:00 – 09:30 · Tiết 1–3" in text + assert "Phòng A.101 · Cơ sở 1" in text + assert "Đang học" in text + + +def test_panel_empty_day_is_not_reported_as_an_error_or_moodle_activity(): + panel = TodaySchedulePanel(on_need_data=lambda: None) + panel.set_state(_state(sessions=())) + panel.toggle() + + assert panel.subtitle.value == "Hôm nay không có lịch" + assert panel.empty.visible is True + assert "Hôm nay không có lịch học" in _texts(panel.empty) + + +def test_panel_refreshes_phase_when_time_crosses_class_boundary(): + panel = TodaySchedulePanel(on_need_data=lambda: None) + panel.set_state( + _state(sessions=(_session(),)), + now=datetime(2026, 8, 11, 6, 30), + ) + assert "Chưa đến giờ" in _texts(panel.item_column.controls[0]) + + assert panel.refresh_time_state(datetime(2026, 8, 11, 7, 0)) is True + + assert "Đang học" in _texts(panel.item_column.controls[0]) diff --git a/tests/test_ws_functions_extended.py b/tests/test_ws_functions_extended.py index d9a9bd7..3c94dd1 100644 --- a/tests/test_ws_functions_extended.py +++ b/tests/test_ws_functions_extended.py @@ -63,6 +63,58 @@ def test_no_quizzes_key(self): assert ws_functions.get_quizzes_by_courses(mock_api, [1]) is None +class TestQuizAttemptStatus: + @staticmethod + def _details_for_attempts(attempts): + def mock_api(function, **_params): + if function == "mod_quiz_get_quizzes_by_courses": + return {"quizzes": [{"id": 7, "coursemodule": 77}]} + if function == "mod_quiz_get_user_attempts": + return {"attempts": attempts} + if function == "core_enrol_get_users_courses": + return [] + return None + + ws_functions.clear_all_caches() + return ws_functions.get_assign_details_via_ws(mock_api, 77, 12, "quiz") + + def test_empty_authoritative_attempt_list_means_quiz_not_started(self): + details = self._details_for_attempts([]) + + assert details["quiz_attempt_status"] == "not_submitted" + assert details["quiz_info"] == [] + + @pytest.mark.parametrize( + ("state", "expected"), + [ + ("finished", "submitted"), + ("inprogress", "in_progress"), + ("overdue", "overdue"), + ("abandoned", "abandoned"), + ("unexpected", "attempted"), + ], + ) + def test_last_attempt_state_is_normalized(self, state, expected): + details = self._details_for_attempts([{"attempt": 1, "state": state}]) + + assert details["quiz_attempt_status"] == expected + + def test_attempt_api_failure_does_not_claim_quiz_is_not_started(self): + def mock_api(function, **_params): + if function == "mod_quiz_get_quizzes_by_courses": + return {"quizzes": [{"id": 7, "coursemodule": 77}]} + if function == "mod_quiz_get_user_attempts": + raise RuntimeError("temporary failure") + if function == "core_enrol_get_users_courses": + return [] + return None + + ws_functions.clear_all_caches() + details = ws_functions.get_assign_details_via_ws(mock_api, 77, 12, "quiz") + + assert details["quiz_attempt_status"] is None + + class TestGetCourseUpdatesSince: """get_course_updates_since() tests."""