From 58c6218c1433f705264d653c95fb697b7f9625f1 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Thu, 23 Apr 2026 17:32:44 -0400 Subject: [PATCH] 04/23 Enhance app functionalities 2 --- CLAUDE.md | 15 +- config.py | 18 +++ example.xlsx | Bin 10985 -> 0 bytes models.py | 61 ++++++-- utils/scheduler.py | 20 ++- views/admin_users_view.py | 181 ++++++++++++++++++++-- views/admin_websites_view.py | 54 +++++++ views/ai_summary_view.py | 288 ++++++++++++++++++++++++++++++++++- 8 files changed, 609 insertions(+), 28 deletions(-) delete mode 100644 example.xlsx diff --git a/CLAUDE.md b/CLAUDE.md index 24567b5..c9b2a20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,7 +222,7 @@ Password strength: PW_MIN_LENGTH=8, requires upper + digit + special char - last_sent_date in-memory (resets on restart) - start() / stop() called from app.py on login/logout (admin only) -### views/ai_summary_view.py *(NEW)* +### views/ai_summary_view.py AI-powered document analysis panel. Accessible from the sidebar for both admin and user roles. Key internals: @@ -238,6 +238,15 @@ Key internals: Key Deadlines & Action Items - Groq API key stored DPAPI-encrypted in config.ini [groq] section - max_tokens=4096; temperature=0.2; MAX_CHARS_PER_FILE=14,000 +- **Evaluation Criteria panel** (collapsible): admin can add/edit/delete criteria; + users see read-only list. Active criteria are appended to the AI prompt as an + alignment evaluation section. The AI outputs RECOMMENDATION: PURSUE/PASS/UNCLEAR + which is parsed and displayed as a colour-coded verdict banner. +- **History tab**: every analysis is saved to ai_analysis_log (with verdict + criteria + snapshot). History treeview is filterable by verdict. Double-click restores full + output in the Analyze tab. Admins see all users; regular users see only their own. +- CriterionDialog has a live character counter with 500-char soft limit guidance. +- URL open and health-check probe both guarded by _validate_and_normalise_url. .doc reading (legacy binary Word format) — 3-tier fallback: 1. win32com (Word COM automation — requires MS Word installed) @@ -335,6 +344,10 @@ Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first | utils/crypto.py | _ITERATIONS | 100,000 | | ai_summary_view.py | MAX_CHARS_PER_FILE | 14,000 | | ai_summary_view.py | _OFFICE_ADDRESS | "2815 Hartland Road, Falls Church, VA 22043, USA" | +| ai_summary_view.py | CriterionDialog._DESC_SOFT_LIMIT | 500 (chars, soft guidance only) | +| app.py | App._IDLE_THROTTLE_S | 5.0 (seconds between Motion-event timer resets) | +| admin_users_view.py | _PasswordResetDialog._AUTO_CLOSE_S | 120 (seconds before auto-close) | +| admin_users_view.py | _PasswordResetDialog._CLIP_CLEAR_S | 30 (seconds before clipboard wipe) | --- diff --git a/config.py b/config.py index f32ed3f..b388684 100644 --- a/config.py +++ b/config.py @@ -402,6 +402,24 @@ def initialize_database(): conn.commit() logger.info("Migration: added visibility column to websites table.") + # Confirm creation of new tables added post-initial-deployment + # These use CREATE TABLE IF NOT EXISTS so they are safe on first run. + # We log confirmation so operators can verify the upgrade applied. + for new_table in ("ai_criteria", "ai_analysis_log"): + cursor.execute( + """ + SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = %s + """, + (new_table,) + ) + (table_exists,) = cursor.fetchone() + if table_exists: + logger.info(f"Table '{new_table}' confirmed present.") + else: + logger.warning(f"Table '{new_table}' was not created — check DDL.") + # Seed default admin if users table is empty cursor.execute("SELECT COUNT(*) FROM users") (count,) = cursor.fetchone() diff --git a/example.xlsx b/example.xlsx deleted file mode 100644 index ab2671e76dd47a5fa7559aca28e6d3581dd46259..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10985 zcmeHt^;cZk(sl<6?gVQH4T0e95ZpZw+}$OOy99R#G)|D<65QS00>Ry#;Lgj;o%_v9 z?tFj2z59oARi2wYU7vdj4K>rv&7aak?$CuYaV$8>F-I5NnSTzORoGb9_yzYgDk6vZ_O&VN z5OOed_a2b9;^N1GHPnwYun7g2@O5<~Oi}=eA8R`mSw&U_rHTp6a9;8Gtf$TOs$eYl z)FT&alEMagC6nsZtAIF-sFsE|YIa{Zbgb5>cUwGxDlcNAqQ=&3ZtOwsF zNVnKi*0D_K8A2^F21YldE{-h2HMVdN%d!$w6oj4^YV<@~(zJSLdAPMwMuwd$+%~Y| znCa4KrVIFj{3d1q3YKA2uS%G0Mm@Xuak6CwPdZ2z@5ooNfL+UkMh)pv(MEE3q}H@6 zIlbv3@?0F$eqMQf{dJse@`r{%51ZWamm4sm*H74&7`qK;Z9?e%ZJ;-vL5C}SyFBg( zW1E_vj($RRy_p~30Dz|_Xn?}s!m?VKk?j20*JPff4(T~8b-~6~_6+pDUH=!3|HU!* zm%m;dD=Xj0h#YtH^fL0xm{f2O{^(_$n1HmU~aqniY z`+@mI-iW|M#(MRc32@tSyfPt^xCHZRf$f_?HT$jF>5>h%W2`sDS$k_Wln&LeT z$=yZa59X3Nm`K9uvo|oEE%oTL0$tn?C@Yz9nU@))gSkmvb&agr&O}mLu^v3=@$yg;{>}YhZb9{1iiXfy!?aX=ZA0skutHDZzHT1= zsYA)*ZFGg(KdJ#J9@Q=o2aA3?s_%8D$}Dfw&G&k`1xIRy zXlDE_t5f}=uTJ!WWswYx5jx;4`$eS5!(3?ugLqoKll zrL1T>O^rDflWy;$E;tiC58EDQHyt2lHdHqj5QW37a&MEv1C^6jHTuR`=#*aa!33G1 zB?$=ebgwr$-LUc-WcY5#(dDC|nol+SWk*xI(5Jh>4a8V`@h>FGB*6x%{Rz=X$9IE$ z)!NIQd>#6!Bq?uRAu5du!+f3z%O1nexqj&a5)<XBgDJ#o1D;1x<@rQAKZiGMLSJ?C?<>>meny2tnKjG zFf_cuVF|IHI4l|oFE`;F;0#zO9(l+D$3&g(5B|q8x6F&`dh`xp zumXFGyMD2To6ct)i5ONxjTp?OR5R^2$SprizZF=-0t1v{+-I)xbYhHhXvk>yN4qle zlpwK0vbL~PNyv(ydG#3|w_=rL2}>mSu}UTO_DcwIs`5#?>>;KHvM`Qmq}qIG(i(3^ zxrXj=0wnjEew?6n>W)po-}w~uO(e6QclDspel{eQ^IoNEdUHsMvi(%RNO^7n9=(R+ z6Iwkc#7uT5KOs{)No&v5W^B>AIE4_AUA4MkW-XemMxoxh@FRZG2$w`b4x<3AO--E}-c2xa_&@$4g|iGM`SIrxQVVpX9)?%usLo9Pvt8VzI&` z(?6XJIl6T4DY3C6iQldW%UU588ghDL#P@*Abufw%J7^?e;E7BjX~k zO??p4^`e-)>CgkN;>o-C$871A{sSbXuOc;Vv)zT+O3EpHEePyFRKL&h7^(}7iqcl7%GZpzole1FG+n8Gx@4b3le6wbNdnw&B|;();EL< zi3Z{OvD|sG`;P6ziLSGpa;xAWf%>Lj`U5kuq{76^APBK|NSWFnqp{Hn^V#mZqJf43Q&12bPQL%Z&t!+cg z9ro4g96v|YlyzUbULB7PoE<_Ft2q8P?p4}Yw69vdI=o|uxhP8Yz{FHd1whs^G`=5m zl9cF{R%3T3jc;o<=#3aprVQJAyg_)rNi0@$Y~yiwS<&~2AW)5j6a!1QWl2+ffa=LQ z3_Gl-FRjBX!SN_^+MG;p*tS3qE1W>pdqZTg?jA%|-qf;ErYvsOcj$lzvo5g$TO2V> zhe|%29%jENYy`Ap!I}K-k`6O6#zAp?TCy_u34`(0kX4n>#L__IQrAwT_jl&o)h_2u0B|Kj> zi?4q@R}s1#S;gKdwuZ*0=cq_wT9 z(_W2j@+2^&36FS%HzHJ)ttWhyckhiK`2R8KUg}i|);%*g2I+6g((g&v!OYm&nBn*9 z@A>vXV>pVi7N_-P-;==%gJlhVFW z=Ht@7OKy~*aArPm^&3%4dStRs!MtFxVpATg|<_98vaHpNkSR zkx1WcN=_pkT@#b`hmV?99Jt9-tV_)cE3O(v;>$}+Yhvwsf{)juj@F=YViHNB8`aY0&P3XN0Ki zF3wZFW~!5on}lYe7^$)_kimrVXYSu@jTS96 zYIeV@M^|z;k_H=|B@Avtot=#hQ9-^Reto30n*LPc1+8a+%VDwqRKf?{R*BDR-jh?z zV#fO5lxQfOaWIkD_#M%Lz2$3bO()k5>z)4Mgsni*iJ<{a{w(Tk3$E~aPBg})(I5scv%GfyKcX? z7P`gV*Z8=G05g)#Pzs3>rHVF|;5D7XIuT)NrNC)osgE7x$anha0y|R=c6YY4k!bMr zl9oDcB1g#Pd=eutvdYu$0F7qMjZKc_CYQ&Hu&M65r-uvYoz|zvc{JU}{)hzQ`y%hh$En)ohd4JiivTK}$D1NmUGIxi2Jh>~*Sg$Q zS1{?2RV)Im5V<~bw;@9p0Yk(T{-n?pNf}ZZIs~AvP0(IUDD6r=0Sj)aycHH7PPDOB z+g*|AtLaLKVr<8ud&W?@6{hXt(SYv&vW9L0vw5eF;GN1 zgC(w@UA1w_dS3o$(tbhEMu%8OMca^|mr$Z{&z(z--GL%kI};7zJ$zB0=&2Q*iSdF2 z^~rfGW2BfpX^!+(jL$Y5*8?c9Ys=w|Nwb$-k<#sG!quOPfh_3W1AUd#5u7Xep~s7Q z6ePmLy*U8OzjcW?ezo#4^}z8;Ns89Fl@Ej07e+Pr15EIvF*&fMNxrU`c-qQ0gl6zu z#L9R&J!52N=Nm-D>@`Nm33)n}U%JPh-q?15@tNQGC-{m1&_~!G&{n;LE%L$s%#zb= zjZ~+>QZz=ReX+-@_DwAimmi!V z{M@VL_s#uFnx&VeotsJnHd{H=4a?}iEIMfWA=j59zq#b78HQo8^dr@yNX#NrZGgoX zd(jo7=x1`UQ&?ruE*{;tQI}MIBD7baN%cH=s@t_;S@N zpn4enTdWe)e80_F1D&;vG~w}6OVw;G#1<)e-fycW<72a*XS82#zJ5j%mBzc=pIFC- zceIp-rWV6}F{~x85x%jVF(D_X$C=C6-1(TpB;2~{K_S&*&-9VBPub>&j*zAbc~dfK z59JIgGmuNw``5(avp;+Q%hsCnm8==^jn1Xzwr-eG#Hrz|dqES(&vI53eh9RwITakX z6<#dUPFC3u^no-ZgiIK*ha`Uzv1GX>z-+L;^ip#G(#~l-^eFS>0xUGYwYhWn)n1fN z=bMISkiBHP)w-jyzvyY4<2s)Deiz<9fB`7!)|Pi>oo;$6Z)T?8cp?#^=Lz}GNIMcddToi$0t!L61&O6dAc4+a%?ZU9EK__&!GvzLed2DJDi?cB|hEl1R0YMinDrpsmc$J ziL~fxx{l3b%3NDPy{>F+m;x!uxGFO$W9o;qer}b6qLr|XI3oiymKTL|P4P3pN}B*# z%W+XnZELwn#}XTpAE<-rb6@ohsp{scNvrx{11Gq6hK9im8%Z?rs&jS$Dt=DQ%eN6a zp6KjwIIW9#%6rufy5<~i)l%Qa^I@~aR?zNNw*}-yKaTaac;I!Kqib$2^C$jHvn5#@ z_MJ|{k1AU5SXW?M;#d}dS$(u6X^Rn{0m5FC;i6K1(jP0H?9{KEu$gj;7n}hGzMH8^ zSzp>FZeU!=;0H$bMwCqe0i{P*1o1kp?D?$o*IjfGx z;{QnW4Hra@^3Uy~`sdRX%D;%--oe$%*#5U8)q$F(?L0fS*Xyz;k`wLmZipozB#bkp z44|z*fNKuztJ1 zK^n1mL`0IasXu_WEHHDyr7#Z0bbyvR5%>17i-vJCf1HtFLMaYg&8t+314lH3O37{IahtqLSk5o7hvPhwFtM|n(SCKMipJi-6gg1}M72bJkm^bw@ zS>SAt6*gu&=w;3HX+UyM`kSjs;A;(faC9p7&AxI?#55YTeoL9>?z z43V@ty~p=^6syu=jI|u2-ZioGs-_$!7P9OU926D%DP}h$7)O(j|Bo;} zsZ7a2Yu9Sf21<#g6;UF7BIWf*o-}UnA9})SmUh9ZAnCiQh|P$I8fM-=BaaGtPEsXf zR5p(67n(2BP$U~cqvvGFxw!^8ZREal529?CXX-+h~S+93td zOWgeXF87i{D7-Ml?r5$)rM+h&KE9ucE^1A!4x7HMu13(leh4ai9JTynjCR(eB5{iY z)oT>VPm^QbBK%E;VmyVrN1jnnVGFh5kqHEu)jHFzxGV87O| zl!@sUMMJ5d@@ZkyPR~L6eJ<TYddYMT#xvpM#XVb)igIqr(BhfQr7GoJ}Rt$k~McZXAJbyo|ByVm1XMLt@q zV5@=ko*y(|#bZ6O?nEmKnlWAN;*Ww_mKF0_iG1s_C-*kHPd1CBDK%VG^*ts^nK(mJ z#GMX0>y;ZcU$UhJx?RLkaRU~C)M~WewLgZAGp5edm%&rpf6P3LBaK@1&obb~v$li% zN9Hlp2OAqHIe^V=On*;6adCp+?*h+V%MTxe=;`vm4%E=jMq(NAV6X>D>Gz6IP(cg} zh|utdDOt#{j@9fwY57#zVWYO;o5h2CC=|-Z48AVwF6w@p;%^y-u~Hx`-DpE1kVR9V zpx!*SSZP+5*$X1Z8#Eg5kL5h6F>6|1T^IK>q*W+kg|@Y}UOMyqT+C#cn+c9PH_CD7 zp(VPAw*^PgvRR6xAn;qUquqyp+!f41G=Hg6v@Qs`k@d~KBkXKh?Q9|PdT^P(IxIU* z=v^#PLrAw9q-8^?B-PA1QfAYCYgWCN_fyvrcOF>ech9kde3(jZL7GSmuvp!(uZolt@E}0&)EFoDG@AD2~ zq6Ic&%c&I9!)|&(-tE-7$Hkx|66{5#P(kgMIdP>G>zP^NYYK}?PT*aZY$;PGLp$^V z+Rqy!AX386vsAfs$R;1hVh!T~e0dAYvA~@vmp#j=k($;TP(UhI%f?CfM#xU#QyIRK zMH?+p^!r{)E({5LwU&)w2?@Ol4SA`4*WoK2W>Dd&6OaEOoECFsE`Zh-S5uakr}!Y; z!VW`l#-es^#NU+6)L*G4fOB0H7*M|@*PW~q#e67O`@O{ZnLX!GHI=8%xJ%-8JRi?T z7I@MUwK)iXPy%yb4M0eiu)E-8u+vIYje}3MiZri5)@@xK324sfFo$#RIs9GLR=(W? zLisT<_3aG$Dhp`wp1&{3XoNt*?s2wZTXx3`}Tcx7Y9Z)9L_WJ>xvk13$wChvkZ>Fit1eDpsUZkvLN+S*20M`&7qw4}`l3N}tvr>l)oyx4D>xX1`a6G=W}o z7^J)L=8^sU{XG4gfbs`F>Bhp>HEk_|&~8O#^s$@d8dKB}?$f3^4xK!zn@zvP0sc>~ z`>O~qr=`fVUUH$H9zm~j{9_jA&;P0;`MfP3pECi*b3?$$)=&X#YiG}3XbU#}V}Rv< zeSqh!3y;;7?qtLZIEH-^?)OZoxl<6D_Z<+iqqOo`(Uw@UZl^$z)T;HiowKQ4qi>QK zas85d7Hd_LU|a@mD4GL~6JbIWC=W%d?jIktAlAul<1XZ+qk-k(_7SaIJ0&$tE*og8 zEMUcZ+Xk2Zl~$iyTyvYXeWrp=!h-BrK}SnfOmREPjmy|3?h#;Bq5u#Yl4^9&b2DV1^ui|#aThT7Bb z-&q)5-y?YG^3u;2W-1Jq;$@JohciM?D(BXDyP-hzocwO+-cdqUeP?JZzW$)QADM=_ z9$pQ9`qq0k&%H?22K_qP!BK8te&^DLOi%Lka>etX`hiu&*vzLj&C;pG*Sekv?l;x*m!tKrAD$53D#$v50h> ziei>^CQF?jSJ_0@0{$BNv9T3_=_>nN0xDF0QAHEPjs%`(eZjGL9MRqoQd}^(QWke9$%l#e~81iMf zJ>sFVs)&#H)qCbspK=nB9K2nuRkNt?DTtE95f68&r``U8bk8tQD$=|+p zCu%f$mhO};Is;5SBpO{T;dQ26`9945Fv`DymD}e-nS5u%*T3)oFcGLA{da)BPb>Y~@V9%y zb4>hYcIj8cUne;JG;Mkwi~41r<5%OqH-i5(1pq1#e>eXBw1t1g`L#RvC( 0 + ) AS total_sites, ROUND( COUNT(DISTINCT sc.website_id) * 100.0 / - NULLIF((SELECT COUNT(*) FROM websites WHERE is_active=1), 0), + NULLIF(( + SELECT COUNT(DISTINCT sw2.website_id) + FROM shift_websites sw2 + JOIN shifts s2 ON s2.id = sw2.shift_id + JOIN shift_users su2 ON su2.shift_id = s2.id + AND su2.user_id = u.id + WHERE s2.is_active = 1 + AND LOCATE( + CAST(DAYOFWEEK(DATE(sc.checked_at)) AS CHAR), + s2.days_of_week + ) > 0 + ), 0), 1 - ) AS pct_complete + ) AS pct_complete FROM shift_checks sc JOIN users u ON u.id = sc.user_id {where_clause} @@ -1430,17 +1462,22 @@ def get_unchecked_sites_for_user(user_id: int): # ─── AI Criteria CRUD ───────────────────────────────────────────────────────── def get_all_criteria(): - """Return all AI evaluation criteria ordered by sort_order, then id.""" + """Return all AI evaluation criteria ordered by sort_order, then id. + + The creator username is intentionally omitted — the criteria treeview does + not display it and the LEFT JOIN was adding a needless per-call cost. + If a creator column is ever added to the UI, restore the JOIN here. + """ conn = None try: conn = get_connection() cur = conn.cursor(dictionary=True) cur.execute( """ - SELECT c.*, u.username AS creator - FROM ai_criteria c - LEFT JOIN users u ON u.id = c.created_by - ORDER BY c.sort_order, c.id + SELECT id, title, description, is_active, sort_order, + created_by, created_at, updated_at + FROM ai_criteria + ORDER BY sort_order, id """ ) rows = cur.fetchall() diff --git a/utils/scheduler.py b/utils/scheduler.py index c8cabff..61cc643 100644 --- a/utils/scheduler.py +++ b/utils/scheduler.py @@ -111,7 +111,14 @@ def _build_html_report() -> str: rows = stats.get("user_stats", []) table_rows = "" + skipped_no_shift = 0 for r in rows: + total = int(r.get("total_sites") or 0) + # Skip users with no shifts scheduled today — they have no expected + # work for this day and showing them as "0 / 0 — 0%" is misleading. + if total == 0: + skipped_no_shift += 1 + continue pct = float(r.get("pct_complete") or 0) color = "#388e3c" if pct >= 100 else "#f57c00" if pct > 0 else "#d32f2f" table_rows += ( @@ -119,11 +126,21 @@ def _build_html_report() -> str: f"{r['username']}" f"{r['full_name'] or ''}" f"{int(r['checked_count'] or 0)}" - f"{int(r['total_sites'] or 0)}" + f"{total}" f"{pct:.0f}%" f"" ) + if not table_rows: + table_rows = ( + "No users with active shifts today." + ) + no_shift_note = ( + f"

