From 67a6c9b5a07c36b7ebb9d60ace67cefe6cf2e240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Tue, 11 Aug 2026 14:29:49 +0700 Subject: [PATCH 1/4] fix(windows): brand Burn installer --- docs/architecture/refactoring-log.md | 15 ++++++++++++ docs/guides/windows-packaging.md | 7 ++++++ packaging/windows/Bundle.wxs | 2 ++ packaging/windows/UTHelper.Bundle.wixproj | 2 +- src/assets/icon.ico | Bin 6189 -> 84964 bytes tests/test_release_hardening.py | 28 ++++++++++++++++++++++ 6 files changed, 53 insertions(+), 1 deletion(-) diff --git a/docs/architecture/refactoring-log.md b/docs/architecture/refactoring-log.md index 4fb0962..03e3e9e 100644 --- a/docs/architecture/refactoring-log.md +++ b/docs/architecture/refactoring-log.md @@ -1289,3 +1289,18 @@ 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. + +## 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. 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/src/assets/icon.ico b/src/assets/icon.ico index 64315490036eb9b513fcaba87b0a366612dad69a..bbe98b106bcb9bb32517cb4dba4e3570913b563f 100644 GIT binary patch literal 84964 zcmagFV{~P~@;-cG+qNgRZQHhOotP8b$s`lo#sm{PnbRP=v zRz16_pQ-`?zyL4+R8+vfK?*Q|006xH&2Vx5C&NGk04x92NJ;-EgJ1!GEjR#xh2?*; z#lL;S=m3C-$p7S!fBR@D001EHe=?i|08q*QpZR|VDS&MX0B8yZ0Af^>q>2~c7G-48%=_hkwIK+woah-&!cIjS4l7)WA%&P?_CdoM<}HZD;y7PHdI zI#!oFOn0cDlHpf53}8ywVK@x-17kJ5(`C?WqGHTp*D`;ayzYi!-d93bnKee2!OcTg zZ5_`lVstyXU2XEaCu}N~sQ4KnyB`PacpnEKa`thl>^fXWJ_e@H@^QVqhA6WN)XXm* zxhTSl>=YZ~u$XDv7tpSY1RV!Kf@xqDRB1{}!>z(qxqb_i*1xjLNohDmGvSTZktoW~ zIs;Qb;wW4Ta@H5;{5uJ}D$r%CwRBCSqsf3Q0jRS2bo%u?r#u!Gav`g+He7@=9f$NH1enqhzoqek4r#|s!Rc{VN-VpuO) zF{w6k5aLcf>Jt!*4e!q+%QsQUZgn#PDm?r6Yb6@;BS|*Zs8!l>_t?Z7+sZ~pz3cX% zkTB2<|5h^sl3g$OkE`Wz{%Blgn~Rw)Z~5=_{!a?nc;2xL>k(Wlul(A#r`1GE_{a0( z@BUU{yUPV}2XB#ak{3l?RI)NdEEA(Q*;oRr0!HoaPmbe9MkSPJuzlmaTl_EcyGQp< z2kOi<@=muqII}y7YXFu#oaQJL?;ko#Jg!4%uWy=@?*Sj1B`+b(gV<&;W_@A7{!ZXq_@&GnWRpAME^eTr}Bu=PyVplb&vMm#R1KqaK6N zQ?@(d5evx_>eajHDA1+qr4jdY4Nrd)a2M#3u*ua59_0$8X`Yaf0(G06%Dg>ezQB2Z zl>X&(((wF;9DuB(l0==DN$CFvzW#$~-2dWh*IV!z0DzkLAHLEx10C`$v4lYaSN_87 zZ9mGkO4uZ#C!wUW>G;gG@(@bU&<-3CC!+pxjdPHNCrSSB^_Nu`z)O|HNX%e(ReZ<309eK7*#+e*yX434vZdX72b&Q0l&y zpox|ZT?gwyimZa~q5=_yA|ezvx7xqEy)bb=QzC+4 ze6RzuLdRtT>Ve!S$mmM|F7atCKt>q61``dSA=gk%iS5YOc$~mq9URuiuH zSqBg7<&=z)P|no74@K?QN@hZ>xM|?xz%J X9h?%?C4uxIZsil&m>QvKe%` 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%0A?YiqzN~vz>1KIFN&G8P8rU6*= zy}?P!nFI4o|Lc=}a{guOKlao!TXpvLzpJN~Zk(BDrZw@nNJnuByDYTihIly3EcT}7 zX4XC*&$)4Qp5QDe&2JsSs!CRr)c0Po>9<0XQJg%cDuAfMR;j zD-dBZiQ{pbX7}C_TSs&NeeF*zQ6-~?l(DamjrF4|b#&bMu5~wj;OI|(`socPOqdf# zQ8g&jZ7%$!Y%~qP;@=}&PjM8X^xUnFJvQN@zu&ypLoYtN>D+nqH>uU@Yt@Zeqo$*2 z)|hr!lTj2a=xKt0nl_MMbSXkOD8P0k$py|gsoKJKU=oONEZZ?HN8ICvt*>w6{-Nr|1GU=Bn$oMJ+DyrfX|biqzZjoV4;KzsE{O z)DQ`qg*pZhEm}Mfg$wc{M(IH=9y|khEqVyJfeSCeXDTvI6=G$Cs2nM%#ZjsyvntJM z_1xtBTB*4mk$GWUx$@Y|X;YuuaLsjI{`mXf|NM4qtnq54QW>b%C4JwRXKaq8bus?P#prph<nGJZ<_bo6KDO_3bu%*Xz4)wpkZ0_&Cnetia9{=2FweUq(mM0KB#Lw6GIGMj(!i zswD}8?UhI7%o%h0lMj!5;l-CGyxci|T+h(JxWQ^|^1$$LoYYcNw{}XNrV|n@SIRmv zWv1kLR-%MEHNd2=ywzu!vS1vPg}$48*5;ii2YJLu>8MmQv6>T=N<)=WtR}RyC*xXM zIwy}C(><|$Z1-mCY}miqnrrrNGHn`E-Bl~)ass=1Lm^MowO`Ig(*V3P_jm!r0G)e* zl~}FSL~jKCl_9d|?(SCizWhYx#reHshO;5l*H

