From c0e965f10b7f4ddccdcf37299a5f5d1a022c420d Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 22 Apr 2026 12:22:26 -0400 Subject: [PATCH] 04/22 Upgraded code commit --- CLAUDE.md | 313 +++++++++++++++++++++++++++++++ Website_Checker_User_Manual.docx | Bin 0 -> 21345 bytes app.py | 70 +++++++ config.py | 27 +++ models.py | 79 ++++++-- requirements.txt | 1 + utils/crypto.py | 2 +- views/admin_shifts_view.py | 146 +++++++++++++- views/admin_websites_view.py | 95 +++++++++- 9 files changed, 714 insertions(+), 19 deletions(-) create mode 100644 CLAUDE.md create mode 100644 Website_Checker_User_Manual.docx diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..52c34f6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,313 @@ +# CLAUDE.md — Website Checker: Complete Technical Knowledge Base + +This document captures the full architecture, design decisions, database schema, module inventory, +feature set, and operational notes for the **Website Checker** desktop application. + +--- + +## 1. Application Overview + +Website Checker is a Python/Tkinter desktop application for shift-based website monitoring. +Regular users log in, view websites assigned to their shift, open each site, and mark it checked. +Administrators manage users, websites, shifts, credentials, and run reports. + +- **UI:** Python `tkinter` + `ttk` +- **Database:** Remote MySQL 5.7+ / MariaDB 10.3+ via `mysql-connector-python` +- **Entry point:** `app.py` → `class App(tk.Tk)` +- **Default window:** 1100×700, minimum 960×620 +- **Default theme:** Light (toggleable to Dark) + +--- + +## 2. Project File Structure + +``` +website_checker/ +├── app.py Entry point, shell, navigation, session management +├── config.py DB config, connection pool, schema DDL, migrations +├── models.py All database access (data layer) +├── requirements.txt +├── CLAUDE.md This file +├── utils/ +│ ├── crypto.py Fernet credential encryption +│ ├── export.py CSV + Excel export +│ ├── scheduler.py Daily email report background daemon +│ └── ui_helpers.py ThemeManager, COLOURS proxy, DateEntry, widgets +└── views/ + ├── admin_dashboard_view.py Admin home — KPI cards + per-user progress table + ├── admin_log_view.py Activity log treeview + ├── admin_shifts_view.py Shift CRUD + PDF export + ├── admin_users_view.py User CRUD + ├── admin_websites_view.py Website CRUD + visibility + credentials + ├── change_password_view.py Self-service password change (all roles) + ├── email_settings_view.py SMTP / scheduled report configuration + ├── login_view.py Login form with rate-limiting countdown + ├── reports_view.py Shift Detail / Unchecked / Summary / Chart tabs + ├── settings_view.py DB connection settings dialog + └── user_dashboard_view.py User checklist — search, bulk check, notifications +``` + +--- + +## 3. Runtime Files + +### `config.ini` +Created on first launch. Contains [database], [email], and [crypto] sections. + +```ini +[database] +host=your-mysql-host +port=3306 +database=website_checker +user=your-db-user +password=your-db-password + +[email] +enabled=false +smtp_host=smtp.example.com +smtp_port=587 +smtp_user=sender@example.com +smtp_password=secret +use_tls=true +recipients=admin@example.com +send_time=18:00 + +[crypto] +salt= +``` + +### `app.log` +UTF-8 log file in working directory. All INFO/WARNING/ERROR from every module. + +--- + +## 4. Database Schema + +All tables use InnoDB/utf8mb4. Created by `initialize_database()` on first launch. +Safe ALTER TABLE migrations run automatically for columns added post-deployment. + +### users +| Column | Type | Notes | +|---|---|---| +| id | INT PK AUTO | | +| username | VARCHAR(100) UNIQUE | | +| password | VARCHAR(255) | bcrypt hash. Legacy SHA-256 (64 hex) auto-migrated on login | +| role | ENUM('admin','user') | | +| full_name | VARCHAR(200) | | +| is_active | TINYINT(1) | | +| failed_attempts | TINYINT UNSIGNED | Incremented on bad login; reset on success | +| locked_until | DATETIME NULL | Set when failed_attempts >= MAX_FAILED_ATTEMPTS (5) | +| created_at / updated_at | DATETIME | | + +### websites +| Column | Type | Notes | +|---|---|---| +| id | INT PK AUTO | | +| name | VARCHAR(200) | | +| url | TEXT | | +| check_type | ENUM('daily','weekly') | weekly = once per ISO week | +| visibility | ENUM('all','assigned') | assigned = only users in website_users table | +| note | TEXT | Shown on user dashboard card | +| is_active | TINYINT(1) | Soft-delete | +| created_by | INT FK users SET NULL | | + +### website_credentials +| Column | Notes | +|---|---| +| website_id FK | | +| username | Plaintext | +| password | Fernet-encrypted with `enc:` prefix | +| label | e.g. "Admin", "Read-only" | + +### shift_checks +One row per user per site per day (upserted). `user_note` optional. + +### activity_log +`action` codes: LOGIN, LOGOUT, SESSION_TIMEOUT, ACCOUNT_LOCKED, CREATE/UPDATE/DELETE_USER, +CHANGE_PASSWORD, CHANGE_PASSWORD_FAIL, CREATE/UPDATE/DELETE_WEBSITE, ADD/REMOVE_CREDENTIAL, +CREATE/UPDATE/DELETE_SHIFT, CHECK_WEBSITE, UPDATE_NOTE, EXPORT_CSV, EXPORT_EXCEL, +EXPORT_SHIFT_PDF, UPDATE_EMAIL_SETTINGS. + +### shifts +`days_of_week` is a digit string using MySQL DAYOFWEEK: 1=Sun 2=Mon 3=Tue 4=Wed 5=Thu 6=Fri 7=Sat. +E.g. "23456" = Mon–Fri. Queried with `LOCATE(DAYOFWEEK(CURDATE()), days_of_week) > 0`. + +### shift_users (junction): (shift_id, user_id) PK, both CASCADE +### shift_websites (junction): (shift_id, website_id) PK + sort_order +### login_attempts: username, attempted_at, ip_address (indexed) +### website_users (junction): (website_id, user_id) PK — for visibility='assigned' + +--- + +## 5. Module Details + +### config.py +- DB_CONFIG loaded from config.ini at import; placeholders if file missing +- get_connection() → pool (pool_size=5) +- initialize_database() → idempotent DDL + ALTER TABLE migrations +- load_config() / save_config() → config.ini read/write +- config_exists() → True if host, database, user are set +- reload_db_config() → re-reads config.ini, resets pool + +### models.py +Each function opens+closes its own connection. All writes call log_action(). + +Password helpers: +- _hash_password(pw) → bcrypt rounds=12 +- _verify_password(pw, stored) → handles bcrypt + legacy SHA-256 +- _needs_rehash(stored) → True for 64-char hex (SHA-256) + +Rate-limit constants: MAX_FAILED_ATTEMPTS=5, LOCKOUT_MINUTES=15 +Password strength: PW_MIN_LENGTH=8, requires upper + digit + special char + +### utils/crypto.py +- PBKDF2-HMAC-SHA256, 100,000 iterations, 32-byte salt from config.ini [crypto] +- encrypt(plaintext) → "enc:" + base64(Fernet token) +- decrypt(ciphertext) → plaintext; legacy plaintext (no enc: prefix) passes through unchanged +- reset_fernet() → force key reload after config.ini replacement + +### utils/ui_helpers.py +- ThemeManager singleton — initial="light". toggle(rebuild_callback) flips theme. +- COLOURS = _ColourProxy(dict) — always delegates to ThemeManager.get(). Import once, always current. +- THEMES["dark"] and THEMES["light"] each have 18 colour keys + 4 cal_* keys +- DateEntry — Frame subclass; .get() → "YYYY-MM-DD", .set(str). Calendar popup with prev/next month+year. +- make_scrollable_frame() — mousewheel scoped to Enter/Leave to prevent stale-widget crashes +- scrolled_text(parent, height, width) → (frame, tk.Text) + +### utils/scheduler.py +- Daemon thread; polls every 60 seconds +- Sends HTML email once per day when now >= send_time +- last_sent_date in-memory (resets on restart) +- start() / stop() called from app.py on login/logout (admin only) + +### views/login_view.py +- check_login_allowed(username) called before authenticate() +- Locked: form disabled; 1-second countdown; re-enables when timer reaches 0 +- Not-yet-locked failed attempt: shows "N attempt(s) remaining before lockout" + +### views/user_dashboard_view.py +Key internals: +- _health_cache: {website_id: ("ok"|"slow"|"down", ms)} — daemon threads, HEAD request, 6s timeout +- Dot colours: green (<=3000ms), amber (>3000ms), red (error) +- Mousewheel: Enter/Leave scoped; unbind_all in destroy() +- Keyboard shortcuts: self.bind() stored in _shortcut_ids; unbound in destroy() +- Notifications: after(60_000) loop; plyer first, fallback to borderless Toplevel toast + +### views/admin_shifts_view.py +- _export_pdf(): reportlab A4 document; one section per active shift; user+website tables + +### views/reports_view.py +- All date fields use DateEntry (no manual text entry) +- Chart: matplotlib Agg + FigureCanvasTkAgg; bars green>=100% / amber>=50% / red<50% + +--- + +## 6. Authentication & Security + +| Concern | Implementation | +|---|---| +| Password hashing | bcrypt rounds=12; SHA-256 auto-rehashed on next login | +| Credential encryption | Fernet (AES-128-CBC + HMAC) via cryptography library | +| Login rate limiting | 5 attempts → 15-min lockout in DB | +| Session timeout | 30-min idle; 1-min warning; any mouse/key event resets | +| Password policy | 8+ chars, uppercase, digit, special character | +| Self-service change | Requires current password; strength meter; same-as-current guard | + +--- + +## 7. Key Business Logic + +### check_type +- daily: shown every shift day +- weekly: hidden once checked this week (YEARWEEK ISO); reappears Monday + +### visibility +- all: every user in shift sees it +- assigned: only users in website_users table see it + +### get_today_checks resolution +1. Check if user has active shifts today (DAYOFWEEK match) +2. If yes: union of shift websites filtered by check_type + visibility +3. If no: all active websites filtered by visibility + check_type (legacy fallback) + +### Soft deletes +Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first). + +--- + +## 8. Configuration Constants (edit in source) + +| File | Constant | Default | +|---|---|---| +| app.py | IDLE_TIMEOUT_MS | 1,800,000 (30 min) | +| app.py | IDLE_WARNING_MS | 60,000 (1 min) | +| app.py | APP_VERSION | "1.0.0" | +| app.py | VERSION_CHECK_ENABLED | False | +| models.py | MAX_FAILED_ATTEMPTS | 5 | +| models.py | LOCKOUT_MINUTES | 15 | +| models.py | PW_MIN_LENGTH | 8 | +| user_dashboard_view.py | NOTIFY_MINUTES_BEFORE | 15 | +| admin_dashboard_view.py | REFRESH_INTERVAL_MS | 60,000 | +| utils/crypto.py | _ITERATIONS | 100,000 | + +--- + +## 9. Critical Gotchas + +1. bind_all("") is NEVER used at module level. Always Enter/Leave scoped. + Every view's destroy() calls unbind_all("") and _unbind_shortcuts(). + +2. COLOURS is a live proxy — delegates to ThemeManager.get() on every access. + Never snapshot it into a local variable at class-creation time. + +3. bcrypt is slow by design (~200-400ms at rounds=12). Expected behaviour. + +4. config.ini stores DB password and SMTP password in plaintext. + Website credential passwords are Fernet-encrypted. + +5. get_today_checks GROUP BY includes sc.id to prevent row collisions when a user + belongs to multiple shifts sharing the same website. + +6. Weekly site logic uses YEARWEEK(..., 1) (ISO week, Monday start). + +7. The email scheduler last_sent_date is in-memory — resets on restart. + A production deployment should persist it to the DB. + +8. All log messages use ASCII only (hyphens not em-dashes/arrows) to prevent + cp1252 UnicodeEncodeError on Windows consoles. + +--- + +## 10. Dependencies + +``` +mysql-connector-python>=8.0.0 +bcrypt>=4.0.0 +openpyxl>=3.1.0 +cryptography>=41.0.0 +matplotlib>=3.7.0 +plyer>=2.1.0 +reportlab>=4.0.0 +``` + +stdlib used: tkinter, csv, smtplib, urllib.request, configparser, threading, calendar, datetime + +--- + +## 11. First-Run Flow + +1. _boot() → config_exists() → False → SettingsView (locked modal) +2. User enters DB creds → Test Connection → Save & Connect +3. reload_db_config() → initialize_database() → seeds admin/admin123 if users empty +4. Login screen shown +5. ThemeManager(root, initial="light") applied; shell built + +--- + +## 12. Deployment Notes + +- Python 3.9+ required. 3.12 tested on Windows. +- tkinter bundled on Windows/macOS; Linux: apt install python3-tk +- Create DB first: CREATE DATABASE website_checker CHARACTER SET utf8mb4; +- MySQL port 3306 must be reachable from client +- Default admin: username=admin / password=admin123 — change immediately diff --git a/Website_Checker_User_Manual.docx b/Website_Checker_User_Manual.docx new file mode 100644 index 0000000000000000000000000000000000000000..6614a9f17f87608714f3f8570e699e15d8f0e15e GIT binary patch literal 21345 zcmd43b963U(>ItC+qP}n#))m)wrx8nwryJ{$%$G zt5;X|+Pi93?W)@KYXun)P-vikjsx*Do&Wmtf1bd;f8Fh!Oc)gYA4y>TMWXLyYUBKG zLdgGmm&O#FJ;DzlAfoRV;r=I~iM_F_t*M<0y@#z0{XdV^q|E#rVnhtlC%ez(q*8#c z+<*~2M+=2;xSENN@HeqX^7W2cXr`~Eh3B7L&3kRu^6dFgGrj|xMG$9EN=Ks-gdu_0 zV!EI2-l`dQYw`^%eyBm&zBD8)wk}&3t zFAC9&BJ?%|r2v9{Ko^Kyx9m;5qmAkMmrLUpmwn%8*o{O4($8gBp}Nn`8H9gv{J$AV zAmUMcP74I&j|B{b^e;yKPZnPJ>f7VUr0u9zaCU%jsS{W9EuYq!tCtpA8`5f%Os;4c zB#1S1NvbVwEh+2LWxcb)n2;l|XiVen?2-q=b>mzb5hRUugRZstp!f$qNsM!qRc6aY zCQ)V*i*+5t(>AW@Zt3=MyMF$9F!%ptk6Z1S%T$^8BT>~dN&uF`RwVGX_Up-A811k@5;i(GcgU`g)n4M(+ut?#Nu5JeKzGYy*x;6Q z$-x=!u*4*sb>XqbFQ;;p8nGeDzuHfTfTW${kAm+eGqlMsV4 zZkHe(0wZW%N$=}2fX&M}ybCSSTUhD|FJ`>nQC*2(0`C#Rla-!fPh2=6jjb0}SODwI z(67CKWL`fDhA5Kv!XT$xTkQ#jn~iM}oDzS^!IgKj(MN#r?ZiCcz!9;Rm*h!T2lU-FR^g zUKc{_7Xmf*`uG-C@E`*)wKaP9Rv*hEwo7*v^l+?uTD^>jyh|mC#M~SUB>Kzc}6)kPPm%Bd0l+6rUA69hBsb(0V^XQ zHXKnTGd7PJJwA}Z$`W-D_K8-BP`>o|AD0L3J6}J3GXr$qp*em|p0AG=^QK@~ZunYo*ouVCZP2BW;{J3!w(zdpZS|17E4#AHdu zL6I}9q7cgW8)ZRiG-CRm;MgUOl|NIX>;&panmQXk6pme!#JJ25bh2#_Y^YVTHMKMG zH{gsj$EPjeAI1f>%_s`9C3IM{I<>@zyS%i{=xu$JSS0W8yJ?~%FyjT+nS&)!5&r5p zEUu_;Rc+Rvb704Y_l?xXuNkdSY#3$Vg4b`4{^pPbyN<^TmPktHpsaOiqmmCoxGqy1 z=T=+=HB3nT^%JVM^|a7wZ3n7^J-94BW;T@M5j1VQ!ozzc`B$%1qds7j0quRF&dIb_ zQ&+Q%V3i@3=P=)_YN~2#Yqpl_0m; zi)+s>SKc?5AQ*d4Et0^GCT5_*0!u}15QpQo;ZLXANV8r1^!YjOCMUC?2WWsV}ievTs*T|PW5 zPI8WS?;>?7bWf&#Xk&_Go~f-1xIJ0%?y7daaii$a_;&}si_W)!a`UdQ}WuoD-tLfFDx&ylnKi@S9W#!)6kQ6(4c zot-+VAefhP-z%b37iUlVdRd$(iPM7Nvs(+2k}N?TP9fiI1oG)#>U@X;^Lo;mWEbRZ z2X&+odOogT?F~^B;YcLhNgk8F$Wgm4xPiZT@m@C|rDSfVoX3?zb=8T1(6G+9#XRV;^=S8%lR9O+pX zY6M~SuucZ|+(=QdPM9Ryt#K~DKF0HGZ2gDS{5zNX8LDEh#8czF*gNMmeqG{wqIX*4 z*Vx)M6q(2Ta>g>uzl&wHsOdTa)xrCz=xS#Z`@-IRa+sEv%r*+2JbgPZ&a*QE*Wx!~ z5=Y$93OU;&5A}zU*mahVqhy-;h!&N~sem+LB@}hjv_~Mr*2s3cxkk>)S{Bp%wz5Ii zW7tG^pI!csBzQBWri55QJrg#!yqjI#AT)%-Z=Ns^S=RZ}$H9RFg#7IEp9Oqde%YAO zrIRfo=^j*XV7M6Ya7Z1u!5}4GkIeKlJv}wSu~c?D6|$Kw7an54jBGB|%hMZay_(wvvDcEFzg@Fx=%=sTnOT1Tbb9%N5l9`gN>6+MZA$nn85!f7($bYtk_>#N$ zlC??5R~CbC6dPgx8 z^pVVXOK6qhoq;LNh~3RA&&m#qRh{>DuPhAYKn~J8sa5EI>%{qVHQ~XUvrcA9$b|Cm zY=+8TX>1@jc=hl)C!kDB$8PV0IpI=vV*Vm|m=CzBb@?m3Eg%X6#P-%R!;&?|p(>FT6+M)5)+v6vVW!1je$#goLBZ46y+F}987rAR-KC+8BlV@RHaVeG?ZTRk^ILL@a7TU zdvn#d3gOGLbgv^`$P?(@Z_M7)6Kjr@2JOHbfdhO=!K=E)fhDt5rw5b`=;jbA<~J?_ zZukGA6{gtD4jt;ShQ(-351zybLQIf~lXmBvQH0HNSx;NmU;Q)A%ow|BD_dSba<3m+QBha0E;{IMrE^vTncrJ-A0o6HV~ za}bV{vbdvHVEFwOSQ*j^M86G9ts^x*D(0|iHzF!Xv0qv)R<1pEvM_7ef{EDNuy{NX zFjArnC%}25z)$t+0WtZT#@04%DF<9>0{u(n_rvuin-dH?fbb(Hx-O6^>x+0S+ArA% zg%Gyo%e&$WSWj7;}+64VmNvdN4`$QMa{Vs$sO)iZFQLI8pM7SXYoS zm5PH{n0_?Oi069G4XzCwCz}~(B~673e0=bRG`;&)=q5R^EA~!`CIt6VYlH`TA*NM1 zzwQS$MB*IaR{o0NPPs%bBO56(6d&D}Q9xR5YkOYFRtEj{ zGz30}Hq-YOcg|CI!w0air6jGvYJeUml$Oh5)wrkT;vMEw&-9P2x+|% zeS3-D$&DkfiKJ=pUY2-n#;QLmOG?KK$1Z8aiGM_(Zt}R8LrKVl70$3}3;Q)rFJs3( zUKum{ll|3kqZ3b*oef}0vIl)GGo@}BC*DS%XQmS0#17f$VCvTj%v=~XVX5KGVQK!e zkrf~+X%ktvil6@J1$u@nmlxSC@}zh}+#%FjUF!)O)Nh|j#z2@vN&H6SoU^lM);Z*g z0-$bV|B!TL2}4#Hxjq}nz{4ci`MUKrjKIG;GE$3F&1FV%Rl9Y;QrTn)>=_k5_8c~& zt{Vx$TDlTvS0cWUL3soX6UyQbyxK zh)Vc?%WQ&k#B62dgk7z2PPSM~)H)DtKl`3Ej18h3wvlO*N=i^DNE=(O;L{MTlwfQ%TS zC+lX?>O!BP|G+2RyZa>#fItWIt4L44gjhq!T_>0JD9L``Q7Ct9OV&Q&DmactO>WQxwB$m`P4ZN=)M z+6R|3Sg&0{vPj6IA073Vpf7`VK6WUOXkP)D!YlxnAfb;GQfMeJy2B7oXJK3SQOR-1 z2S2~zouqP*wZF^lWr>35+4CEIsxM9Ec%NnNopeYxruB*_#Mp%Nd<-Y>A4{tz$5`fp zA}IQN*}L&Gkh$(>zEB#eW^ciIQk6G@#9*8qf7B?Csq#)5!OV`}m~o_mDTXI*H~{j3 z_Qp-XBBu{Nj|!9JOcAU6o#wZ|wo`2$-!N$p3GqsUA$d*1niLkV8^ zkV_;y3DWL89>|2@q;7JPmC^jjlhi0rvRne(6*geRGE}$2JS}g|$`b{sG@|UHL$U3n zn6?@zI;3#E1=PZbIl`+|xx%b?O_?bf%uEfhj1OPL;5quT6QdX}==`zb*pBw+BEw5# zEgP4nGf-7rVxuK0{ykT4RQj7L9Hn_L`)!-oo!{df%*UNGF66AD-lhn5(i!U*)e+%A z;Arg&R;@1eb`B>_R3DLR^jC@&7C4KBYs?_F4Pv3|xY{8036YCWB+Q059{*HPlT>;$ zMn_rO3dT92oUe8d9b~AFL*)?TX@3&bdi^^u6Bx~H<@}|4%hwy9b)+O{*p)bvw=O6U z5h=KQze2Ke<^;Q}Hx|B%h98`!FQ3hWu{b)9U@_NlY%z7r7{#sNNdJqW_RVkS{51!{ z4eI9;qdB$qc~ia;xrA(0bvQquT=m?0n?# zT6}^lDY5bPIpoW%+;3j)+pei)@O`nYqyUj8^yEr^#=8rnMpwh=zdmu+#^aNlgSTmQ zuUftAm>X~+)-PU>L=>yWRjh%&5JCGhT(o-FIgP9@cOP9}arGLa^lN0ige;4GOhI-E zVK!(0-;KOY5GL!%{Jd02G=!0zXRMi9Mn)tM_=+i#CA#ssJd}H(`Gck7HA^IgvjGvS zc_j_h1n(I3SqD^SVkL!4=IjOaA*b?1r6Dz{VboMfc=YDGK z444+SabK>I&LJL{CMh`)(M#Dzdh^O$Cb3n=hIyy(G4)JhseupD9t>UtLn2V13LtE$ z7432BDdg7WcSe>(wzn?v%$@lWl4L->9Diu1Z&Y@1Uvuii$^mMf!WAtQTS+LD78C8b zS<`2sH}Q4grHfIblN!g7J&YJ%_SjubZ$t@7@j3*qQ#a;u)X*OxVdLh)xolW_8y~-o~#~`X{0%H(de+HXzPmD3fZ|s$#%kyJkT%<&isFl zYTY>twme7n`adtyWJkuCb5a$vq4Ru)pAc=k*azD-YhsCU$A!G_PL>7>m4Q8%fWWNO zV-y&E74!Q98xm!8*p5Ox_D$Gob6hi&V09l&?;1}Z?Hxe9-Qm zwKrEvNzNaz_sOvi9*#s$DhuO(@prIFN!(XEH7K@LJFPTLiiy;+#n81AN6fIyhJvpq zl8Xs?06HvA6(Nn)LKr*q7Z&obJC22ez3b0jj|vUlh`Db&+z0k|=BeF;>2q9X_%BWu z)*Yub?>=jx9N$s>SI~{ev|Ooj0QKdUXS%xSC`g)GvM5gwwpGrKh8-KggXR4rm_`O` z$Zs{}wC+TFz(gjqu*NBSiFCaofe5o;qZ`E3z6Pz?;dIixO$t8pj4Oz+fZ74h#MF-v z;1iQEHsV`d{Mv}z{3CxRhzR6ARJ*Z-c2W5RKa!;zxRRhnEk`C${5wE?N?4I1l#0$jVVRvZQoXzNDmDrELRw)LSnbe`f-g^!qvO zHQC2wA|&WNE@>ctr`e4e7+feFLJ6EOJGmC>%2^iGLv2_f5o08nhaRKRU_(e#Xwa{_ zx)h93n*d-<(WhupFsv~&0d29Sj4H^pg|EZ=5P;Wnnl}HD^z>!Vftu>mmLshan?Xm%!?4XN{G)rNu|54WFY{+tHjft>M3K*Hj#&FkgT#bY->6TM0V|fg2bx0PRc-hWlVvU7=|tIltxl*vV^SLDC$}%wg)e4Abdkj zM=Gu{R_*4ZWvJe4+hHYrxV-IzsdBQr#O@tmM{PBoz_e`pTnfw)St0g{&-had%{l^- zV_*zfS6Xm?QoK|KFmr+nC=KndD_xEX+SQGq0 zETuo5>1?Vw;z6Q@=QhB!1kF6?un1+s5X4L#DG*wLo3X-eAoB}5Cc{Ue!P@%|vIb*< z#c(MQf}(;rO#KbQf5-BdR-xf|OsU>X!O#7NFC}F3hK%?{eZ_nb+&adJTHSiCjN6eZ zo8e<#%+jOc!ZK<&Y|Ju(&}lqXnwe~T*aDRYtYw}n=xUfY*54y21gn@5s#lWeu}f>n z0vcxpdcQsiYO2+3wPV0#y9Q#%J*pYtMh+z5TqI|)yS_{pN0yY3PBoV&0|vk!3d5dg z$_-s^Tg2)cYHVYEco`GtXM=(*NqF&X*yZ#d!f1AVJ?Z;Nv?S=gw{kS)`e}z=1BC{p zlY(~BlLZ^EyboWi)$Z;nD%=Z z^is|9|DEiAQ{-;u1o$YKXz-?n*s>pSBRNrHQR!S=XrRZ4DykKK+F{O_F?_1cA46F@ zO8#*ni4@8P5O%=u)2@enhq0F6EpR(A7cO=Q@_^ZZ`T2Ah+svE0NsxO9wIEVm?VZ=a z7buRSuY2a?S~9HiQr*M}oe!gqhK#|*evcMOrTxBk3jNX$%>uTPk6iBbm_Bm8Inom# zyqdhKXQf~)ruy<_VGsO&VvB9L%T;aJ9su4A1?SQCCBkH`8H-)US)`-WnI}zv0$xyPix3Yd=ZQJn8uN5d z4bICJ*fSW-%EGku%D>{=y3GaD4P|r;-kmO{ZAp84R_5>a=f)~kft9C-`~DRD>*aIH z{yU~59A+pOV{>e9G>yF&EBE*J`8wdq$8u!nh2wEn+#WQ%Kyx5*JihXD7DVAOYX070 z=`VQw5LgqpM(wE=MJt4q!ucnTUEq}Te<9g$opl00X9F=Ll4q6>_fMmGM@qQ1673OP z#>$qQ+8!m^wksm%Ed8SNS{P!s)?nO*w?i`CKZk*3G+R>JKm#<%mxHXjtf zX#d5uek=>ixJQJlW%P`~yzZ2CH(o!j^}`@UknF!xTA;gsu{I5me5dnZoUU~J5QdL9 zD%QVBu=?n|KN6PoTpkK^WVU%EBpeVy#H_`}INOUK^+D0LVM#-3;fd1=G593A;@-v` zgYF-qrMN9TxjW?3_0K48L!4@s!!OVyi_c*hbevljoMcs!D&jPu&e9X;I!c9^8vuu6 zBzhZZTX9M_P|=Is(^{aLHQj$qdw-_GsrycL>D1pKP5JWS9q!Zn#A^O zeGuGa2JKewJ=sg~q$x*NrpIc#4X@2adM|=7DFx0mqziUc`B?%2av`yRp`ei` zu*1bYD4owl`66SDL&KN)UJl447FE1jwqY5Mx)(qF3PM;dPotDNnE-W^z)!6xaI#4C z)OMak%Pve1y?7@6vu3B5fC$#R@L)>!>~R;q^O@MavoZBQsYLi>ZlQ_vj2qWG;p zpMFRy>Hv+>`t;`y)V?y?havl&z4%PVS~0IQai9M3%wh>AdiXRcFy0%pJG-);^e940F&WL}d?X)KlzNhI_ zukBLlY+;$E(n_^C6hnNXZ1XYigDhD8HPjt zac3;Fbuea@bKnF_ajH(uOlxhbN_h7eRD>$F1;gLw4xMR(E44l0TgqadIVL#7$LfWC!HwO%ANf2_(OseloxarEzt!Bo8x>kz0?7#CY_~Q@ znT8WG=>JykZAEU`8v3i%TkfO4zRBpY7&LU@ zXojCzt+nm4ij}kxUa| z*G67eO^S}l7661JuZlr_%9B~pA6Bvrl0(Sc8FK|}AeSzx(|V*6iiYL_fXzNAOC48Tjnl+>#0 zK+i0@voBbASbW(M5@U#}XEBF@7A{!8xn6@O%nS;PG;VbhTOG#QL8^~LCG&$u$>kUw z)8qs|E7Y{d+dAGv>7#Y{KBvx}959;QMm5C@aZczqQzCWSl1o&&?Zeip>AO#@az%Tm z*pG!Q-*-3|S%xL|&L>UDbKQ_ML`4WR%3B>=zz;96n zb7EaUk3});FitIxI!w`Yjf3i5k7jaS2B$SRl<7o*BG~3-(<<~y_z1D-OcN$2gFqO3 z6jhltw#LiFZ#sd_d}U$=kg-VW4z0{wQJA3Hw@K2D4-m+~!p-L0k@c>z%( zQyIcbL@Lc+ZLtSJ1YW6kES_pZI5cnz;YNdo(nHeT+^_0l`9@xZW)b7;3=KuJAOdEiIVxql;%sP8^) zgL3Jk9I{t2-vya^h#azojU)KK*&m7Sxo4-bDG{Mz2{V-~LC0?@h~m=uFmI_(iVn`G zDWx7GQ4y;^1_fO^)glTfDC?66oiWs6go5b;F>Pg2M+nuy2hA@a!Gfni3MDmC!i3!t_9`}y*pW!zfc1cjOeCDmrj3a# zu9dDYV(>U=o5Ow?49Wn@tj9#qMp&{$BWdY+71a2UZ(-Su+t%yqnYe1qL7!wCqq9c% z;-6TA?GhxnCW^e->Yun%K;6`-sBLyM?%I z*mQ4$TeSDv#n@4G0MuTHxyn0vkv%mXKE@P*SP(h<;wys9qfQ)K#qojd8%r z_Bpop$%$He|2DD6-QUURIbThkM#Y}Pt_g;U_@DZ|F57)e%){}zFB>X~Tz3GogScgh zS{64#UHAwSV5>5|I!@eGf4uDT0`#>XJ^9=;09zGO5eQF*|B)FGMsk|d`-{cVp-5PV zoiBJ5g>-(DycllJxLJB4KL4E;kU`zT6q%2Wr0mS!V*=GCAU<{UIptiZovl)=Mz92U zNp2-?;V;z4hq_q*Aj}Qv*~S|Vp6N;~7_;%qNPWHTiSlEDYeIy~?K^XE9Gb=00s`^}5L17Z&OK1;&e4*5W<-lJ96OzUovc9F_7Uhjf5F=VT2A`||ewx=kaCOK>yiKxCcZM*Pv|XGa zeS@HBgBlMiZy<4NbSVrYOt91UMR2JNYYh;-EHn%s`NMe2sG4#`j?xee)tnBT1b5;;xBZI7k=lQ z25BIpk{vB=9UC1Ca9j7>uw22iMz$)ex9AC7*8V=Z71(%7wr=xxtzz<{@cGf#u{C>N zIsGWkkH6XZ2)i1y1jun* zymSoL8q5U8+tsDP(58a&xupwTkJ9QMbcj{=&Xg8UJ4F+4emZCc2*nLKeE=c+292cY9VRPvL$A6cCC2&Y4!s8FryaC`2l zpA9Ix4Kt ztEfp}_30+y-UsEV!R+-N${j_!eBD0vt5Z>xyLAQoPnkCCOREKF^1W@KiVqh$hKtR2vw>d>`l_*y0EPOhSx zY~r5b8R>y%`Yezh_bgz-^HH8#1QCVsC8Wg%vLy&RZt7J^M^^T@@{Ac+4`=Y%VVgtM zk`z4He367)TyUI|#V1AYxd-v`Tu6@ge@W|L^99;3YK~kg;mKknbO-!TdY;=#|rH$aR+<&r~|OpLXYqshuaP>RNU9IZCn_R2Af zP*#pE4DX7)p?ohfzG6$uo^{*YHuxb4%bS0EF?hft;-lwqsVW@h%w1245unsHaow|NXd8mMD9XpC6RLf$mM%KLj!P0cyjN&~l!Yb1#tmASy4q3E9 zOtEFiB2T1a=Em4jGg{`nUDjY8yJqBbsY=}(&3iwZ$Avf>$(i1-0`OS2rm3|p4S;*v z0QXNgfLyJU=yKz5g?A(W=$3_!oAT+sd z%dX$!=lQ12pvUIl+ZR3}o~-Td%FC&Ycu+aWCPrt41i8ygqzzn16?)s?3)79s!NX*i z+n^5Rh&4%0_JK+#kY{El!Low6E15ABVyHD>f^*c6$;3XUl{+=3)vRbE<3TH#FgmSW z(t!^vw+V!hmj(oNgkPHJ-R)}Uyl<4vr_D)Rj89BTQwx2QkRf2x>ZT5rHRZKHz0}Cs zNN*aCr&hGQh~WR+W+RZYz03%41|NW}V_S3}Ce}KL>L?}59R$FuVmT;gL94gs@36>vej`WXfybC_6DG70T~r8gDSy|Exok; zG(!0{PkL_?&YOBf7QSyCb862$W&+o*i2?99pA3$s&={}lcXb@r%O17SGi`jSA+jTk zW`IYJt3w4$Y0M|o=HRwc$&lOwJXTSHT;^7_YHi!+Fm zrUjHu%R6YEZ?PC->99^$t8;APgw71wu(yXlm&J9~IzanD0m!V2 zJ4h>a^bBbr^xGiAEr{@T7$MAz2*vJ}TPZL>sxIU9yV0~&n%^u1vX%}aKqMyojYLbKm~ZEN0rd44`v<)^Zm?XH0I|=9 zkjQ{AwgP!>9bp0*OQSe|)%8}tW2x17D#Ly~hL*bs*IA`{L68VCdi zoLC<$v)yj1;4?%lAn6hjvG8wC1sjw(Jf~A%^5%ma6n-&%(6(sq#|do(v@u-`P1crP zs+pB`QD#URO(+hi%7rExdZtLzx=CX}F(03EZI>w-51X{xq_kgH;g;k)KeJeuxL6k) z8c!@L4@8m0OwNR6pYVJ|!D+MLyk+revlJ2q4Daz0L4Infx6&EA(iwgM;WKRS3Z~Bu zWC>txmjJBDL=lRQr493$cSQR9q-kK=kp3D*p3CfLN$uLUq0Yz#9^?%f9(tG^ES(?S z;9g0!`*yL1k4H8damgmZF6NGJC}pC8X2;tv&od%k_i;$#P}7JFJ!tL9b#!e2^+cSF zqvYfo5VFFOt4IxrcKX9JVxpkgtJ!-EJ{C=Ty>M1B!oL23xRDIS$ql^-h#{)&PGhp4 zXee>DjKc{a-#D2a33IfiW>ZP-^v->$QR@4}E4rqF`lH;>{;>H>ip*4%lKXy15|3?U zpGfT)6cHwijKaH{S$oZFCegH2Q6B8cN$mkHYrUYRvB{Z>jjXf9h{wdMboj%$t-xid zZ`B6td0lVH(!YIaS@5grCk+cj8S6vag89XQZw>o<6-RfeR>Nhn;PH5o?C5C*mJl|K zDyw^)kYnXa+Dzc5vIfDN^dGV3^-NhBj;x3igKv))HOi8~jcrzAdqjQ8D5?fgq^|96lH)VJDxLm3pD>>d7B z{D=qSMJx9=ek2$e=)YhO|0XlGcQXA)zf+TRtp^!V!@i^^e6yQpi3@7#;p)g?OKmRc zNhRQ{`6ZdFzWi*LwC1yQ$#0UwpL1F>2vqQ{20SfYA{RpPAz3+u&ut@@tHgLk5)ou! z)NtU)xPv4fDtFVI4tBS&VCW@p6))!VoU{vdA=8X5U*S<7q-0LnH!;gJEaL5==?(;J zU^q!g$Dn~xXc;%5jCnBUHK|_moBL|7!Ges-g#zL6%cP>}Y=FxC4ng=b6Q{yTz#z=a zaLz$6K-61i6=yzLYzU0#PqoGSEoz+GDdQ@rLjLRkL650g=3bT!q`W4?cQs>w#o%!Rvz zRNg7c*L}$jSG|He{iV&7xlxZ)b>}Z&tl|^sU-I~Gd`aIZa8A)TlefNYDDuC|sGX~= zk*SlVo%uhUUQ(B_+h9cOi4x48$|$7e2}2~az$tc^CzyK#Y8KrPeE|`Zx~qMs@3d|e zi(gJoNuhiFL)vmws-Vuw#d5<3&n0qkxu3>TN61~IGz|3Qvx}5Co)V=A%de}S8xxR@ zMXNMjOvt2>0IQSpD>ZTaHN@iL_>L6&cgUj0V6{l3aGfYfoeCvro*0IvN+l_{v#|=O zVcIm*m}dMR(J9%Vatw*MlV>U9plM2FjJJvUA4*!}q*JtGGPKY+OAdeCSt}yyM=KU_ zad?(k#v6JkmLkQ}SLdKi7ino*1*#2AV4dg+(T1f+#zRGAgsKYS=H74VMd(eFoQY*e zAlVGnf>GCUDxcWb3pEeA!Kv^!LeYL}RibXYiG3f9Bqn~mYk($G1#h!>3&#E!@XA4PovRWA|Q+tKm- zoF3y+18q+WrpTPww)QRu+{Bv$(2OesA<;LaVtAf7I}n`yOo3e3mtkgJ;zZ@ zcGAe{u$k0 z&l7v2;jgd%#-9D}CIHu>mu=)e~+g)M9`~5Rx8x^_mvkJZM76o zZJqc5*m-Ri6L{5W(G=@g6_Ly@Ix4o2Vnc*UH%QTVlo<=fvJKlVk->>xhxknm45bt~ zEmw5+^58klOQV*%vDrcEz2==Ext+7xUXpGEl#`hheq_{Z4)TS2bPXcUns(Iax7{--FuM#v{nBv zy)?78clrNGFAn%0ykrAta4yJIpw8`6aSMHY+_NOEM2V)#9<*^^eed2UHLd425L4$VfjHo3ScJ z-sbouFft%gY zTwr$}efTuwQZr7Fjqx4ypRDvL{<1E)=vWhoEfP3TE7oe#l%$v44s|sht2*uraEzT` z5g)!2uf(s4IhKd~>o7cvv|&6Alcf4#rPnAA*g}f#gxoE`sjlgFqd;N`RzBP;o3jZ} zFR*{fEfzscfN?wI7DdccAedF*6sEL#e_*M?DNPmjl6{bI))81PlKP%Ycv&R<^(z+V zDya=0Z`F=fmjjMD0h4gOU{jVQ6Yo(Zp?p7$`uiw%Jmixdk5kK5riM+*2!B;wX7oqF zGQeYchQgMafZ^B2bpE!;M$>=|G_|?HP?WM~&%P|p0T3GTvF=0EGp_bUI&Nj{4U@{7 z6q1K+Y+gpE3n&)*it@zc@%E;B6x5TRrfy#&?P3eu;<-y}K_D_><`pV34K0ezZ@=AG zVr=bb$Rd!m<(pr9)h(G#y~1uGj^J}($NnMjm$d3MUj$*+sS-whb78ZpF5jDJ310;> z|LdYYVj594@$Eg$eWP#xJq7=Vij<^md@t#+VfrI;zQkRtLH!ZRtTW7yW|VVJKUBn8 zTeJ!ZCjrZ4zi59Y!BH+8i$g^HeTfcf?)a#wEJs2oJB=Zo$`BQH=9JboIea}aY#?vj zkjp-v72j4hUdPT$T;NJpit0v<+EbFU`$6O`10N-vCqr)m1CX@eCxxdm4ulc?-DBQq zC<&hP_6uCx!apNeb^5i2uG|RF!VV+PF+G8yDfK+obg&c=7_+EW24chnbuPT1H^&%@ z1W4-k9mn^m32`GLX(_zN7Br@s@rnJt@Yj$_IO~~yzC22nPO?{LCAi15=9k$n*?qik zLZ#Qy4NCG$P@GEy)jl%R36}&}loTJur;6pzsBE7P%-v)C0IFK+S$CM0Ov`)j^%2`yw{Y_%v_cwt1=C3+UWJ!%g=|jlesl zeTkYd>#%wM`HjB+w2#UP>L|6}V(0v}JSg9mM_a_+&gC0vt*_$gVCt;L~5Nw3b0aMtIG%LBF{gfM0wU}6%$UIKi-wxjQVwR%&c8mUSD4FM4@lbk0)A3 z@P%1Vmyc9iXki?nTO4OP|2{c%wbR?PSB0A+H0&y2tQ^IS`=b$8;%Q*fJnmvvFlk8P zhKYMre2#+;pB&vsE1W{&(osH9a#YsT7QHj?mxtgE~1D>{bt$keI0)0G<38}BkQq`O&Sh0x_PYPqJ?KE2X6M2 z+r%RHLN;L4E56Oj*#B|~bG1daP{}ow4&CUC)`5FV%a#>YKzUkm8^ghl341=}HJank z55n~9u+@%@NF9aaaApAcNJ;aKR-qqF8gguUiFn);=Cw8G-zM|d0S*0uPJ2m|z*R5? zeuEWDjOKS3sEPq~WxC*dCJ`mFP7e1*>}i+lx=`Ds4f)sCYn3EtNi>aDrId$1%tT6 zl4E~e%#~ol634&2q6zH{=pw)=G~miAxV0GXE*I!0id8SyKm^ zK37>W2h#?3*@eG@n5VBLP2+)=AJ_5h&DefPFm)s8CnkEc~uN2gp|vvC%~@yQ(aQc&KR5E+i&rQwwV4f z%M<_nk_VxI#aG*9g=u%;sm%1Yz63r?1AC=Th4nTmcb||Da^&=A zcixYqf9}Vjlf@)fqCm9*I*TUv zrQ(;BQDW{=EFp`3BKg8`EEH+BFdf*ZnpL!KwxSPG%@2He-FVE)ZE-1^oIam7y)k^w z0Km{_m>8>gof@{WHoF72$`iPP;!50b!3d25He>NqFm4*3*#WJ6ly{dAIOLq=^zRH#z0h68;!KAJ+IoE8%i ziX1|G%Sed$-eGv(d8@s4|LVD}xn}JhwP%(u)PRZs z@`c!BCJn6LBfgmQyqDJlk z>`QaYjPkijS{f5*d(M*zXa+b8~gpk+-Psgw**IJ{dY=_A;^_f&7 z|1sqazOsGX1CZemAg@kd^iu9pU3K9WZyN+gV1YlV>Q@fD6(g+ z95gPUG+eVu%-t#iycbzJ*m{*vmH`5p?kNMa#3?VK4`#GkooE7CSRrUlcSgq&d(F+S?} zd$S@@lel~#@wDka67|ol`G}9EXB=)Ux9WUdm#}Fo(5!!JS)R;lYPt!?(jTy;Te9@W z;_*PGY5nZHl{7$iCt(}D)QV7BXoFYio_Fd&kvSap_X;taSO2arxZ8!oZKgrAy3>ZqN2}ewbGzrYj*f*rMu0ba+c4VPvskj4@?Y_$!XI;v zBc%`x=Vx-9yo?@>XhvFKe#aG~d^@__4`z&(mR6vHeD!FA2Tw0(?8!hQzaQ6d*cm_V z_d%;QFpQ6ATOK#Aq}+e32Y(TT-)X3W%cZ9DEOkZs@xNnD`Sr=9gPVz*%<*Mx8)xJA zDY=DHcnimuaYJVR{((f0w8m`t3 zt?;2W7uV0VXjTkPuv?pHtB$j3ik$!9U~}PQ@1o5;zc&VAl+fuTyRL_Ogq}}rXpEcW ze|2-^;HW8kLOw<{Nh>X~%OS_9T8Wxu6<|t5TsIX+jk?d7l4t+#B>PiE$me0aM%qR+ zRN{Z;Xk#$h5yI7<)C2i!;DqGU*~g1M-De}8&j-%Gkr3QIXyd15nP7GL2$dw0IsN|U zzJFzh)G4p*$nL1N|8c)zYUZg+`jh)!C#Jrrc}z7_CP=BIC}1uJC5N&nTk$9^sQetf zOzFQA_LIT)&t(m9zWD;0TG}Lji(}}CsXDe!i^Vf+V;Q9qq%^b8DI;IcBy$ZiZQ)o& zpP$KIWZ}H(fx9c;IfX~%%Cw6tdAhI#*Lv;X=R)78m5jYT?^$%kwurOJ_)<`j!OJ|u zHO$#`Y5qI-Ea?(w_v_NAvcwvmJmuuoH>ZR`GuGTcpe5!4+l_bPMix1IDm8vpQeeAW zxng24Qt*o&@tNn4I5*1ep0u8sikcwC_HF8?pxu#io*51NH1C<9FS~HyJbEvWlc)v#kB22w+-=< zPxv;y8gd!Q3lm~VQLA&6LyhwX+#q$RK2FP-*sTs@R07ZMxH^0$u~Xy(85QR6&q*DVG}^El8Ug!nXX{250l&-DgE zX2*bcodh4pjOE^bg^KiOcjBu9#sQDC5UdqHd{;0u_?*0_Y|p}7k11IOX7Iv`QWN%Gxw@PodS1vh549RmrzvfZ+6>; z6MnxT+}D|HYuBMDh!;T%7}>y9d(R*H@MMZVq=6U?^2L43=y z#EX{o&phWT9&YgLw)4DZfuLBiOxs#yIwCLE+f{DqkYxuJn-rbrDt|2yRigjD?E_J| z$yxfbdg5mjI=eB}qVF67vd&&8Uq0N^&bqt$pV%JABmEXhXJO?-e0_u&;L(6ZziH4H zW1!jBA@oY{#-c-{fDgpsp^Z0z8=VvQh%*Kf_%dMz0+$@-foE_g_>3=*DH)LPdI(Ss zI2_&y2jQiP3^>>q2f?2g%jx~NjYZ!+_Aumr2EsNr5AV-y0uhKPC~UJ^2ZzJEP9S_< zi4ihW4+_M89yQ=`8H*nEQ`OC4K8V=}8D8?=L?IAUTbOkP!+}uOPcM#xYxxM1LLf!n ze@Jvwjfn_Ss{0}lyu=Jfq_55(g2V#f54<`EqMI}rXh;BXG`z?JqHDFjSaO0Bz{?mQ zLADM=an4&s4LAY3hyW5e>p}<^B?UMdeuD?m`}82Y-?}Qn@lZ9;?||TvJpNe{w^+WI zM))-iq-xrvg4*}LiiGa^!BvG~iUdmX#X#W39At7l!C->gba)_e9|*#c1`IgV8^Gbv zC(68!{YFgtfRcQ1AMm$67)Z+|lPgdX-SPl`6$x{0g@7|1@-ARvVT8iIeh>*Ggb|Uz KuUh&`0P%n25nM3< literal 0 HcmV?d00001 diff --git a/app.py b/app.py index 39eaba9..f007a11 100644 --- a/app.py +++ b/app.py @@ -21,6 +21,11 @@ logger = logging.getLogger("app") IDLE_TIMEOUT_MS = 30 * 60 * 1000 # 30 minutes in milliseconds IDLE_WARNING_MS = 60 * 1000 # warn 1 minute before timeout +# ─── Version Configuration ──────────────────────────────────────────────────── +APP_VERSION = "1.0.0" +VERSION_CHECK_URL = "https://api.github.com/repos/your-org/website-checker/releases/latest" +VERSION_CHECK_ENABLED = False # set True and update URL when publishing releases + class App(tk.Tk): def __init__(self): @@ -88,6 +93,11 @@ class App(tk.Tk): if user.get("role") == "admin": from utils.scheduler import start as _sched_start _sched_start() + # Non-blocking version check (admin only) + if user.get("role") == "admin" and VERSION_CHECK_ENABLED: + import threading + threading.Thread(target=self._check_for_updates, + daemon=True).start() def _centre(self): self.update_idletasks() @@ -388,6 +398,66 @@ class App(tk.Tk): ) self._logout() + # ─── Version Check ──────────────────────────────────────────────────────── + + def _check_for_updates(self): + """ + Background thread: fetch the latest release tag from GitHub and + show a dismissible banner if a newer version is available. + Set VERSION_CHECK_ENABLED=True and update VERSION_CHECK_URL to activate. + """ + try: + import urllib.request + import json + req = urllib.request.Request( + VERSION_CHECK_URL, + headers={"User-Agent": "WebsiteChecker/" + APP_VERSION}, + ) + with urllib.request.urlopen(req, timeout=6) as resp: + data = json.loads(resp.read()) + latest_tag = data.get("tag_name", "").lstrip("v") + release_url = data.get("html_url", "") + + if latest_tag and latest_tag != APP_VERSION: + logger.info(f"New version available: {latest_tag} (current: {APP_VERSION})") + self.after(0, lambda: self._show_update_banner(latest_tag, release_url)) + except Exception as e: + logger.debug(f"Version check failed (non-critical): {e}") + + def _show_update_banner(self, latest_version: str, release_url: str): + """Show a non-intrusive dismissible update notification at the top.""" + if not self.current_user: + return + try: + banner = tk.Frame(self.content, bg=COLOURS["accent"], pady=6) + banner.pack(fill="x", side="top", before=self.content.winfo_children()[0]) + + tk.Label(banner, + text=f" Update available: v{latest_version} " + f"(you have v{APP_VERSION})", + bg=COLOURS["accent"], fg=COLOURS["white"], + font=FONT_SMALL).pack(side="left", padx=(8, 0)) + + if release_url: + import webbrowser + tk.Button( + banner, text="View Release", + command=lambda: webbrowser.open(release_url), + bg=COLOURS["white"], fg=COLOURS["accent"], + relief="flat", font=FONT_SMALL, cursor="hand2", + padx=8, pady=2, + ).pack(side="left", padx=8) + + tk.Button( + banner, text="✕ Dismiss", + command=banner.destroy, + bg=COLOURS["accent"], fg=COLOURS["white"], + relief="flat", font=FONT_SMALL, cursor="hand2", + padx=8, + ).pack(side="right", padx=8) + except Exception: + pass # UI may not be ready — silently skip + # ─── Change Password ────────────────────────────────────────────────────── def _show_change_password(self): diff --git a/config.py b/config.py index a238111..f242a24 100644 --- a/config.py +++ b/config.py @@ -245,6 +245,15 @@ def initialize_database(): INDEX idx_username_time (username, attempted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, + """ + CREATE TABLE IF NOT EXISTS website_users ( + website_id INT NOT NULL, + user_id INT NOT NULL, + PRIMARY KEY (website_id, user_id), + FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """, ] conn = None @@ -294,6 +303,24 @@ def initialize_database(): conn.commit() logger.info("Migration: added failed_attempts and locked_until columns to users table.") + # Add visibility column to websites if it doesn't exist + cursor.execute( + """ + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'websites' + AND COLUMN_NAME = 'visibility' + """ + ) + (has_vis,) = cursor.fetchone() + if not has_vis: + cursor.execute( + "ALTER TABLE websites ADD COLUMN visibility " + "ENUM('all','assigned') NOT NULL DEFAULT 'all' AFTER check_type" + ) + conn.commit() + logger.info("Migration: added visibility column to websites table.") + # Seed default admin if users table is empty cursor.execute("SELECT COUNT(*) FROM users") (count,) = cursor.fetchone() diff --git a/models.py b/models.py index a31b97e..2bbedbb 100644 --- a/models.py +++ b/models.py @@ -427,18 +427,21 @@ def get_website_by_id(website_id: int): conn.close() -def create_website(admin_id, name, url, check_type, note, credentials: list): +def create_website(admin_id, name, url, check_type, note, credentials: list, + visibility: str = "all", assigned_user_ids: list = None): """ credentials: list of dicts with keys: username, password, label - check_type: 'daily' or 'weekly' + check_type: 'daily' or 'weekly' + visibility: 'all' (all users) or 'assigned' (only website_users) """ conn = None try: conn = get_connection() cur = conn.cursor() cur.execute( - "INSERT INTO websites (name, url, check_type, note, created_by) VALUES (%s,%s,%s,%s,%s)", - (name, url, check_type, note, admin_id) + "INSERT INTO websites (name, url, check_type, visibility, note, created_by) " + "VALUES (%s,%s,%s,%s,%s,%s)", + (name, url, check_type, visibility, note, admin_id) ) conn.commit() new_id = cur.lastrowid @@ -451,17 +454,29 @@ def create_website(admin_id, name, url, check_type, note, credentials: list): log_action(admin_id, "ADD_CREDENTIAL", "website_credentials", new_id, f"Added credential label='{cred.get('label','')}' " f"user='{cred['username']}' for website '{name}'.") + + # Assign specific users if visibility='assigned' + if visibility == "assigned" and assigned_user_ids: + for uid in assigned_user_ids: + cur.execute( + "INSERT IGNORE INTO website_users (website_id, user_id) VALUES (%s,%s)", + (new_id, uid) + ) + conn.commit() cur.close() log_action(admin_id, "CREATE_WEBSITE", "websites", new_id, - f"Created website '{name}' check_type='{check_type}'.") + f"Created website '{name}' check_type='{check_type}' " + f"visibility='{visibility}'.") return new_id finally: if conn: conn.close() -def update_website(admin_id, website_id, name, url, check_type, note, credentials: list): +def update_website(admin_id, website_id, name, url, check_type, note, + credentials: list, visibility: str = "all", + assigned_user_ids: list = None): conn = None try: conn = get_connection() @@ -475,8 +490,9 @@ def update_website(admin_id, website_id, name, url, check_type, note, credential old_creds = {(r["username"], r["label"] or "") for r in cur.fetchall()} cur.execute( - "UPDATE websites SET name=%s, url=%s, check_type=%s, note=%s WHERE id=%s", - (name, url, check_type, note, website_id) + "UPDATE websites SET name=%s, url=%s, check_type=%s, " + "visibility=%s, note=%s WHERE id=%s", + (name, url, check_type, visibility, note, website_id) ) # Replace credentials cur.execute("DELETE FROM website_credentials WHERE website_id=%s", (website_id,)) @@ -488,6 +504,15 @@ def update_website(admin_id, website_id, name, url, check_type, note, credential ) new_creds.add((cred["username"], cred.get("label", "") or "")) + # Replace assigned users + cur.execute("DELETE FROM website_users WHERE website_id=%s", (website_id,)) + if visibility == "assigned" and assigned_user_ids: + for uid in assigned_user_ids: + cur.execute( + "INSERT IGNORE INTO website_users (website_id, user_id) VALUES (%s,%s)", + (website_id, uid) + ) + conn.commit() cur.close() @@ -504,7 +529,8 @@ def update_website(admin_id, website_id, name, url, check_type, note, credential f"from website id={website_id}.") log_action(admin_id, "UPDATE_WEBSITE", "websites", website_id, - f"Updated website id={website_id} check_type='{check_type}'.") + f"Updated website id={website_id} check_type='{check_type}' " + f"visibility='{visibility}'.") finally: if conn: conn.close() @@ -544,6 +570,30 @@ def get_website_credentials(website_id: int): conn.close() +def get_website_assigned_users(website_id: int): + """Return users explicitly assigned to a website (visibility='assigned').""" + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + """ + SELECT u.id, u.username, u.full_name + FROM website_users wu + JOIN users u ON u.id = wu.user_id + WHERE wu.website_id = %s + ORDER BY u.username + """, + (website_id,) + ) + rows = cur.fetchall() + cur.close() + return rows + finally: + if conn: + conn.close() + + # ─── Shift Check CRUD ───────────────────────────────────────────────────────── def get_today_checks(user_id: int): @@ -617,7 +667,7 @@ def get_today_checks(user_id: int): (user_id, user_id, user_id) ) else: - # Legacy fallback: show all active websites (original behaviour) + # Legacy fallback: show active websites, filtered by visibility cur.execute( """ SELECT @@ -638,6 +688,13 @@ def get_today_checks(user_id: int): AND sc.user_id = %s AND DATE(sc.checked_at) = CURDATE() WHERE w.is_active = 1 + AND ( + w.visibility = 'all' + OR EXISTS ( + SELECT 1 FROM website_users wu + WHERE wu.website_id = w.id AND wu.user_id = %s + ) + ) AND ( w.check_type = 'daily' OR ( @@ -652,7 +709,7 @@ def get_today_checks(user_id: int): ) ORDER BY w.name """, - (user_id, user_id) + (user_id, user_id, user_id) ) rows = cur.fetchall() diff --git a/requirements.txt b/requirements.txt index fbcdd66..9286445 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ openpyxl>=3.1.0 cryptography>=41.0.0 matplotlib>=3.7.0 plyer>=2.1.0 +reportlab>=4.0.0 diff --git a/utils/crypto.py b/utils/crypto.py index 771e640..ee83ae3 100644 --- a/utils/crypto.py +++ b/utils/crypto.py @@ -113,7 +113,7 @@ def decrypt(ciphertext: str) -> str: token = ciphertext[4:].encode("ascii") return _get_fernet().decrypt(token).decode("utf-8") except InvalidToken: - logger.error("Credential decryption failed — wrong key or corrupted data.") + logger.error("Credential decryption failed - wrong key or corrupted data.") return "" except Exception as e: logger.error(f"Credential decryption error: {e}") diff --git a/views/admin_shifts_view.py b/views/admin_shifts_view.py index 1e053e0..02d2300 100644 --- a/views/admin_shifts_view.py +++ b/views/admin_shifts_view.py @@ -51,7 +51,10 @@ class AdminShiftsView(ttk.Frame): command=self._open_edit).pack(side="right", padx=(4, 0)) ttk.Button(toolbar, text="✕ Delete", style="Danger.TButton", - command=self._delete_selected).pack(side="right") + command=self._delete_selected).pack(side="right", padx=(4, 0)) + ttk.Button(toolbar, text="⬇ Export PDF", + style="Ghost.TButton", + command=self._export_pdf).pack(side="right", padx=(0, 12)) cols = ("ID", "Shift Name", "Days", "Start", "End", "Users", "Websites", "Active", "Note") @@ -103,6 +106,147 @@ class AdminShiftsView(ttk.Frame): sel = self.tree.selection() return int(sel[0]) if sel else None + # ─── Export ─────────────────────────────────────────────────────────────── + + def _export_pdf(self): + """Export the full shift schedule to a PDF file.""" + from tkinter import filedialog + from models import get_all_shifts, get_shift_assigned_users, get_shift_assigned_websites + import datetime + + save_path = filedialog.asksaveasfilename( + title="Save Shift Schedule PDF", + defaultextension=".pdf", + filetypes=[("PDF files", "*.pdf"), ("All files", "*.*")], + initialfile=f"shift_schedule_{datetime.date.today().isoformat()}.pdf", + ) + if not save_path: + return + + try: + from reportlab.lib.pagesizes import A4 + from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle + from reportlab.lib.units import cm + from reportlab.lib import colors + from reportlab.platypus import ( + SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable + ) + + shifts = get_all_shifts() + doc = SimpleDocTemplate(save_path, pagesize=A4, + leftMargin=2*cm, rightMargin=2*cm, + topMargin=2*cm, bottomMargin=2*cm) + styles = getSampleStyleSheet() + + ACCENT = colors.HexColor("#5b4de8") + LIGHT = colors.HexColor("#e0dff8") + DARK = colors.HexColor("#1a1a2e") + DIMGREY = colors.HexColor("#6b6b80") + + title_style = ParagraphStyle("Title", parent=styles["Title"], + textColor=ACCENT, fontSize=20, spaceAfter=4) + sub_style = ParagraphStyle("Sub", parent=styles["Normal"], + textColor=DIMGREY, fontSize=10, spaceAfter=16) + h2_style = ParagraphStyle("H2", parent=styles["Heading2"], + textColor=ACCENT, fontSize=13, spaceBefore=14) + body_style = ParagraphStyle("Body", parent=styles["Normal"], + textColor=DARK, fontSize=10, leading=14) + dim_style = ParagraphStyle("Dim", parent=styles["Normal"], + textColor=DIMGREY, fontSize=9, leading=12) + + story = [ + Paragraph("Shift Schedule", title_style), + Paragraph(f"Generated {datetime.datetime.now().strftime('%d %B %Y, %H:%M')}", + sub_style), + HRFlowable(width="100%", thickness=1, color=ACCENT), + Spacer(1, 0.4*cm), + ] + + for s in shifts: + if not s["is_active"]: + continue + + days_str = _days_label(s["days_of_week"]) + start = str(s.get("start_time", ""))[:5] + end = str(s.get("end_time", ""))[:5] + + story.append(Paragraph(s["name"], h2_style)) + story.append(Paragraph( + f"Days: {days_str}    " + f"Time: {start} – {end}    " + f"Users: {s['user_count']}    " + f"Websites: {s['website_count']}", + body_style)) + + if s.get("note"): + story.append(Paragraph(f"{s['note']}", dim_style)) + + story.append(Spacer(1, 0.25*cm)) + + # Users table + users = get_shift_assigned_users(s["id"]) + if users: + user_data = [["Assigned Users", ""]] + for u in users: + user_data.append([u["username"], u["full_name"] or ""]) + t = Table(user_data, colWidths=[5*cm, 8*cm]) + t.setStyle(TableStyle([ + ("BACKGROUND", (0,0), (-1,0), LIGHT), + ("TEXTCOLOR", (0,0), (-1,0), ACCENT), + ("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"), + ("FONTSIZE", (0,0), (-1,-1), 9), + ("ROWBACKGROUNDS", (0,1), (-1,-1), [colors.white, colors.HexColor("#f5f5fa")]), + ("GRID", (0,0), (-1,-1), 0.5, colors.HexColor("#c5c5d8")), + ("LEFTPADDING", (0,0), (-1,-1), 8), + ("TOPPADDING", (0,0), (-1,-1), 4), + ("BOTTOMPADDING", (0,0), (-1,-1), 4), + ])) + story.append(t) + story.append(Spacer(1, 0.2*cm)) + + # Websites table + sites = get_shift_assigned_websites(s["id"]) + if sites: + site_data = [["#", "Website", "URL"]] + for i, w in enumerate(sites, 1): + site_data.append([str(i), w["name"], w["url"]]) + t = Table(site_data, colWidths=[1*cm, 5*cm, 10*cm]) + t.setStyle(TableStyle([ + ("BACKGROUND", (0,0), (-1,0), LIGHT), + ("TEXTCOLOR", (0,0), (-1,0), ACCENT), + ("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"), + ("FONTSIZE", (0,0), (-1,-1), 9), + ("ROWBACKGROUNDS", (0,1), (-1,-1), [colors.white, colors.HexColor("#f5f5fa")]), + ("GRID", (0,0), (-1,-1), 0.5, colors.HexColor("#c5c5d8")), + ("LEFTPADDING", (0,0), (-1,-1), 6), + ("TOPPADDING", (0,0), (-1,-1), 4), + ("BOTTOMPADDING", (0,0), (-1,-1), 4), + ])) + story.append(t) + + story.append(Spacer(1, 0.5*cm)) + story.append(HRFlowable(width="100%", thickness=0.5, + color=colors.HexColor("#c5c5d8"))) + story.append(Spacer(1, 0.3*cm)) + + doc.build(story) + + from models import log_action + log_action(self.current_user["id"], "EXPORT_SHIFT_PDF", "shifts", + None, f"Shift schedule exported to PDF: {save_path}") + logger.info(f"Shift schedule PDF exported: {save_path}") + + from utils.ui_helpers import show_info + show_info(f"PDF exported successfully.\n\n{save_path}") + + except ImportError: + from utils.ui_helpers import show_error + show_error("reportlab is required for PDF export.\n" + "Install it with: pip install reportlab") + except Exception as e: + from utils.ui_helpers import show_error + show_error(f"PDF export failed:\n{e}") + # ─── Actions ────────────────────────────────────────────────────────────── def _open_add(self): diff --git a/views/admin_websites_view.py b/views/admin_websites_view.py index 23d4b55..5dc2f86 100644 --- a/views/admin_websites_view.py +++ b/views/admin_websites_view.py @@ -97,10 +97,11 @@ class AdminWebsitesView(ttk.Frame): if not wid: show_error("Please select a website to edit.") return - from models import get_website_by_id, get_website_credentials + from models import get_website_by_id, get_website_credentials, get_website_assigned_users data = get_website_by_id(wid) creds = get_website_credentials(wid) - data["credentials"] = creds + data["credentials"] = creds + data["assigned_users"] = get_website_assigned_users(wid) WebsiteDialog(self, self.current_user, website_data=data, on_save=self._load_websites) @@ -227,6 +228,66 @@ class WebsiteDialog(tk.Toplevel): note_frame, self.note_txt = scrolled_text(form, height=4) note_frame.grid(row=3, column=1, sticky="ew", pady=6) + # ── Visibility ──────────────────────────────────────────────────────── + ttk.Label(form, text="Visibility").grid( + row=4, column=0, sticky="w", padx=(0, 10), pady=6) + + vis_frame = tk.Frame(form, bg=COLOURS["bg"]) + vis_frame.grid(row=4, column=1, sticky="w", pady=6) + + self.visibility_var = tk.StringVar(value="all") + for val, lbl in [("all", "👥 All Users"), ("assigned", "🔒 Assigned Only")]: + rb = tk.Radiobutton( + vis_frame, text=lbl, + variable=self.visibility_var, value=val, + bg=COLOURS["bg"], fg=COLOURS["text"], + activebackground=COLOURS["bg"], + activeforeground=COLOURS["accent"], + selectcolor=COLOURS["surface2"], + font=FONT, cursor="hand2", + command=self._on_visibility_change, + ) + rb.pack(side="left", padx=(0, 16)) + + # ── Assigned users (shown only when visibility='assigned') ───────────── + self._user_assign_frame = ttk.Frame(self.inner) + self._user_assign_frame.pack(fill="x", padx=24, pady=(0, 4)) + + ttk.Label(self._user_assign_frame, text="Assigned Users", + style="Heading.TLabel").pack(anchor="w", pady=(4, 6)) + + # Load all active regular users for the picker + from models import get_all_users + try: + all_users = [u for u in get_all_users() + if u["is_active"] and u["role"] == "user"] + except Exception: + all_users = [] + + self._user_vars = {} # user_id -> BooleanVar + user_grid = tk.Frame(self._user_assign_frame, bg=COLOURS["surface"], + padx=12, pady=8) + user_grid.pack(fill="x") + + for i, u in enumerate(all_users): + var = tk.BooleanVar(value=False) + self._user_vars[u["id"]] = var + col, row = i % 3, i // 3 + tk.Checkbutton( + user_grid, + text=f"{u['username']} ({u['full_name'] or ''})", + variable=var, + bg=COLOURS["surface"], fg=COLOURS["text"], + activebackground=COLOURS["surface"], + activeforeground=COLOURS["accent"], + selectcolor=COLOURS["surface2"], + font=FONT_SMALL, cursor="hand2", + anchor="w", + ).grid(row=row, column=col, sticky="w", padx=8, pady=2) + + # Hide initially; shown when visibility='assigned' + self._user_assign_frame.pack_forget() + # ── Credentials section ─────────────────────────────────────────────── ttk.Separator(self.inner, orient="horizontal").pack( fill="x", padx=24, pady=12) @@ -257,11 +318,26 @@ class WebsiteDialog(tk.Toplevel): self.url_var.set(d.get("url") or "") self.check_type_var.set(d.get("check_type") or "daily") self.note_txt.insert("1.0", d.get("note") or "") + vis = d.get("visibility") or "all" + self.visibility_var.set(vis) + # Pre-tick assigned users + assigned_ids = {u["id"] for u in d.get("assigned_users", [])} + for uid, var in self._user_vars.items(): + var.set(uid in assigned_ids) + self._on_visibility_change() for cred in d.get("credentials", []): self._add_cred_row(cred) else: self._add_cred_row() + def _on_visibility_change(self): + """Show or hide the user assignment panel based on visibility selection.""" + if self.visibility_var.get() == "assigned": + self._user_assign_frame.pack(fill="x", padx=24, pady=(0, 4), + before=self.creds_container) + else: + self._user_assign_frame.pack_forget() + def _add_cred_row(self, cred=None): frame = ttk.Frame(self.creds_container, style="Surface.TFrame") frame.pack(fill="x", pady=4, ipady=4) @@ -300,10 +376,15 @@ class WebsiteDialog(tk.Toplevel): url = self.url_var.get().strip() check_type = self.check_type_var.get() note = self.note_txt.get("1.0", "end-1c").strip() + visibility = self.visibility_var.get() + assigned_user_ids = [uid for uid, var in self._user_vars.items() if var.get()] if not name or not url: show_error("Name and URL are required.") return + if visibility == "assigned" and not assigned_user_ids: + show_error("Please assign at least one user, or set visibility to All Users.") + return credentials = [] for label_var, user_var, pass_var, _ in self.cred_rows: @@ -320,16 +401,18 @@ class WebsiteDialog(tk.Toplevel): from models import update_website update_website(self.current_user["id"], self.website_data["id"], - name, url, check_type, note, credentials) + name, url, check_type, note, credentials, + visibility, assigned_user_ids) logger.info(f"Website id={self.website_data['id']} updated " - f"check_type='{check_type}'.") + f"check_type='{check_type}' visibility='{visibility}'.") show_info("Website updated successfully.") else: from models import create_website create_website(self.current_user["id"], - name, url, check_type, note, credentials) + name, url, check_type, note, credentials, + visibility, assigned_user_ids) logger.info(f"New website '{name}' created " - f"check_type='{check_type}'.") + f"check_type='{check_type}' visibility='{visibility}'.") show_info("Website created successfully.") self.on_save() self.destroy()