" + f"{skipped_no_shift} user(s) had no shifts scheduled today and are " + f"excluded from this report.

" + ) if skipped_no_shift else "" return f""" @@ -142,6 +159,7 @@ def _build_html_report() -> str: {table_rows} +{no_shift_note}

Sent automatically by Website Checker at {datetime.datetime.now().strftime('%H:%M')}.

diff --git a/views/admin_users_view.py b/views/admin_users_view.py index 3887f7a..44d07b4 100644 --- a/views/admin_users_view.py +++ b/views/admin_users_view.py @@ -6,7 +6,7 @@ import tkinter as tk from tkinter import ttk import logging from utils.ui_helpers import ( - COLOURS, FONT, FONT_BOLD, FONT_HEADING, + COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL, show_error, show_info, confirm_delete ) @@ -143,15 +143,9 @@ class AdminUsersView(ttk.Frame): f"Password reset for user id={uid} '{username}' " f"by admin '{self.current_user['username']}'." ) - # Copy to clipboard - self.clipboard_clear() - self.clipboard_append(pwd) - show_info( - f"Temporary password for '{username}' has been set and " - f"copied to your clipboard:\n\n{pwd}\n\n" - "Please share it with the user securely.\n" - "The user should change it immediately after logging in." - ) + # Show the temporary password in a purpose-built secure dialog + # instead of show_info (which leaves the password visible indefinitely) + _PasswordResetDialog(self, username=username, password=pwd) except Exception as e: show_error(f"Password reset failed:\n{e}") @@ -296,3 +290,170 @@ class UserDialog(tk.Toplevel): self.destroy() except Exception as e: show_error(f"Save failed:\n{e}") + + +# ─── Secure Password Reset Dialog ───────────────────────────────────────────── + +class _PasswordResetDialog(tk.Toplevel): + """ + Purpose-built dialog for displaying a freshly-generated temporary password. + + Features: + - Password field is masked by default; admin can reveal with 👁 toggle. + - 📋 Copy button copies to clipboard with a 2-second "Copied!" flash. + - Clipboard is auto-cleared after 30 seconds for security. + - Dialog auto-closes after 120 seconds with a live countdown so the + password cannot sit on screen indefinitely. + """ + + _AUTO_CLOSE_S = 120 # seconds before auto-close + _CLIP_CLEAR_S = 30 # seconds before clipboard is wiped + + def __init__(self, parent, username: str, password: str): + super().__init__(parent) + self._password = password + self._username = username + self._remaining = self._AUTO_CLOSE_S + self._clip_job = None + self._tick_job = None + self._revealed = False + + self.title("Temporary Password") + self.configure(bg=COLOURS["bg"]) + self.resizable(False, False) + self.grab_set() + self.protocol("WM_DELETE_WINDOW", self._close) + self._build_ui() + self._centre() + self._tick() + + def _centre(self): + self.update_idletasks() + w, h = 480, 300 + x = (self.winfo_screenwidth() - w) // 2 + y = (self.winfo_screenheight() - h) // 2 + self.geometry(f"{w}x{h}+{x}+{y}") + + def _build_ui(self): + C = COLOURS + pad = dict(padx=24, pady=8) + + tk.Label(self, text="🔑 Password Reset", + bg=C["bg"], fg=C["text"], + font=FONT_BOLD).pack(anchor="w", **pad) + + tk.Label( + self, + text=f"A temporary password has been generated for '{self._username}'.\n" + "Share it securely — the user must change it on first login.", + bg=C["bg"], fg=C["text_dim"], + font=FONT_SMALL, justify="left", wraplength=430, + ).pack(anchor="w", padx=24, pady=(0, 8)) + + # Password row + pw_frame = tk.Frame(self, bg=C["surface2"], padx=8, pady=8) + pw_frame.pack(fill="x", padx=24, pady=(0, 8)) + + self._pw_var = tk.StringVar(value=self._password) + self._pw_entry = tk.Entry( + pw_frame, textvariable=self._pw_var, + show="•", state="readonly", + readonlybackground=C["surface2"], fg=C["text"], + relief="flat", font=(FONT_BOLD[0], 14), + ) + self._pw_entry.pack(side="left", fill="x", expand=True) + + tk.Button( + pw_frame, text="👁", + command=self._toggle_reveal, + bg=C["surface2"], fg=C["text_dim"], + activebackground=C["surface"], activeforeground=C["text"], + relief="flat", font=FONT_SMALL, cursor="hand2", padx=6, + ).pack(side="left", padx=(6, 0)) + + self._copy_btn = tk.Button( + pw_frame, text="📋 Copy", + command=self._copy, + bg=C["accent"], fg=C["white"], + activebackground=C["accent_hover"], activeforeground=C["white"], + relief="flat", font=FONT_SMALL, cursor="hand2", + padx=10, pady=4, + ) + self._copy_btn.pack(side="left", padx=(6, 0)) + + # Countdown label + self._countdown_lbl = tk.Label( + self, text="", + bg=C["bg"], fg=C["text_dim"], font=FONT_SMALL, + ) + self._countdown_lbl.pack(pady=(0, 4)) + + # Warning + tk.Label( + self, + text="⚠ This dialog will close automatically. " + "The clipboard is cleared after 30 seconds.", + bg=C["bg"], fg=C["warning"], + font=FONT_SMALL, wraplength=430, justify="left", + ).pack(anchor="w", padx=24, pady=(0, 8)) + + # Close button + tk.Button( + self, text="Close", + command=self._close, + bg=C["surface2"], fg=C["text"], + activebackground=C["danger"], activeforeground=C["white"], + relief="flat", font=FONT_SMALL, cursor="hand2", + padx=12, pady=6, + ).pack(pady=(0, 16)) + + def _toggle_reveal(self): + self._revealed = not self._revealed + self._pw_entry.config(show="" if self._revealed else "•") + + def _copy(self): + self.clipboard_clear() + self.clipboard_append(self._password) + self._copy_btn.config(text="✔ Copied!", bg=COLOURS["success"]) + self.after(2000, lambda: self._copy_btn.config( + text="📋 Copy", bg=COLOURS["accent"])) + # Schedule clipboard wipe + if self._clip_job: + try: + self.after_cancel(self._clip_job) + except Exception: + pass + self._clip_job = self.after( + self._CLIP_CLEAR_S * 1000, self._clear_clipboard) + + def _clear_clipboard(self): + try: + self.clipboard_clear() + self.clipboard_append("") + except Exception: + pass + + def _tick(self): + if not self.winfo_exists(): + return + self._countdown_lbl.config( + text=f"Auto-closes in {self._remaining}s") + if self._remaining <= 0: + self._close() + return + self._remaining -= 1 + self._tick_job = self.after(1000, self._tick) + + def _close(self): + # Cancel any pending jobs + for job in (self._clip_job, self._tick_job): + if job: + try: + self.after_cancel(job) + except Exception: + pass + self._clear_clipboard() + try: + self.destroy() + except Exception: + pass \ No newline at end of file diff --git a/views/admin_websites_view.py b/views/admin_websites_view.py index ea9eaac..586bcfc 100644 --- a/views/admin_websites_view.py +++ b/views/admin_websites_view.py @@ -427,6 +427,21 @@ class WebsiteDialog(tk.Toplevel): if not name or not url: show_error("Name and URL are required.") return + + # Normalise and validate URL before writing to the database. + # Reject schemes that could be executed in-browser (javascript:, data:, etc.) + # and enforce that a host is present so the health-check thread and the + # "open in browser" action both receive a usable address. + url = _validate_and_normalise_url(url) + if url is None: + show_error( + "The URL entered is not valid.\n\n" + "Please enter a full web address, e.g.:\n" + " https://www.example.com\n" + " http://intranet.local/app" + ) + return + if visibility == "assigned" and not assigned_user_ids: show_error("Please assign at least one user, or set visibility to All Users.") return @@ -463,3 +478,42 @@ class WebsiteDialog(tk.Toplevel): self.destroy() except Exception as e: show_error(f"Save failed:\n{e}") + + +# ─── URL validation helper ──────────────────────────────────────────────────── + +def _validate_and_normalise_url(raw: str) -> "str | None": + """ + Validate and normalise a user-supplied URL string. + + Rules: + - If no scheme is present, prepend 'https://'. + - Only 'http' and 'https' schemes are accepted. + - A non-empty netloc (host) must be present. + - Returns the normalised URL string on success, None on failure. + + This prevents dangerous schemes (javascript:, data:, file:, etc.) from + reaching the database, the health-check thread, or webbrowser.open(). + """ + import urllib.parse + + if not raw: + return None + + # Prepend https:// if no scheme is given so urlparse can parse the host + if "://" not in raw: + raw = "https://" + raw + + try: + parts = urllib.parse.urlparse(raw) + except Exception: + return None + + if parts.scheme.lower() not in ("http", "https"): + return None + + if not parts.netloc: + return None + + # Reconstruct a clean URL (strips any leading/trailing whitespace artefacts) + return urllib.parse.urlunparse(parts) diff --git a/views/ai_summary_view.py b/views/ai_summary_view.py index a99a084..6eef6f1 100644 --- a/views/ai_summary_view.py +++ b/views/ai_summary_view.py @@ -217,9 +217,17 @@ class AiSummaryView(ttk.Frame): self._build_criteria_panel() - pane = tk.PanedWindow(self, orient="horizontal", + # Notebook — Analyze tab (existing layout) + History tab (new) + self._nb = ttk.Notebook(self) + self._nb.pack(fill="both", expand=True, pady=(0, 6)) + + # ── Analyze tab ─────────────────────────────────────────────────────── + analyze_tab = tk.Frame(self._nb, bg=C["bg"]) + self._nb.add(analyze_tab, text="✨ Analyze") + + pane = tk.PanedWindow(analyze_tab, orient="horizontal", bg=C["border"], sashwidth=4, sashrelief="flat") - pane.pack(fill="both", expand=True, pady=(0, 6)) + pane.pack(fill="both", expand=True) left = tk.Frame(pane, bg=C["bg"]) pane.add(left, minsize=260, width=300) @@ -229,6 +237,12 @@ class AiSummaryView(ttk.Frame): pane.add(right, minsize=350) self._build_output_panel(right) + # ── History tab ─────────────────────────────────────────────────────── + history_tab = tk.Frame(self._nb, bg=C["bg"]) + self._nb.add(history_tab, text="🕑 History") + self._build_history_panel(history_tab) + self._nb.bind("<>", self._on_tab_changed) + status_bar = tk.Frame(self, bg=C["surface2"], pady=4) status_bar.pack(fill="x", side="bottom") tk.Label(status_bar, textvariable=self._status_var, @@ -858,12 +872,24 @@ class AiSummaryView(ttk.Frame): ) full_output = header + result - self.after(0, lambda t=full_output, v=verdict: self._on_success(t, v)) + # Build artefacts for history persistence + file_names_str = ", ".join(os.path.basename(fp) for fp in file_paths) + criteria_snapshot = ( + "\n".join( + f"{i+1}. {c['title']}: {c['description']}" + for i, c in enumerate(active_criteria) + ) if active_criteria else None + ) + + self.after(0, lambda t=full_output, v=verdict, + fn=file_names_str, m=model, cs=criteria_snapshot: + self._on_success(t, v, fn, m, cs)) except Exception as exc: self.after(0, lambda e=exc: self._on_error(e)) - def _on_success(self, text: str, verdict): + def _on_success(self, text: str, verdict, file_names: str, + model: str, criteria_snapshot: str): self._stop_spinner() self._set_output_text(text) if verdict: @@ -876,6 +902,25 @@ class AiSummaryView(ttk.Frame): f"'{self.current_user.get('username')}' " f"({len(self._files)} file(s)). Verdict: {verdict or 'N/A'}." ) + # Persist to ai_analysis_log so the history panel can display it + try: + from models import save_ai_analysis + save_ai_analysis( + user_id=self.current_user["id"], + file_names=file_names, + model=model, + verdict=verdict, + criteria_snapshot=criteria_snapshot, + summary_text=text, + ) + except Exception as e: + logger.warning(f"[AI SUMMARY] Could not save analysis to history: {e}") + # Refresh history panel if it exists (it is built lazily on tab switch) + if hasattr(self, "_refresh_history"): + try: + self._refresh_history() + except Exception: + pass def _on_error(self, exc: Exception): self._stop_spinner() @@ -904,6 +949,241 @@ class AiSummaryView(ttk.Frame): self._progress.pack_forget() self._run_btn.config(state="normal", text="✨ Analyze with AI") + # -- History panel -------------------------------------------------------- + + def _build_history_panel(self, parent): + """ + Build the analysis history tab. + Admin: sees all users' analyses. + User: sees only their own. + Treeview columns: Date/Time, User (admin only), Files, Model, Verdict + Double-click or View button restores the full result in the output panel. + """ + C = COLOURS + + # ── Toolbar ─────────────────────────────────────────────────────────── + toolbar = tk.Frame(parent, bg=C["surface"], pady=8, padx=10) + toolbar.pack(fill="x") + + tk.Label(toolbar, text="📜 Analysis History", + bg=C["surface"], fg=C["text"], + font=FONT_BOLD).pack(side="left") + + tk.Button( + toolbar, text="🔍 View Result", + command=self._history_view_selected, + bg=C["accent"], fg=C["white"], + activebackground=C["accent_hover"], activeforeground=C["white"], + relief="flat", font=FONT_SMALL, cursor="hand2", + padx=8, pady=4, + ).pack(side="right", padx=(4, 0)) + + tk.Button( + toolbar, text="↻ Refresh", + command=self._refresh_history, + bg=C["surface2"], fg=C["text"], + activebackground=C["accent"], activeforeground=C["white"], + relief="flat", font=FONT_SMALL, cursor="hand2", + padx=8, pady=4, + ).pack(side="right", padx=(4, 0)) + + # ── Verdict filter ──────────────────────────────────────────────────── + filter_frame = tk.Frame(parent, bg=C["surface"], padx=10, pady=4) + filter_frame.pack(fill="x") + + tk.Label(filter_frame, text="Filter by verdict:", + bg=C["surface"], fg=C["text_dim"], + font=FONT_SMALL).pack(side="left") + + self._hist_filter_var = tk.StringVar(value="All") + for label in ("All", "PURSUE", "PASS", "UNCLEAR", "— none —"): + tk.Radiobutton( + filter_frame, text=label, + variable=self._hist_filter_var, value=label, + command=self._apply_history_filter, + bg=C["surface"], fg=C["text"], + activebackground=C["surface"], + activeforeground=C["accent"], + selectcolor=C["surface2"], + font=FONT_SMALL, cursor="hand2", + ).pack(side="left", padx=(8, 0)) + + # ── Treeview ────────────────────────────────────────────────────────── + tree_frame = tk.Frame(parent, bg=C["bg"]) + tree_frame.pack(fill="both", expand=True, padx=6, pady=(4, 4)) + + if self._is_admin: + cols = ("ID", "Date/Time", "User", "Files", "Model", "Verdict") + widths = [40, 140, 100, 280, 160, 80] + else: + cols = ("ID", "Date/Time", "Files", "Model", "Verdict") + widths = [40, 140, 360, 160, 80] + + self._hist_tree = ttk.Treeview( + tree_frame, columns=cols, + show="headings", selectmode="browse", + ) + for col, w in zip(cols, widths): + self._hist_tree.heading(col, text=col) + self._hist_tree.column( + col, width=w, + anchor="center" if col in ("ID", "Verdict") else "w", + ) + + # Colour-code verdict rows + self._hist_tree.tag_configure("PURSUE", foreground=C["success"]) + self._hist_tree.tag_configure("PASS", foreground=C["danger"]) + self._hist_tree.tag_configure("UNCLEAR", foreground=C["warning"]) + self._hist_tree.tag_configure("none", foreground=C["text_dim"]) + + vsb = ttk.Scrollbar(tree_frame, orient="vertical", + command=self._hist_tree.yview) + self._hist_tree.configure(yscrollcommand=vsb.set) + vsb.pack(side="right", fill="y") + self._hist_tree.pack(side="left", fill="both", expand=True) + + self._hist_tree.bind("", + lambda _: self._history_view_selected()) + + # ── Detail strip ───────────────────────────────────────────────────── + detail_bar = tk.Frame(parent, bg=C["surface2"], pady=6, padx=10) + detail_bar.pack(fill="x", side="bottom") + self._hist_detail_lbl = tk.Label( + detail_bar, text="Select a row to see details.", + bg=C["surface2"], fg=C["text_dim"], + font=FONT_SMALL, anchor="w", wraplength=900, justify="left", + ) + self._hist_detail_lbl.pack(fill="x") + self._hist_tree.bind("<>", self._on_history_select) + + # Store all loaded rows for client-side filtering + self._hist_all_rows = [] + + self._refresh_history() + + def _refresh_history(self): + """Reload history from DB and repopulate the treeview.""" + try: + from models import get_ai_analysis_history + uid = None if self._is_admin else self.current_user["id"] + self._hist_all_rows = get_ai_analysis_history(user_id=uid, limit=200) + except Exception as e: + logger.error(f"Could not load AI analysis history: {e}") + self._hist_all_rows = [] + self._apply_history_filter() + + def _apply_history_filter(self): + """Re-populate the treeview using the active verdict filter.""" + self._hist_tree.delete(*self._hist_tree.get_children()) + verdict_filter = self._hist_filter_var.get() + + for row in self._hist_all_rows: + verdict = row.get("verdict") or "" + # Map filter labels to DB values + if verdict_filter == "All": + pass + elif verdict_filter == "— none —": + if verdict: + continue + elif verdict != verdict_filter: + continue + + dt_str = str(row.get("analyzed_at", ""))[:16] + files = (row.get("file_names") or "")[:60] + if len(row.get("file_names") or "") > 60: + files += "…" + tag = verdict if verdict else "none" + + if self._is_admin: + values = ( + row["id"], dt_str, + row.get("username") or "—", + files, row.get("model") or "", + verdict or "—", + ) + else: + values = ( + row["id"], dt_str, + files, row.get("model") or "", + verdict or "—", + ) + + self._hist_tree.insert( + "", "end", iid=str(row["id"]), + tags=(tag,), values=values, + ) + + def _on_history_select(self, event=None): + """Show a one-line detail strip when a history row is selected.""" + sel = self._hist_tree.selection() + if not sel: + return + analysis_id = int(sel[0]) + try: + from models import get_ai_analysis_detail + detail = get_ai_analysis_detail(analysis_id) + except Exception: + return + if not detail: + return + criteria_info = ( + f" | Criteria: {detail['criteria_snapshot'][:80]}…" + if detail.get("criteria_snapshot") else + " | No criteria evaluated" + ) + self._hist_detail_lbl.config( + text=( + f"ID {detail['id']} | " + f"{str(detail.get('analyzed_at', ''))[:16]} | " + f"Model: {detail.get('model', '')} | " + f"Verdict: {detail.get('verdict') or 'N/A'}" + f"{criteria_info}" + ) + ) + + def _history_view_selected(self): + """ + Load the selected history entry's full summary into the output panel + and switch to the Analyze tab so the user can read it. + """ + sel = self._hist_tree.selection() + if not sel: + show_error("Please select an analysis to view.") + return + analysis_id = int(sel[0]) + try: + from models import get_ai_analysis_detail + detail = get_ai_analysis_detail(analysis_id) + except Exception as e: + show_error(f"Could not load analysis:\n{e}") + return + if not detail: + show_error("Analysis record not found.") + return + + # Switch to Analyze tab + self._nb.select(0) + # Restore output + self._set_output_text(detail["summary_text"]) + verdict = detail.get("verdict") + if verdict: + self._show_verdict_banner(verdict) + else: + self._hide_verdict_banner() + self._set_status( + f"Viewing history entry #{analysis_id} " + f"({str(detail.get('analyzed_at', ''))[:16]})" + ) + + def _on_tab_changed(self, event=None): + """Refresh history data whenever the user switches to the History tab.""" + try: + current = self._nb.index(self._nb.select()) + if current == 1: # History tab is index 1 + self._refresh_history() + except Exception: + pass + # -- Status bar ------------------------------------------------------------ def _set_status(self, msg: str):