{?CLYBjJWs#2jB!#5SBR7Xc! zwX9kPXRb3jTYKUpJ#+j_*t+4xvW450awP|k0#cSEC=QLlN@$MdZ8QzQ(sl3O&?0-2 zxwYbXG~M|XYBUYNiuZh)J9%?7M{_hsb13!y0T7bR;0T5xQUCw|07*qoM6N<$f>g$i Ang9R* literal 6189 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 LNkvXXu0mjfsD1)V 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") From 68b5d281ba4f4f96f6920d1363bcd7a44c655d6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Tue, 11 Aug 2026 15:20:35 +0700 Subject: [PATCH 2/4] feat: add Portal schedule and branded startup --- README.md | 2 +- docs/PRIVACY.md | 5 +- docs/api/portal.md | 54 ++- docs/architecture/refactoring-log.md | 28 ++ .../2026-08-04-moodle-submission-workflow.md | 2 +- src/config.py | 2 +- src/core/portal_client.py | 146 +++++++ src/core/today_schedule.py | 356 ++++++++++++++++++ src/gui/app_controller.py | 293 ++++---------- src/gui/components/today_schedule_panel.py | 318 ++++++++++++++++ src/main.py | 98 ++++- tests/test_gui_app_controller.py | 46 +-- tests/test_main_single_instance.py | 23 +- tests/test_portal_client.py | 111 ++++++ tests/test_startup_visibility.py | 71 ++++ tests/test_today_schedule.py | 257 +++++++++++++ tests/test_today_schedule_panel.py | 106 ++++++ 17 files changed, 1659 insertions(+), 259 deletions(-) create mode 100644 src/core/portal_client.py create mode 100644 src/core/today_schedule.py create mode 100644 src/gui/components/today_schedule_panel.py create mode 100644 tests/test_portal_client.py create mode 100644 tests/test_today_schedule.py create mode 100644 tests/test_today_schedule_panel.py 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..eff0f9a 100644 --- a/docs/architecture/refactoring-log.md +++ b/docs/architecture/refactoring-log.md @@ -1289,3 +1289,31 @@ 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. + +## 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. 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/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/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/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/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_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_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]) From 060d16ebfa5c793a6d0ee083d967f8f7e43e06b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Tue, 11 Aug 2026 19:33:55 +0700 Subject: [PATCH 3/4] fix: show quiz attempt status badges --- docs/architecture/refactoring-log.md | 18 ++++++ src/core/data_orchestrator.py | 7 ++- src/core/ws_functions.py | 16 +++++- src/gui/core/utils.py | 9 +++ tests/test_activity_badges.py | 55 +++++++++++++++++++ ...test_data_orchestrator_service_boundary.py | 24 ++++++++ tests/test_ws_functions_extended.py | 52 ++++++++++++++++++ 7 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 tests/test_activity_badges.py diff --git a/docs/architecture/refactoring-log.md b/docs/architecture/refactoring-log.md index 4fb0962..7080571 100644 --- a/docs/architecture/refactoring-log.md +++ b/docs/architecture/refactoring-log.md @@ -1289,3 +1289,21 @@ 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. 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/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/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/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_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.""" From 123ee916336162f7952faa83dcd0db056e9aead1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Tue, 11 Aug 2026 20:00:53 +0700 Subject: [PATCH 4/4] chore(release): prepare v2.3.0 --- docs/architecture/refactoring-log.md | 18 ++++++++++++++++++ pyproject.toml | 2 +- tests/test_release_metadata.py | 4 ++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/architecture/refactoring-log.md b/docs/architecture/refactoring-log.md index 79901c1..18ea671 100644 --- a/docs/architecture/refactoring-log.md +++ b/docs/architecture/refactoring-log.md @@ -1350,3 +1350,21 @@ rg -n "Wait-Process" scripts/test_windows_single_instance_e2e.ps1 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/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/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):