Compare commits

...
10 Commits
Author SHA1 Message Date
nngo 727a422c63 04/24 Fixed email settings aren't shared accross the devices 2026-04-24 17:35:57 -04:00
nngo 71a53232a8 04/24 Fixed keyring issues 2026-04-24 17:33:20 -04:00
nngo 5d6ca5a039 04/24 Migrated info which stored in .ini to database and keyring 2026-04-24 15:37:58 -04:00
nngo f2a8d3f15b 04/24 Fixed bulk import check for duplicate 2026-04-24 14:26:07 -04:00
nngo 88b5e85c5b 04/24 Added import website in bulk 2026-04-24 13:57:49 -04:00
nngo 5f2a4e4749 04/24 Fixed running report issues 2026-04-24 12:44:51 -04:00
nngo 07bae2a724 04/24 fix bugs 2026-04-24 08:08:35 -04:00
nngo 6ec439bafe Add bid tracker 2026-04-24 07:15:48 -04:00
nngo 262c71e93b 04/23 Fix smtp issue 2026-04-23 17:52:34 -04:00
nngo 58c6218c14 04/23 Enhance app functionalities 2 2026-04-23 17:32:44 -04:00
21 changed files with 5454 additions and 693 deletions
+196 -79
View File
@@ -1,21 +1,23 @@
# CLAUDE.md — Website Checker: Complete Technical Knowledge Base # CLAUDE.md — Website Checker: Complete Technical Knowledge Base
This document captures the full architecture, design decisions, database schema, module inventory, This document is the authoritative technical reference for the **Website Checker**
feature set, and operational notes for the **Website Checker** desktop application. desktop application. Keep it current whenever code changes are made.
--- ---
## 1. Application Overview ## 1. Application Overview
Website Checker is a Python/Tkinter desktop application for shift-based website monitoring. Website Checker is a Python/Tkinter desktop application for shift-based website
Regular users log in, view websites assigned to their shift, open each site, and mark it checked. monitoring backed by a remote MySQL database. Regular users log in, view websites
Administrators manage users, websites, shifts, credentials, and run reports. assigned to their shift, open each site, and mark it checked. Administrators manage
users, websites, shifts, credentials, email reports, and AI-powered document analysis.
- **UI:** Python `tkinter` + `ttk` - **UI:** Python `tkinter` + `ttk`
- **Database:** Remote MySQL 5.7+ / MariaDB 10.3+ via `mysql-connector-python` - **Database:** Remote MySQL 5.7+ / MariaDB 10.3+ via `mysql-connector-python`
- **Entry point:** `app.py``class App(tk.Tk)` - **Entry point:** `app.py``class App(tk.Tk)`
- **Default window:** 1100×700, minimum 960×620 - **Default window:** 1100×700, minimum 960×620
- **Default theme:** Light (toggleable to Dark) - **Default theme:** Light (toggleable to Dark)
- **Platform:** Windows desktop (primary); macOS/Linux supported with limitations
--- ---
@@ -28,21 +30,23 @@ website_checker/
├── models.py All database access (data layer) ├── models.py All database access (data layer)
├── requirements.txt ├── requirements.txt
├── CLAUDE.md This file ├── CLAUDE.md This file
├── README.md Setup and deployment guide
├── USER_MANUAL.md End-user documentation
├── utils/ ├── utils/
│ ├── config_crypto.py Windows DPAPI encryption for config.ini credentials │ ├── config_crypto.py Windows DPAPI encryption for config.ini credentials
│ ├── crypto.py Fernet credential encryption (website passwords) │ ├── crypto.py Fernet credential encryption (website passwords)
│ ├── export.py CSV + Excel export │ ├── export.py CSV + Excel export
│ ├── scheduler.py Daily email report background daemon │ ├── scheduler.py Daily email report background daemon + SMTP helpers
│ └── ui_helpers.py ThemeManager, COLOURS proxy, DateEntry, widgets │ └── ui_helpers.py ThemeManager, COLOURS proxy, DateEntry, widgets
└── views/ └── views/
├── admin_dashboard_view.py Admin home — KPI cards + per-user progress table ├── admin_dashboard_view.py Admin home — KPI cards + per-user progress table
├── admin_log_view.py Activity log treeview ├── admin_log_view.py Activity log treeview
├── admin_shifts_view.py Shift CRUD + PDF export ├── admin_shifts_view.py Shift CRUD + PDF export + inactive filter toggle
├── admin_users_view.py User CRUD ├── admin_users_view.py User CRUD + secure password reset dialog
├── admin_websites_view.py Website CRUD + visibility + credentials (collapsible) ├── admin_websites_view.py Website CRUD + visibility + credentials (collapsible)
├── ai_summary_view.py AI document analysis via Groq API (NEW) ├── ai_summary_view.py AI document analysis via Groq API
├── change_password_view.py Self-service password change (all roles) ├── change_password_view.py Self-service password change (all roles)
├── email_settings_view.py SMTP / scheduled report configuration ├── email_settings_view.py SMTP / scheduled report config (3-mode security)
├── login_view.py Login form with rate-limiting countdown ├── login_view.py Login form with rate-limiting countdown
├── reports_view.py Shift Detail / Unchecked / Summary / Chart tabs ├── reports_view.py Shift Detail / Unchecked / Summary / Chart tabs
├── settings_view.py DB connection settings dialog ├── settings_view.py DB connection settings dialog
@@ -71,9 +75,11 @@ smtp_host=smtp.example.com
smtp_port=587 smtp_port=587
smtp_user=sender@example.com smtp_user=sender@example.com
smtp_password=dpapi:<base64-blob> ; encrypted smtp_password=dpapi:<base64-blob> ; encrypted
use_tls=true security=starttls ; starttls | ssl | none
use_tls=true ; legacy compat field, derived from security
recipients=admin@example.com recipients=admin@example.com
send_time=18:00 send_time=18:00
last_sent_date=2025-01-01 ; ISO date, prevents re-send after restart
[groq] [groq]
api_key=dpapi:<base64-blob> ; encrypted api_key=dpapi:<base64-blob> ; encrypted
@@ -92,6 +98,7 @@ UTF-8 log file in working directory. All INFO/WARNING/ERROR from every module.
All tables use InnoDB/utf8mb4. Created by `initialize_database()` on first launch. All tables use InnoDB/utf8mb4. Created by `initialize_database()` on first launch.
Safe ALTER TABLE migrations run automatically for columns added post-deployment. Safe ALTER TABLE migrations run automatically for columns added post-deployment.
New tables (ai_criteria, ai_analysis_log) verified with information_schema check and logged.
### users ### users
| Column | Type | Notes | | Column | Type | Notes |
@@ -111,7 +118,7 @@ Safe ALTER TABLE migrations run automatically for columns added post-deployment.
|---|---|---| |---|---|---|
| id | INT PK AUTO | | | id | INT PK AUTO | |
| name | VARCHAR(200) | | | name | VARCHAR(200) | |
| url | TEXT | | | url | TEXT | Validated by _validate_and_normalise_url (http/https only) |
| check_type | ENUM('daily','weekly') | weekly = once per ISO week | | check_type | ENUM('daily','weekly') | weekly = once per ISO week |
| visibility | ENUM('all','assigned') | assigned = only users in website_users table | | visibility | ENUM('all','assigned') | assigned = only users in website_users table |
| note | TEXT | Shown on user dashboard card | | note | TEXT | Shown on user dashboard card |
@@ -130,9 +137,10 @@ Safe ALTER TABLE migrations run automatically for columns added post-deployment.
One row per user per site per day (upserted). `user_note` optional. One row per user per site per day (upserted). `user_note` optional.
### activity_log ### activity_log
`action` codes: LOGIN, LOGOUT, SESSION_TIMEOUT, ACCOUNT_LOCKED, CREATE/UPDATE/DELETE_USER, `action` codes: LOGIN, LOGOUT, SESSION_TIMEOUT, ACCOUNT_LOCKED,
CHANGE_PASSWORD, CHANGE_PASSWORD_FAIL, RESET_PASSWORD, CREATE/UPDATE/DELETE_WEBSITE, CREATE/UPDATE/DELETE_USER, CHANGE_PASSWORD, CHANGE_PASSWORD_FAIL, RESET_PASSWORD,
ADD/REMOVE_CREDENTIAL, CREATE/UPDATE/DELETE_SHIFT, CHECK_WEBSITE, UPDATE_NOTE, 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, EXPORT_CSV, EXPORT_EXCEL, EXPORT_SHIFT_PDF, UPDATE_EMAIL_SETTINGS,
CREATE/UPDATE/DELETE_AI_CRITERION. CREATE/UPDATE/DELETE_AI_CRITERION.
@@ -146,15 +154,16 @@ E.g. "23456" = MonFri. Queried with `LOCATE(DAYOFWEEK(CURDATE()), days_of_wee
### website_users (junction): (website_id, user_id) PK — for visibility='assigned' ### website_users (junction): (website_id, user_id) PK — for visibility='assigned'
### ai_criteria ### ai_criteria
Stores evaluation criteria used by the AI to assess opportunity alignment. Stores evaluation criteria used by the AI to assess solicitation alignment.
| Column | Type | Notes | | Column | Type | Notes |
|---|---|---| |---|---|---|
| id | INT PK AUTO | | | id | INT PK AUTO | |
| title | VARCHAR(200) | Short label | | title | VARCHAR(200) | Short label shown in treeview |
| description | TEXT | Full criterion text sent to the AI | | description | TEXT | Full criterion text sent to the AI prompt |
| is_active | TINYINT(1) | Inactive criteria excluded from AI prompt | | is_active | TINYINT(1) | Inactive criteria excluded from AI prompt |
| sort_order | INT | Controls display and prompt ordering | | sort_order | INT | Controls display and prompt ordering |
| created_by | INT FK users SET NULL | | | created_by | INT FK users SET NULL | |
| created_at / updated_at | DATETIME | |
### ai_analysis_log ### ai_analysis_log
Persists every AI analysis result for history and audit purposes. Persists every AI analysis result for history and audit purposes.
@@ -173,16 +182,24 @@ Persists every AI analysis result for history and audit purposes.
## 5. Module Details ## 5. Module Details
### app.py
- App(tk.Tk) — root window; manages login state, shell, sidebar, session timeout
- IDLE_TIMEOUT_MS = 1,800,000 (30 min); IDLE_WARNING_MS = 60,000 (1 min warning)
- _reset_idle_timer throttled: Motion events fire at most once per _IDLE_THROTTLE_S=5.0 seconds
- _idle_last_reset: monotonic timestamp used for throttle check
- Admin login → scheduler.start(); logout → scheduler.stop()
- Theme toggle destroys and rebuilds the entire shell (content panels re-instantiated)
### config.py ### config.py
- DB_CONFIG loaded from config.ini at import; placeholders if file missing - DB_CONFIG loaded from config.ini at import; placeholders if file missing
- get_connection() → pool (pool_size=5) - get_connection() → pool (pool_size=5)
- initialize_database() → idempotent DDL + ALTER TABLE migrations - initialize_database() → idempotent DDL + ALTER TABLE migrations + table confirmation logs
- load_config() / save_config() → decrypt/encrypt DPAPI fields transparently - load_config() / save_config() → decrypt/encrypt DPAPI fields transparently
- migrate_plaintext_config() → one-time migration; encrypts any plain-text creds on startup - migrate_plaintext_config() → one-time migration; encrypts any plain-text creds on startup
- config_exists() → True if host, database, user are set - config_exists() → True if host, database, user are set
- reload_db_config() → re-reads config.ini, resets pool - reload_db_config() → re-reads config.ini, resets pool
### utils/config_crypto.py *(NEW)* ### utils/config_crypto.py
Windows DPAPI-based encryption for config.ini sensitive values. Windows DPAPI-based encryption for config.ini sensitive values.
- encrypt_value(plaintext) → "dpapi:<base64>" string - encrypt_value(plaintext) → "dpapi:<base64>" string
- decrypt_value(stored) → plaintext; plain-text pass-through for legacy values - decrypt_value(stored) → plaintext; plain-text pass-through for legacy values
@@ -201,6 +218,14 @@ Password helpers:
Rate-limit constants: MAX_FAILED_ATTEMPTS=5, LOCKOUT_MINUTES=15 Rate-limit constants: MAX_FAILED_ATTEMPTS=5, LOCKOUT_MINUTES=15
Password strength: PW_MIN_LENGTH=8, requires upper + digit + special char Password strength: PW_MIN_LENGTH=8, requires upper + digit + special char
Key functions:
- get_today_checks(user_id) — shift-aware; respects visibility and check_type
- get_unchecked_report(target_date, user_id) — shift-scoped; only sites user was expected to check
- get_summary_report(date_from, date_to) — shift-scoped total_sites per user per day
- get_all_criteria() / get_active_criteria() — no LEFT JOIN; simple SELECT from ai_criteria
- save_ai_analysis() / get_ai_analysis_history() / get_ai_analysis_detail()
- delete_user() — guards: self-delete prevention + last-admin prevention
### utils/crypto.py ### utils/crypto.py
- PBKDF2-HMAC-SHA256, 100,000 iterations, 32-byte salt from config.ini [crypto] - PBKDF2-HMAC-SHA256, 100,000 iterations, 32-byte salt from config.ini [crypto]
- encrypt(plaintext) → "enc:" + base64(Fernet token) - encrypt(plaintext) → "enc:" + base64(Fernet token)
@@ -218,64 +243,97 @@ Password strength: PW_MIN_LENGTH=8, requires upper + digit + special char
### utils/scheduler.py ### utils/scheduler.py
- Daemon thread; polls every 60 seconds - Daemon thread; polls every 60 seconds
- Sends HTML email once per day when now >= send_time - Sends HTML email once per day when now >= send_time
- smtp_password is DPAPI-encrypted on save (encrypt_value) / decrypted on load (decrypt_value) - smtp_password DPAPI-encrypted on save / decrypted on load
- last_sent_date in-memory (resets on restart) - last_sent_date persisted to config.ini [email] last_sent_date — survives restarts
- start() / stop() called from app.py on login/logout (admin only) - start() / stop() called from app.py on admin login/logout
- start() guards against double-start: joins existing thread before spawning new one
- _make_smtp_server(host, port, security) — centralised connection with explicit ehlo() calls
(required on Windows for reliable STARTTLS/SSL handshake)
- security field: "starttls" (port 587) | "ssl" (port 465) | "none" (port 25)
- test_smtp_connection() — 4-step diagnostic: DNS → TCP → TLS → Auth
- send_test_email() — sends real HTML test message through full pipeline
- Email report excludes users with total_sites=0 (no shift today); adds footnote count
### views/ai_summary_view.py *(NEW)* ### views/email_settings_view.py
AI-powered document analysis panel. Accessible from the sidebar for both admin and user roles. - Security mode: 3 radio buttons (STARTTLS/SSL·TLS/None) replacing old Use STARTTLS checkbox
- Selecting a security mode auto-fills the default port
- Loads legacy use_tls bool from config.ini and maps to security field on first load
- 🔌 Test Connection — step-by-step diagnostic output in status label
- 📧 Send Test Email — real email send; all buttons disabled during operation
- Save preserves last_sent_date in config.ini
### views/ai_summary_view.py
AI-powered document analysis panel. Accessible from sidebar for both roles.
Key internals: Key internals:
- Supported file types: .txt, .md, .csv, .pdf, .docx, .doc, .xlsx, .xls - Supported file types: .txt, .md, .csv, .pdf, .docx, .doc, .xlsx, .xls
- Groq API key and model selection are **visible only to admins** (user role sees neither) - Groq API key and model selection visible only to admins
- Regular users use the stored API key transparently - Regular users use stored API key transparently
- Background thread for AI calls (prevents UI freeze) - Background thread for AI calls (prevents UI freeze)
- Extraction prompt focused on procurement/solicitation fields: - Notebook layout: ✨ Analyze tab + 🕑 History tab
Solicitation Number/Type, Set-Aside, Description, Work Site, Pre-Proposal Conference, - Extraction prompt: 12 labeled fields per document + 4-section Overall Summary
POC, Square Footage, Driving Distance from office (2815 Hartland Rd, Falls Church VA), - Driving distance calculated from 2815 Hartland Rd, Falls Church VA (AI estimate)
Last Day for Questions, Due Date
- Overall summary covers: Scope of Work, Contract Period, Proposal Submission Requirements,
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 - max_tokens=4096; temperature=0.2; MAX_CHARS_PER_FILE=14,000
.doc reading (legacy binary Word format) — 3-tier fallback: Evaluation Criteria panel (collapsible):
- Admin: Add/Edit/Delete controls; CriterionDialog with live character counter (soft limit 500)
- Users: read-only treeview
- Active criteria appended to AI prompt; AI outputs RECOMMENDATION: PURSUE/PASS/UNCLEAR
- Verdict parsed by _parse_verdict() and shown as colour-coded banner (green/red/amber)
History tab:
- Every analysis saved to ai_analysis_log with verdict + criteria snapshot
- save_ai_analysis() called in _on_success() — never blocks the result display
- Filterable by verdict radio buttons (All/PURSUE/PASS/UNCLEAR/none)
- Double-click or View Result restores full output in Analyze tab
- Admins see all users' history; regular users see only their own
- Auto-refreshes on tab switch via <<NotebookTabChanged>>
.doc reading (legacy binary Word) — 3-tier fallback:
1. win32com (Word COM automation — requires MS Word installed) 1. win32com (Word COM automation — requires MS Word installed)
2. docx2txt (pure Python) 2. docx2txt (pure Python)
3. Raw ASCII scrape from binary 3. Raw ASCII scrape from binary
URL safety: _validate_and_normalise_url() rejects non-http/https schemes.
### views/admin_websites_view.py ### views/admin_websites_view.py
- Website CRUD dialog now has a **collapsible credentials section** (hidden by default) - Website CRUD dialog with collapsible credentials section (hidden by default)
- "🔑 Show Credentials" / "🔒 Hide Credentials" toggle button - _on_visibility_change uses before=self._cred_body (not creds_container — same parent)
- Auto-expands when editing a site that already has saved credentials - _validate_and_normalise_url() called in _save() before DB write
- " Add Credential" auto-expands the section if collapsed - URL validation: prepends https:// if no scheme; rejects javascript:/data:/file: etc.
- **Bug fixed:** `_on_visibility_change` previously used `before=self.creds_container`
which crosses a widget parent boundary causing a TclError; corrected to `before=self._cred_body` ### views/admin_users_view.py
- 🔑 Reset Password button generates 16-char cryptographically random password
- _PasswordResetDialog: masked field, 👁 reveal, 📋 copy (2s flash), 120s auto-close,
30s clipboard wipe, countdown timer
- Logs RESET_PASSWORD to activity_log
### views/admin_shifts_view.py
- "Show inactive" checkbox toggles visibility of inactive shifts in treeview
- _load_shifts filters by _show_inactive BooleanVar; no extra DB call
- _export_pdf(): reportlab A4; one section per active shift
### views/user_dashboard_view.py ### views/user_dashboard_view.py
Key internals: - _health_cache: {website_id: ("ok"|"slow"|"restricted"|"down", ms)}
- _health_cache: {website_id: ("ok"|"slow"|"down", ms)} — daemon threads, HEAD request, 6s timeout - ok: 2xx/3xx ≤3000ms; slow: 2xx/3xx >3000ms; restricted: 4xx; down: 5xx/network error
- Dot colours: green (<=3000ms), amber (>3000ms), red (error) - Notifications: single consolidated reminder per shift end-time group (not per site)
- Mousewheel: Enter/Leave scoped; unbind_all in destroy() - Groups unchecked sites by end_time; fires one notification listing all sites
- Keyboard shortcuts: self.bind() stored in _shortcut_ids; unbound in destroy() - _notified_sites key: (frozenset(names), end_t)
- Notifications: after(60_000) loop; plyer first, fallback to borderless Toplevel toast - Keyboard shortcuts: stored in _shortcut_ids; unbound in destroy()
- **Credentials popup no longer opens automatically on link click** - Credentials popup: 📋 copy (username 2s flash; password 2s flash + 15s clipboard wipe)
- "🔑 Credentials" button appears on each site card only if the site has saved credentials
- CredentialsPopup: 📋 copy button for username (2s flash); 📋 copy button for password ### views/reports_view.py
(2s flash + clipboard auto-cleared after 15s for security); 👁 toggle to reveal password - Tabs: Shift Detail / Unchecked / Summary / Chart
- All date fields use DateEntry (no manual text entry)
- Summary report: shift-scoped total_sites per user per day (not global count)
- Unchecked report: shift-scoped; only sites user was expected to check that day
- Chart: matplotlib Agg + FigureCanvasTkAgg; bars green≥100% / amber≥50% / red<50%
### views/login_view.py ### views/login_view.py
- check_login_allowed(username) called before authenticate() - check_login_allowed(username) called before authenticate()
- Locked: form disabled; 1-second countdown; re-enables when timer reaches 0 - Locked: form disabled; 1-second countdown; re-enables when timer reaches 0
- Not-yet-locked failed attempt: shows "N attempt(s) remaining before lockout" - Not-yet-locked failed attempt: shows "N attempt(s) remaining before lockout"
### 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 ## 6. Authentication & Security
@@ -287,10 +345,14 @@ Key internals:
| Config credential protection | Windows DPAPI (CryptProtectData) — user-account-scoped | | Config credential protection | Windows DPAPI (CryptProtectData) — user-account-scoped |
| Encrypted fields | DB user, DB password, SMTP password, Groq API key | | Encrypted fields | DB user, DB password, SMTP password, Groq API key |
| Login rate limiting | 5 attempts → 15-min lockout in DB | | Login rate limiting | 5 attempts → 15-min lockout in DB |
| Session timeout | 30-min idle; 1-min warning; any mouse/key event resets | | Session timeout | 30-min idle; 1-min warning; Motion events throttled to 1/5s |
| Password policy | 8+ chars, uppercase, digit, special character | | Password policy | 8+ chars, uppercase, digit, special character |
| Self-service change | Requires current password; strength meter; same-as-current guard | | Self-service change | Requires current password; strength meter; same-as-current guard |
| Clipboard security | Password copy auto-clears clipboard after 15 seconds | | Clipboard security | Password copy auto-clears clipboard after 15 seconds |
| URL safety | _validate_and_normalise_url rejects non-http/https schemes |
| Admin self-delete | Blocked in delete_user() |
| Last-admin deletion | Blocked in delete_user() |
| Password reset display | _PasswordResetDialog: 120s auto-close, 30s clipboard wipe |
### DPAPI Migration ### DPAPI Migration
`migrate_plaintext_config()` is called automatically on every startup (in `_init_db`). `migrate_plaintext_config()` is called automatically on every startup (in `_init_db`).
@@ -307,13 +369,21 @@ On first run after this feature was added, it encrypts any existing plain-text v
### visibility ### visibility
- all: every user in shift sees it - all: every user in shift sees it
- assigned: only users in website_users table see it - assigned: only users in website_users table see it (enforced in both shift and legacy paths)
### get_today_checks resolution ### get_today_checks resolution
1. Check if user has active shifts today (DAYOFWEEK match) 1. Check if user has active shifts today (DAYOFWEEK match)
2. If yes: union of shift websites filtered by check_type + visibility 2. If yes: shift websites filtered by check_type + visibility (website_users respected)
3. If no: all active websites filtered by visibility + check_type (legacy fallback) 3. If no: all active websites filtered by visibility + check_type (legacy fallback)
### Unchecked report scope
Uses shift membership to determine "expected" sites per user per day.
CROSS JOIN replaced with shift-aware subquery — no false positives for off-shift users.
### Summary report total_sites
Correlated subquery counts shift-assigned sites per user per check_date day-of-week.
A user in a 3-site shift shows 3 as total_sites, not the global count.
### Soft deletes ### Soft deletes
Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first). Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first).
@@ -327,6 +397,7 @@ Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first
| app.py | IDLE_WARNING_MS | 60,000 (1 min) | | app.py | IDLE_WARNING_MS | 60,000 (1 min) |
| app.py | APP_VERSION | "1.0.0" | | app.py | APP_VERSION | "1.0.0" |
| app.py | VERSION_CHECK_ENABLED | False | | app.py | VERSION_CHECK_ENABLED | False |
| app.py | App._IDLE_THROTTLE_S | 5.0 (seconds between Motion-event timer resets) |
| models.py | MAX_FAILED_ATTEMPTS | 5 | | models.py | MAX_FAILED_ATTEMPTS | 5 |
| models.py | LOCKOUT_MINUTES | 15 | | models.py | LOCKOUT_MINUTES | 15 |
| models.py | PW_MIN_LENGTH | 8 | | models.py | PW_MIN_LENGTH | 8 |
@@ -335,6 +406,10 @@ Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first
| utils/crypto.py | _ITERATIONS | 100,000 | | utils/crypto.py | _ITERATIONS | 100,000 |
| ai_summary_view.py | MAX_CHARS_PER_FILE | 14,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 | _OFFICE_ADDRESS | "2815 Hartland Road, Falls Church, VA 22043, USA" |
| ai_summary_view.py | CriterionDialog._DESC_SOFT_LIMIT | 500 (chars, soft guidance only) |
| 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) |
| utils/scheduler.py | (timeout in _make_smtp_server) | 15 seconds |
--- ---
@@ -357,9 +432,8 @@ Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first
6. Weekly site logic uses YEARWEEK(..., 1) (ISO week, Monday start). 6. Weekly site logic uses YEARWEEK(..., 1) (ISO week, Monday start).
7. The email scheduler `last_sent_date` is now persisted to `config.ini` 7. The email scheduler last_sent_date is persisted to config.ini [email] last_sent_date
`[email] last_sent_date` (ISO format). A restart after the configured (ISO format). A restart after the configured send_time will not re-send that day.
`send_time` will not re-send the report on the same day.
8. All log messages use ASCII only (hyphens not em-dashes/arrows) to prevent 8. All log messages use ASCII only (hyphens not em-dashes/arrows) to prevent
cp1252 UnicodeEncodeError on Windows consoles. cp1252 UnicodeEncodeError on Windows consoles.
@@ -368,8 +442,19 @@ Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first
If config.ini is copied to a different machine or user account, credentials If config.ini is copied to a different machine or user account, credentials
cannot be decrypted. Users must re-enter credentials via the Settings dialog. cannot be decrypted. Users must re-enter credentials via the Settings dialog.
10. The AI summary view fetches credentials from the DB at card render time (not lazily). 10. Theme toggle destroys and rebuilds the entire shell. Any unsaved in-memory
Avoid having hundreds of sites with credentials as this adds DB round-trips per render. state in the active panel is lost. AI summary output is not preserved across
theme toggles (it is in ai_analysis_log; retrieve from History tab).
11. _make_smtp_server calls ehlo() explicitly before and after starttls() — this is
required on Windows. Removing these calls breaks SMTP on many servers.
12. The security field in config.ini supersedes use_tls. On existing deployments
without a security field, load_email_config() derives it from use_tls for
backward compatibility.
13. get_all_criteria() does NOT join users table. The creator column is excluded
intentionally — the treeview doesn't show it and the join is wasted I/O.
--- ---
@@ -390,8 +475,8 @@ pywin32>=306
docx2txt>=0.8 docx2txt>=0.8
``` ```
stdlib used: tkinter, csv, smtplib, urllib.request, configparser, threading, calendar, stdlib used: tkinter, csv, smtplib, urllib.request, urllib.parse, configparser,
datetime, base64, re, tempfile threading, calendar, datetime, base64, re, tempfile, socket
--- ---
@@ -416,7 +501,28 @@ My Shift · 🤖 AI Summary · Change Password · Sign Out
--- ---
## 13. Deployment Notes ## 13. SMTP Configuration Reference
| Security Mode | Protocol | Default Port | Use When |
|---|---|---|---|
| STARTTLS | Plain → TLS upgrade | 587 | Office 365, Exchange, most corporate |
| SSL/TLS | TLS from first byte | 465 | Gmail direct, some providers |
| None | Unencrypted | 25 | Internal relay servers only |
Connection flow in _make_smtp_server:
- STARTTLS: SMTP() → ehlo() → starttls() → ehlo()
- SSL: SMTP_SSL() → ehlo()
- None: SMTP() → ehlo()
test_smtp_connection() diagnostic steps:
1. DNS — getaddrinfo(); fails = hostname wrong or no network
2. TCP — create_connection(); fails = firewall blocking the port
3. TLS — _make_smtp_server(); fails = wrong security mode or port
4. Auth — server.login(); fails = wrong username/password or App Password needed
---
## 14. Deployment Notes
- Python 3.9+ required. 3.12 tested on Windows. - Python 3.9+ required. 3.12 tested on Windows.
- tkinter bundled on Windows/macOS; Linux: apt install python3-tk - tkinter bundled on Windows/macOS; Linux: apt install python3-tk
@@ -426,30 +532,41 @@ My Shift · 🤖 AI Summary · Change Password · Sign Out
- pywin32 required for DPAPI encryption (Windows only). Install: pip install pywin32 - pywin32 required for DPAPI encryption (Windows only). Install: pip install pywin32
- Microsoft Word recommended for .doc file support in AI Summary (falls back to docx2txt) - Microsoft Word recommended for .doc file support in AI Summary (falls back to docx2txt)
- Groq API key required for AI Summary feature — free at https://console.groq.com - Groq API key required for AI Summary feature — free at https://console.groq.com
- Gmail users: enable 2FA, then create an App Password at myaccount.google.com/apppasswords
- config.ini is machine- and user-account-specific. Do not share or copy between machines.
--- ---
## 14. AI Summary Feature — Setup & Prompt Details ## 15. AI Summary Feature — Setup & Prompt Details
### Setup (Admin) ### Setup (Admin only)
1. Obtain a free Groq API key at https://console.groq.com 1. Obtain a free Groq API key at https://console.groq.com
2. Navigate to **🤖 AI Summary** in the sidebar 2. Navigate to 🤖 AI Summary in the sidebar
3. Enter the API key and select a model 3. Enter the API key and select a model
4. Click **💾 Save Settings** — key is stored DPAPI-encrypted in config.ini 4. Click 💾 Save Settings — key stored DPAPI-encrypted in config.ini [groq]
### Supported Models ### Supported Models
- llama-3.3-70b-versatile (default, recommended) - llama-3.3-70b-versatile (default, recommended)
- llama-3.1-8b-instant - llama-3.1-8b-instant (faster, smaller)
- gemma2-9b-it - gemma2-9b-it
- mixtral-8x7b-32768 - mixtral-8x7b-32768
### Prompt Strategy ### Prompt Strategy
The prompt instructs the AI to extract 12 labeled fields per document: Extraction: 12 labeled fields per document:
Solicitation Number, Type, Set-Aside, Description/Scope, Work Site, Pre-Proposal Conference, Solicitation Number, Type, Set-Aside, Description/Scope, Work Site,
POC, Square Footage, **Driving Distance from 2815 Hartland Rd Falls Church VA** (calculated by AI), Pre-Proposal Conference, POC, Square Footage,
Driving Distance from 2815 Hartland Rd Falls Church VA (AI estimate),
Last Day for Questions, Due Date, Other requirements. Last Day for Questions, Due Date, Other requirements.
Then produces an Overall Summary with 4 sections: Overall Summary: 4 sections:
A. Scope of Work · B. Contract Period · C. Proposal Submission Requirements · D. Key Deadlines A. Scope of Work · B. Contract Period · C. Proposal Submission Requirements · D. Key Deadlines
The driving distance/time is an AI estimate using major highways. Actual times may vary with traffic. Alignment evaluation (when criteria active):
- Each criterion assessed as MEETS / DOES NOT MEET / PARTIALLY MEETS
- RECOMMENDATION: PURSUE | PASS | UNCLEAR on its own line (machine-read by _parse_verdict)
- Executive Summary: 2-3 sentences
### History & Audit
- Every analysis is auto-saved to ai_analysis_log (verdict + criteria snapshot at time of run)
- update_criterion() logs full description text in activity_log.detail for audit trail
- Admins see all users' analyses; users see only their own
+135 -79
View File
@@ -1,136 +1,192 @@
# Website Checker — Desktop App # Website Checker
A Tkinter-based desktop application for shift-based website monitoring, A Windows desktop application for shift-based website monitoring.
backed by a remote MySQL database. Teams use it to systematically verify that assigned websites are operational
each shift. Administrators manage users, websites, shifts, and receive
automated daily email reports. An AI-powered document analysis panel helps
evaluate government solicitations against configurable business criteria.
--- ---
## Project Structure ## Requirements
- **Windows 10/11** (primary platform)
- **Python 3.9 or newer** (3.12 recommended; must include Tkinter — standard on Windows)
- **MySQL 5.7+ or MariaDB 10.3+** accessible over the network
- **pip packages** listed in `requirements.txt`
Optional but recommended:
- **Microsoft Word** — for reading legacy `.doc` files in AI Summary
- **Groq API key** — free at https://console.groq.com, required for AI Summary
---
## Installation
### 1. Clone or extract the project
``` ```
website_checker/ website_checker/
├── app.py ← Entry point & main application shell ├── app.py
├── config.py ← DB config, connection pool, schema init ├── config.py
├── models.py ← All database access (CRUD + logging) ├── models.py
├── requirements.txt ├── requirements.txt
├── utils/ ├── utils/
│ └── ui_helpers.py ← Theme, colour constants, reusable widgets
└── views/ └── views/
├── login_view.py ← Login screen
├── admin_users_view.py ← Admin: User Management
├── admin_websites_view.py ← Admin: Website Link Management
├── admin_log_view.py ← Admin: Activity Log
└── user_dashboard_view.py ← User: Shift Checklist Dashboard
``` ```
--- ### 2. Install dependencies
## Prerequisites
- Python 3.9 or newer (must include Tkinter — standard on Windows/macOS)
- A remote MySQL 5.7+ / MariaDB 10.3+ server
- The database and a user with CREATE / INSERT / UPDATE / DELETE privileges
---
## Setup Instructions
### 1. Install Python dependencies
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
``` ```
### 2. Configure the database connection Key packages: `mysql-connector-python`, `bcrypt`, `cryptography`, `openpyxl`,
`matplotlib`, `plyer`, `reportlab`, `groq`, `pypdf`, `python-docx`, `pywin32`, `docx2txt`
Open `config.py` and update the `DB_CONFIG` dictionary: ### 3. Create the MySQL database
```python ```sql
DB_CONFIG = { CREATE DATABASE website_checker CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
"host": "your-mysql-host", # ← change this
"port": 3306,
"database": "website_checker", # ← create this DB first
"user": "your-db-user", # ← change this
"password": "your-db-password", # ← change this
}
``` ```
> **Important:** Create the database on your MySQL server first: Grant a dedicated user full access to this database.
> ```sql
> CREATE DATABASE website_checker CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
> ```
### 3. Run the application ### 4. Run the application
```bash ```bash
python app.py python app.py
``` ```
On first launch, the app automatically creates all required tables and seeds a On first launch, a **Database Settings** dialog appears. Enter your MySQL host,
default admin account: port, database name, username, and password. Click **Test Connection**, then
**Save & Connect**.
---
## First Login
The database is auto-initialised on first successful connection.
A default administrator account is created:
| Field | Value | | Field | Value |
|----------|------------| |----------|------------|
| Username | `admin` | | Username | `admin` |
| Password | `admin123` | | Password | `admin123` |
**Change the admin password immediately after first login.** **Change this password immediately** via the sidebar → Change Password.
--- ---
## Feature Overview ## Feature Overview
### Admin Role ### For Regular Users
| Feature | Description | | Feature | Description |
|---|---| |---|---|
| User Management | Create, edit, deactivate, and delete users; assign admin or regular role | | **My Shift checklist** | View all websites assigned to your shift for today |
| Website Management | Add sites with name, URL, multiple login credentials, and notes | | **Site health indicator** | Colour dot shows if site is reachable (green/amber/red) |
| Activity Log | View a chronological audit trail of all create/edit/delete/login events | | **One-click check-off** | Click the checkbox or press Space to mark a site checked |
| Shift Dashboard | Admins can also use the shift checklist like regular users | | **Open in browser** | Click the site name or URL to open it directly |
| **Credentials popup** | View stored login credentials with password reveal and copy |
| **Shift notes** | Write a per-site note for each shift day |
| **Bulk check** | Select All Unchecked → mark all in one action |
| **Search / filter** | Search by name, filter by checked/unchecked status |
| **Shift reminder** | Desktop notification X minutes before shift ends listing all unchecked sites |
| **🤖 AI Summary** | Upload solicitation documents for AI-powered extraction and analysis |
| **Change Password** | Self-service password change with strength indicator |
### For Administrators
All user features, plus:
### Regular User Role
| Feature | Description | | Feature | Description |
|---|---| |---|---|
| Login | Secure username/password login | | **Dashboard** | KPI cards (users online, sites checked today, completion %) + per-user progress table |
| Shift Checklist | See all active websites; click URL to open in browser; check off each site | | **Website Management** | Add/edit/delete websites; set visibility (all users or assigned only); store credentials |
| Credentials Popup | Clicking a site shows its stored login credentials with a password reveal toggle | | **User Management** | Create/edit/deactivate/delete users; assign roles; reset passwords securely |
| Notes | Write or update a per-site note for each shift day | | **Shift Management** | Create/edit shifts with days-of-week, start/end times, assigned users and websites |
| Progress Bar | Visual indicator of how many sites have been checked this shift | | **Reports** | Shift Detail / Unchecked Sites / Summary / Completion Chart — all exportable |
| **Activity Log** | Full audit trail of every action in the system |
| **Email Reports** | Automated daily HTML report via SMTP; STARTTLS, SSL/TLS, or plain |
| **AI Summary settings** | Configure Groq API key, model selection, and evaluation criteria |
--- ---
## Activity Logging ## SMTP Email Setup
Every significant action is recorded in the `activity_log` table: Navigate to the sidebar gear icon → **Email Report Settings**.
| Action | Trigger | | Security Mode | Port | Use For |
|---|---|---|
| STARTTLS | 587 | Office 365, Exchange, most corporate servers |
| SSL / TLS | 465 | Gmail (with App Password), some providers |
| None | 25 | Internal relay servers only |
**Gmail users:** Enable 2-Step Verification, then create an App Password at
`myaccount.google.com/apppasswords`. Use the App Password — not your Gmail password.
Use **🔌 Test Connection** to run a step-by-step diagnostic (DNS → TCP → TLS → Auth).
Use **📧 Send Test Email** to verify full end-to-end delivery.
---
## AI Summary Setup
1. Get a free API key at https://console.groq.com
2. Open **🤖 AI Summary** → enter the key → **💾 Save Settings**
3. Upload PDF, Word, or Excel solicitation documents
4. Click **✨ Analyze with AI**
The AI extracts solicitation fields, calculates driving distance from the office,
and — when evaluation criteria are configured — issues a PURSUE / PASS / UNCLEAR
recommendation. All analyses are saved to history and can be reviewed at any time.
---
## Security Notes
- All sensitive configuration values (DB password, SMTP password, Groq API key)
are encrypted using **Windows DPAPI** and tied to the current Windows user account.
`config.ini` cannot be decrypted on another machine or account.
- Website credentials are encrypted with **Fernet (AES-128-CBC + HMAC)**.
- User passwords are hashed with **bcrypt (rounds=12)**.
- Accounts are locked for 15 minutes after 5 failed login attempts.
- Sessions time out after 30 minutes of inactivity.
- The password reset dialog auto-closes after 120 seconds and wipes the clipboard after 30 seconds.
---
## File Reference
| File | Purpose |
|---|---| |---|---|
| `LOGIN` / `LOGOUT` | User authentication events | | `app.py` | Entry point — run this |
| `CREATE_USER` | Admin creates a new user | | `config.ini` | Auto-created; stores DB/SMTP/Groq settings (encrypted) |
| `UPDATE_USER` | Admin edits a user | | `app.log` | Application log — check here when troubleshooting |
| `DELETE_USER` | Admin deletes a user | | `CLAUDE.md` | Full technical reference for developers |
| `CREATE_WEBSITE` | Admin adds a website | | `USER_MANUAL.md` | Step-by-step guide for end users |
| `UPDATE_WEBSITE` | Admin edits a website |
| `DELETE_WEBSITE` | Admin soft-deletes a website |
| `CHECK_WEBSITE` | User marks a website as checked |
| `UPDATE_NOTE` | User updates their shift note |
Logs are also written to `app.log` in the application directory.
--- ---
## Database Schema (auto-created on first run) ## Troubleshooting
- `users` — application accounts with role-based access | Symptom | Resolution |
- `websites` — monitored sites |---|---|
- `website_credentials` — multiple username/password pairs per site | "Cannot connect to database" | Verify host/port/credentials in Settings; check MySQL firewall |
- `shift_checks` — one record per user per site per day | "SMTP error: Connection unexpectedly closed" | Wrong security mode — try SSL/TLS on port 465 |
- `activity_log` — full audit trail | "Authentication failed" | Wrong password; Gmail requires an App Password |
| "AI analysis failed: 401" | Invalid or expired Groq API key |
| "No readable text found" | PDF may be scanned/image-only; try a text-based PDF |
| App won't start (tkinter error) | Reinstall Python with Tkinter option checked |
| Config.ini decryption error | config.ini was moved from another machine — re-enter credentials |
--- ---
## Notes ## Default Admin Credentials
- Websites are **soft-deleted** (flagged inactive) to preserve historical check records. | Username | Password |
- Passwords are stored as **SHA-256 hashes**. For production, consider upgrading to `bcrypt`. |---|---|
- The connection pool size is set to 5; increase `pool_size` in `config.py` for larger teams. | `admin` | `admin123` |
Change immediately after first login.
+689
View File
@@ -0,0 +1,689 @@
# Website Checker — User Manual
**Version 1.0** | For all users
---
## Table of Contents
1. [Getting Started](#1-getting-started)
2. [Logging In](#2-logging-in)
3. [My Shift — The Daily Checklist](#3-my-shift--the-daily-checklist)
4. [Checking Off a Website](#4-checking-off-a-website)
5. [Site Health Indicator](#5-site-health-indicator)
6. [Viewing Credentials](#6-viewing-credentials)
7. [Shift Reminder Notifications](#7-shift-reminder-notifications)
8. [AI Document Summary](#8-ai-document-summary)
9. [Evaluation Criteria](#9-evaluation-criteria)
10. [Analysis History](#10-analysis-history)
11. [Change Password](#11-change-password)
12. [Admin — Dashboard](#12-admin--dashboard)
13. [Admin — Website Management](#13-admin--website-management)
14. [Admin — User Management](#14-admin--user-management)
15. [Admin — Shift Management](#15-admin--shift-management)
16. [Admin — Reports](#16-admin--reports)
17. [Admin — Activity Log](#17-admin--activity-log)
18. [Admin — Email Report Settings](#18-admin--email-report-settings)
19. [Admin — AI Summary Settings](#19-admin--ai-summary-settings)
20. [Session Timeout](#20-session-timeout)
21. [Keyboard Shortcuts](#21-keyboard-shortcuts)
22. [Troubleshooting](#22-troubleshooting)
---
## 1. Getting Started
Website Checker is a desktop application that helps your team systematically
verify that assigned websites are operational each shift. Every team member
logs in, checks each site, and marks it complete. Administrators can see
team-wide progress in real time, run reports, and receive automated daily emails.
**Who sees what:**
| Feature | Regular User | Administrator |
|---|---|---|
| My Shift checklist | ✔ | ✔ |
| AI Document Summary | ✔ | ✔ |
| Change Password | ✔ | ✔ |
| Dashboard & Reports | — | ✔ |
| Manage Websites / Users / Shifts | — | ✔ |
| Email Settings & Activity Log | — | ✔ |
| AI API key & model selection | — | ✔ |
| Add/Edit Evaluation Criteria | — | ✔ |
---
## 2. Logging In
1. Launch the application by running `app.py` (or the desktop shortcut).
2. Enter your **Username** and **Password**.
3. Press **Enter** or click **Sign In**.
**If your account is locked:** After 5 failed attempts your account is locked
for 15 minutes. The login screen shows a countdown timer. Wait for it to reach
zero and try again with the correct password.
**If you forget your password:** Contact your administrator. They can reset it
from the User Management panel.
---
## 3. My Shift — The Daily Checklist
After logging in, regular users land on the **My Shift** panel. This shows all
websites you are expected to check during your current shift today.
### Layout
```
┌─────────────────────────────────────────────────────┐
│ My Shift [ Search... ] [All ▾] [✓ Select All Unchecked] │
├──────────────────────────────────────────────────────┤
│ ● [✓] Portal ↗ Open 🔑 Credentials │
│ https://portal.gov Shift: Morning │
│ Note: Check dashboard for alerts [Edit] │
├──────────────────────────────────────────────────────┤
│ ● [ ] Vendor Hub ↗ Open 🔑 Credentials │
│ https://vendor.example.com Shift: Morning │
├──────────────────────────────────────────────────────┤
│ Progress: ████████░░ 1 / 2 checked (50%) │
└─────────────────────────────────────────────────────┘
```
### Elements
- **Colour dot** — health status of the site (see §5)
- **Checkbox** — tick to mark the site as checked for today
- **Site name / URL** — click either to open the site in your browser
- **🔑 Credentials** — appears only if credentials are stored; click to view
- **Note field** — free-text area to leave a per-site note for the shift
- **Progress bar** — shows how many sites you have checked vs total
### Filtering and search
- **Search box** — type any part of a site name or URL to filter the list
- **Dropdown filter** — choose All, Checked, or Unchecked
- **✓ Select All Unchecked** — ticks all currently visible unchecked sites at once
---
## 4. Checking Off a Website
1. Open the website (click the name or URL).
2. Verify the site is operational.
3. Tick the **checkbox** on the site card, or press **Space** when the card is focused.
4. The checkbox turns green and the progress bar advances.
To **uncheck** a site, tick the checkbox again. You can re-check as many times
as needed throughout the shift.
**Weekly sites** appear only once per week. Once checked, they disappear from
your list until the following Monday.
---
## 5. Site Health Indicator
The coloured dot next to each site name is automatically refreshed in the
background when you open the checklist.
| Colour | Meaning |
|---|---|
| 🟢 Green | Site is reachable and responding quickly (under 3 seconds) |
| 🟡 Amber | Site is reachable but slow (over 3 seconds), OR responding with a 4xx access restriction |
| 🔴 Red | Site is unreachable — network error or server failure |
**Amber (restricted)** means the server responded — it is online — but rejected
the automated probe request. This is normal for sites that block automated checks.
Open the site manually to verify it is actually working.
Hover over the dot to see the tooltip with exact response time and status.
---
## 6. Viewing Credentials
If your administrator has stored login credentials for a site, a **🔑 Credentials**
button appears on that site's card.
1. Click **🔑 Credentials**.
2. A popup appears showing all stored credential sets for that site.
3. Click **📋** next to the username to copy it to the clipboard (flashes "Copied!" for 2 seconds).
4. Click **📋** next to the password to copy it (also clears the clipboard automatically after 15 seconds).
5. Click **👁** to reveal the password on screen.
The popup closes when you click outside it or press Escape.
---
## 7. Shift Reminder Notifications
If you have unchecked sites when your shift is about to end, the app sends a
desktop notification listing all unchecked sites together.
- The reminder fires **15 minutes before your shift end time** by default.
- One notification covers all unchecked sites in the shift — you will not receive
a separate popup per site.
- Example: *"3 sites unchecked: Portal, Vendor Hub, SAM.gov. Shift ends at 17:00 (15 min remaining)."*
If the desktop notification system is unavailable, an in-app toast message appears
in the bottom-right corner instead.
---
## 8. AI Document Summary
The **🤖 AI Summary** panel lets you upload government solicitation documents
and receive an AI-generated extraction of key procurement fields, plus an
alignment recommendation based on your company's evaluation criteria.
### Uploading documents
1. Click ** Add Files** in the left pane.
2. Select one or more files. Supported formats:
- PDF (`.pdf`)
- Word documents (`.docx`, `.doc`)
- Excel spreadsheets (`.xlsx`, `.xls`)
- Text files (`.txt`, `.md`, `.csv`)
3. The file list shows each file name and size.
4. To remove a file, right-click it and choose **Remove selected**, or use **✕ Clear All**.
### Running the analysis
1. Click **✨ Analyze with AI**.
2. The status bar shows progress as each file is read and the AI processes them.
3. Results appear in the right pane when complete.
### What the AI extracts
For each document, the AI identifies and labels:
| Field | Example |
|---|---|
| Solicitation Number | W912DR-25-R-0042 |
| Solicitation Type | RFP, RFQ, IFB |
| Set-Aside | Small Business, 8(a), N/A |
| Description / Scope of Work | Summary of required services |
| Work Site / Location | Full address of performance site |
| Pre-Proposal Conference | Date, time, address, mandatory or optional |
| Point of Contact (POC) | Name, phone, email |
| Total Square Footage | If applicable |
| Driving Distance & Travel Time | From office to conference/site (AI estimate) |
| Last Day to Submit Questions | Date |
| Due Date & Time | Submission deadline |
| Other Notable Requirements | Bonding, insurance, certifications |
After the per-document breakdown, the AI produces an **Overall Summary** with:
- **A. Scope of Work** — what is being requested and key performance requirements
- **B. Contract Period** — base period, option years, anticipated start date
- **C. Proposal Submission Requirements** — documents, formatting, evaluation criteria
- **D. Key Deadlines & Action Items** — all critical dates in chronological order
### Saving and copying results
- **📋 Copy** — copies the full output text to the clipboard
- **💾 Save as TXT** — saves the output to a text file you choose
- **🗑 Clear** — resets the output panel
---
## 9. Evaluation Criteria
When your administrator has configured evaluation criteria, the AI automatically
assesses the solicitation against each one and issues a recommendation.
### The criteria panel
The **📋 Evaluation Criteria** panel sits below the page header and is collapsible.
It shows all currently configured criteria with their title, description, sort order,
and active status.
Criteria marked **✔ Active** are included in every AI analysis you run.
Criteria marked **✘ Inactive** are shown for reference but not sent to the AI.
### The alignment verdict
When active criteria exist, the analysis output includes a colour-coded banner:
| Banner | Meaning |
|---|---|
| ✅ Green — PURSUE | The opportunity strongly aligns with your criteria |
| 🚫 Red — PASS | The opportunity fails one or more key criteria |
| ⚠️ Amber — UNCLEAR | Insufficient information in the documents to decide |
Below the banner, the AI lists each criterion and explains whether the solicitation
**MEETS**, **DOES NOT MEET**, or **PARTIALLY MEETS** it, citing specific details
from the documents. It ends with a 2-3 sentence executive summary.
### For administrators — managing criteria
Click ** Add Criterion** in the criteria panel header to open the Add Criterion dialog.
| Field | Description |
|---|---|
| Title * | Short name shown in the treeview (e.g. "Geographic Range") |
| Description * | Full criterion text sent to the AI — be specific |
| Sort Order | Lower numbers appear first |
| Active | Uncheck to exclude from AI analysis without deleting |
**Tips for writing effective criteria:**
- Be specific and measurable: *"Work site must be within 50 miles of Falls Church, VA"*
is better than *"Site must be nearby."*
- One criterion per idea — don't combine multiple requirements in one description.
- The description has a **500-character soft limit**. Longer descriptions consume
token budget that would otherwise go to the document content.
To **edit** a criterion, select it in the list and click **✎ Edit**, or double-click the row.
To **delete** a criterion, select it and click **✕ Delete**. You will be asked to confirm.
---
## 10. Analysis History
Every analysis you run is automatically saved. Click the **🕑 History** tab to review past results.
### History tab features
- **Treeview** — shows Date/Time, Files analyzed, Model used, and Verdict for each analysis.
Administrators see all users' analyses; regular users see only their own.
- **Verdict filter** — radio buttons let you filter by All / PURSUE / PASS / UNCLEAR / No verdict.
- **Detail strip** — selecting a row shows the criteria snapshot and other metadata at the bottom.
- **↻ Refresh** — reloads the history from the database.
- **🔍 View Result** — loads the full AI output for the selected analysis back into the
Analyze tab output panel, including the verdict banner. You can then copy or save it.
- **Double-click** on any row does the same as View Result.
The History tab refreshes automatically whenever you switch to it.
---
## 11. Change Password
All users can change their own password at any time.
1. Click **Change Password** in the sidebar.
2. Enter your **Current Password**.
3. Enter and confirm your **New Password**.
4. Click **Change Password**.
Password requirements:
- At least 8 characters
- At least one uppercase letter
- At least one digit (09)
- At least one special character (`!@#$%^&*` etc.)
A strength indicator shows how strong the new password is as you type.
---
## 12. Admin — Dashboard
The Dashboard is the administrator's home screen. It refreshes automatically every 60 seconds.
### KPI cards (top row)
| Card | Description |
|---|---|
| Active Users | Number of users with active accounts |
| Sites Checked Today | Total site checks recorded today across all users |
| Team Completion | Percentage of expected checks completed today |
| Unchecked Sites | Number of sites not yet checked today |
### Per-user progress table
Shows each active user with their checked count, total expected sites today, and
completion percentage. Completion is colour-coded:
- 🟢 Green — 100% complete
- 🟡 Amber — partially complete
- 🔴 Red — not started
Users with no shifts scheduled today are excluded from the table automatically.
---
## 13. Admin — Website Management
Navigate to **Websites** in the sidebar.
### Adding a website
1. Click ** Add Website**.
2. Fill in the required fields:
| Field | Description |
|---|---|
| Name * | Display name shown on user dashboards |
| URL * | Full web address (https:// will be added if omitted) |
| Check Type | Daily (shown every shift day) or Weekly (once per week) |
| Visibility | All Users or Assigned Only |
| Note | Optional note shown on the user's site card |
3. If **Assigned Only** is selected, a user assignment panel appears — tick the users who should see this site.
4. Optionally expand **🔑 Credentials** to add login credentials for the site.
5. Click **Save**.
### Credentials
Each website can have multiple credential sets (e.g. Admin login, Read-only login).
Each set has a **Label**, **Username**, and **Password**.
Passwords are stored encrypted and are only revealed when the user clicks 👁 in the credentials popup.
### Editing a website
Select a website in the list and click **✎ Edit**, or double-click the row.
All fields can be changed, including credentials.
### Deactivating / deleting a website
- **Deactivate** (recommended): Uncheck **Active** in the edit dialog. The site is hidden from
users but historical check records are preserved.
- **Delete**: Select and click **✕ Delete**. This is permanent.
---
## 14. Admin — User Management
Navigate to **Users** in the sidebar.
### Adding a user
1. Click ** Add User**.
2. Fill in Username, Full Name, Role (Admin or User), and Password.
3. Click **Save**.
### Editing a user
Select a user and click **✎ Edit** or double-click. You can change their name,
role, active status, and set a new password.
### Resetting a password
1. Select a user and click **🔑 Reset Password**.
2. Confirm the prompt.
3. A secure dialog appears with a randomly generated 16-character temporary password.
4. Click **📋 Copy** to copy it to the clipboard (the clipboard is wiped after 30 seconds).
5. Share the temporary password with the user securely (e.g. by phone).
6. The dialog auto-closes after 120 seconds.
7. The user should change this password immediately after logging in.
**Notes:**
- You cannot reset your own password here — use Change Password in the sidebar.
- The last active administrator account cannot be deleted.
### Deactivating vs deleting
- **Deactivate** (is_active = off): The user cannot log in but their records are preserved.
- **Delete**: Permanent. Use deactivate first if you want to keep audit history.
---
## 15. Admin — Shift Management
Navigate to **Shifts** in the sidebar.
### What is a shift?
A shift defines which websites a group of users should check, on which days of the week,
and during which time window. A user can belong to multiple shifts.
### Adding a shift
1. Click ** New Shift**.
2. Fill in:
| Field | Description |
|---|---|
| Shift Name | e.g. "Morning Team", "Weekend Check" |
| Days of Week | Tick each day this shift runs |
| Start Time | When the shift begins (HH:MM) |
| End Time | When the shift ends — used for reminder notifications |
| Note | Optional internal note |
3. On the **Users** tab, tick the users assigned to this shift.
4. On the **Websites** tab, tick the websites this shift should check (drag to reorder).
5. Click **Save**.
### Show inactive shifts
By default, deactivated shifts are hidden. Tick **Show inactive** in the toolbar
to display them (shown in grey).
### Exporting shifts to PDF
Click **⬇ Export PDF** to generate a printable PDF showing all active shifts,
their assigned users, and their website lists.
---
## 16. Admin — Reports
Navigate to **Reports** in the sidebar. There are four tabs.
### Shift Detail
Shows every check-in for a specific user and date range.
Select a user, set a date range, and click **Generate**.
Export to CSV or Excel with the export buttons.
### Unchecked Sites
Shows websites that were NOT checked on a given date, scoped to each user's actual shift.
If no date is selected, defaults to today.
Filter by a specific user or view all users.
### Summary
Shows per-user per-day statistics: sites checked, total expected, and completion percentage.
Total is based on that user's actual shift assignment for that day — not a global site count.
### Completion Chart
A bar chart showing today's completion percentage per user.
Bars are colour-coded: green ≥ 100%, amber ≥ 50%, red < 50%.
---
## 17. Admin — Activity Log
Navigate to **Activity Log** in the sidebar.
The log shows every significant action taken in the system, including:
logins, logouts, session timeouts, account lockouts, all create/edit/delete operations,
password changes and resets, website checks, exports, email setting changes,
and AI criterion changes.
Each entry shows: timestamp, acting user, action type, affected record, and detail notes.
Use the search bar to filter by username, action type, or any text in the detail field.
Use the date range pickers to narrow the time window.
---
## 18. Admin — Email Report Settings
Open **Settings** (gear icon in sidebar) → **Email Report Settings**, or click the
email icon if visible.
### Configuration fields
| Field | Description |
|---|---|
| Enable daily emails | Master on/off switch |
| SMTP Host | Your mail server address |
| Security | STARTTLS (port 587), SSL/TLS (port 465), or None (port 25) |
| SMTP Port | Auto-filled when Security mode is selected; can be changed manually |
| SMTP Username | Usually your full email address |
| SMTP Password | For Gmail, use an App Password — not your Gmail password |
| Recipients | Comma-separated email addresses to receive the report |
| Send Time | HH:MM (24-hour) — the time the report is sent each day |
### Testing the connection
**🔌 Test Connection** runs a step-by-step diagnostic:
1. **DNS** — can the server name be resolved?
2. **TCP** — can a connection be made to the port?
3. **TLS** — does the TLS/security handshake succeed?
4. **Auth** — are the username and password accepted?
The status line reports exactly which step failed and what to check.
**📧 Send Test Email** sends a real test message to the configured recipients.
Use this to confirm full end-to-end delivery after the connection test passes.
### Common setups
**Office 365 / Exchange Online:**
- Security: STARTTLS
- Port: 587
- Username: full email address
- Password: your Microsoft account password or app password
**Gmail (Google Workspace):**
- Security: SSL / TLS
- Port: 465
- Username: full Gmail address
- Password: App Password (not your Gmail password)
→ Enable 2-Step Verification → myaccount.google.com/apppasswords
**Internal relay:**
- Security: None
- Port: 25
- Username/Password: may not be required
### What the daily report contains
The report email lists every active user who had shifts today, showing:
- Number of sites checked
- Total sites expected for their shift
- Completion percentage (colour-coded)
Users with no shift scheduled today are excluded, with a footnote showing the count.
---
## 19. Admin — AI Summary Settings
### Setting up the API key
1. Get a free API key at **https://console.groq.com** (no credit card required).
2. Open **🤖 AI Summary** in the sidebar.
3. Enter the API key in the **Groq API Key** field.
4. Select a model (see below).
5. Click **💾 Save Settings**.
The API key is stored encrypted on your machine and is never shared with other users.
### Available models
| Model | Best for |
|---|---|
| llama-3.3-70b-versatile | Best overall quality — recommended default |
| llama-3.1-8b-instant | Faster responses, slightly lower quality |
| gemma2-9b-it | Good alternative for shorter documents |
| mixtral-8x7b-32768 | Large context window for very long documents |
### Managing evaluation criteria
See §9 above for full details on adding, editing, and deleting criteria.
**Key points for admins:**
- Criteria are shared — all users' analyses use the same active criteria.
- Deactivating a criterion (unchecking Active) removes it from future AI prompts
without deleting it — useful for temporarily suspending a criterion.
- The full description text is saved in the audit log each time a criterion is updated,
so you can always reconstruct what the AI was evaluating against at any historical date.
---
## 20. Session Timeout
The application automatically signs you out after **30 minutes of inactivity**
(no mouse movement, key presses, or clicks).
One minute before timeout, a warning dialog appears. Click **Stay Signed In** to
reset the timer. If you don't respond, you are signed out and must log in again.
Any unsaved work is lost.
---
## 21. Keyboard Shortcuts
Available on the My Shift dashboard:
| Shortcut | Action |
|---|---|
| `Ctrl+F` | Focus the search box |
| `Ctrl+A` | Select all unchecked sites |
| `Ctrl+Enter` | Mark selected site as checked |
| `Space` | Toggle checkbox on focused site card |
| `Escape` | Clear search / deselect |
---
## 22. Troubleshooting
### I can't log in
- Check Caps Lock — passwords are case-sensitive.
- After 5 failed attempts, your account is locked for 15 minutes.
Wait for the countdown to finish and try again.
- Contact your administrator if you need a password reset.
### My shift shows no sites
- You may not be assigned to any active shift scheduled for today.
- Contact your administrator to verify your shift assignment and the days-of-week setting.
### The health dot is always red for a site
- The site may block automated probes (returns 4xx — shown as amber, not red).
- A red dot means a network error — the server is not responding at all.
- Check your internet connection. If other sites are green, the specific site may be down.
### The AI analysis fails with "401"
- Your Groq API key is invalid or expired.
- Log in to https://console.groq.com, generate a new key, and update it in AI Summary settings.
### The AI analysis says "No readable text found"
- The PDF may be a scanned image rather than a text-based PDF.
- Try using a version of the document that is copy-pasteable, or a Word/Excel version.
### Email test fails at Step 3 (TLS)
- You are using the wrong security mode for your mail server.
- Try switching from STARTTLS to SSL/TLS (port 465) or vice versa.
- Contact your IT department to confirm the correct SMTP settings.
### Email test fails at Step 4 (Auth)
- For Gmail/Google Workspace: you must use an **App Password**, not your regular password.
Go to myaccount.google.com/apppasswords to create one.
- For Office 365: your account may require Modern Authentication or an app password.
Contact your IT department.
### I can't see the Evaluation Criteria panel
- The panel is present for all users but may be collapsed. Click **▼ Expand** to open it.
### My analysis history is empty
- History is only saved for analyses run after the feature was deployed.
Earlier analyses are not retroactively added.
- Check that you are on the 🕑 History tab (not the ✨ Analyze tab).
- Click **↻ Refresh** to reload from the database.
### The app freezes during AI analysis
- The AI call runs in a background thread — the UI should remain responsive.
- If the app appears frozen, wait up to 60 seconds. Long documents take time to process.
- If it remains unresponsive, close and reopen the application. The analysis may have failed silently — check `app.log`.
---
*For technical issues not covered here, ask your system administrator to check `app.log`
in the application directory for detailed error messages.*
+105 -12
View File
@@ -65,9 +65,24 @@ class App(tk.Tk):
SettingsView(self, on_save_callback=on_complete, first_run=True) SettingsView(self, on_save_callback=on_complete, first_run=True)
def _init_db(self): def _init_db(self):
"""Initialise database schema, then proceed to login.""" """Initialise database schema, then proceed to login.
reload_db_config() is called first to ensure DB_CONFIG is populated
with the credentials just saved by the Settings dialog. Without this,
DB_CONFIG retains its placeholder values for the remainder of the
first-run session and every connection attempt fails.
"""
from config import reload_db_config
reload_db_config()
try: try:
initialize_database() initialize_database()
# Signal the DB log handler that the pool is ready so queued
# startup log records are flushed to app_log immediately.
try:
from config import db_log_handler
db_log_handler.install()
except Exception:
pass
except Exception as e: except Exception as e:
messagebox.showerror( messagebox.showerror(
"Database Error", "Database Error",
@@ -79,7 +94,8 @@ class App(tk.Tk):
# Re-open setup so the user can correct the credentials # Re-open setup so the user can correct the credentials
self._show_setup(on_complete=self._init_db) self._show_setup(on_complete=self._init_db)
return return
# Encrypt any plain-text credentials in config.ini (one-time migration) # One-time migration: import config.ini [database] → OS keychain,
# and config.ini [email]/[groq]/[crypto] → app_settings DB table.
try: try:
from config import migrate_plaintext_config from config import migrate_plaintext_config
migrate_plaintext_config() migrate_plaintext_config()
@@ -124,9 +140,46 @@ class App(tk.Tk):
child.destroy() child.destroy()
# ── Sidebar ────────────────────────────────────────────────────────── # ── Sidebar ──────────────────────────────────────────────────────────
sidebar = tk.Frame(self, bg=COLOURS["surface"], width=200) # The sidebar outer frame holds the fixed-width column.
sidebar.pack(side="left", fill="y") # Inside it we place a Canvas + Scrollbar so that the nav items are
sidebar.pack_propagate(False) # scrollable on shorter screens (especially for admin with many items).
sidebar_outer = tk.Frame(self, bg=COLOURS["surface"], width=200)
sidebar_outer.pack(side="left", fill="y")
sidebar_outer.pack_propagate(False)
sidebar_canvas = tk.Canvas(
sidebar_outer, bg=COLOURS["surface"],
highlightthickness=0, width=200,
)
sidebar_scrollbar = ttk.Scrollbar(
sidebar_outer, orient="vertical", command=sidebar_canvas.yview)
sidebar_canvas.configure(yscrollcommand=sidebar_scrollbar.set)
# Scrollbar only visible on overflow — pack canvas first so it fills
sidebar_canvas.pack(side="left", fill="both", expand=True)
sidebar_scrollbar.pack(side="right", fill="y")
# The actual sidebar frame lives inside the canvas window
sidebar = tk.Frame(sidebar_canvas, bg=COLOURS["surface"], width=200)
sidebar_window = sidebar_canvas.create_window(
(0, 0), window=sidebar, anchor="nw", width=200)
def _on_sidebar_configure(event):
sidebar_canvas.configure(
scrollregion=sidebar_canvas.bbox("all"))
def _on_canvas_resize(event):
sidebar_canvas.itemconfig(sidebar_window, width=event.width)
sidebar.bind("<Configure>", _on_sidebar_configure)
sidebar_canvas.bind("<Configure>", _on_canvas_resize)
# Mouse-wheel scrolling inside the sidebar
def _on_mousewheel(event):
sidebar_canvas.yview_scroll(
int(-1 * (event.delta / 120)), "units")
sidebar.bind_all("<MouseWheel>", _on_mousewheel)
# App branding # App branding
brand = tk.Frame(sidebar, bg=COLOURS["surface"], pady=20) brand = tk.Frame(sidebar, bg=COLOURS["surface"], pady=20)
@@ -148,26 +201,37 @@ class App(tk.Tk):
self._nav_buttons = {} self._nav_buttons = {}
self._active_section = None self._active_section = None
# nav_groups is a list of (group_label, items) tuples.
# group_label=None means no section header — used for the user role.
# A separator is rendered between every group automatically.
if self.current_user["role"] == "admin": if self.current_user["role"] == "admin":
nav_items = [ nav_groups = [
("MANAGEMENT", [
("🏠 Dashboard", "dashboard", self._show_dashboard), ("🏠 Dashboard", "dashboard", self._show_dashboard),
("👤 Users", "users", self._show_users), ("👤 Users", "users", self._show_users),
("🌐 Websites", "websites", self._show_websites), ("🌐 Websites", "websites", self._show_websites),
("🗓 Shifts", "shift_mgmt", self._show_shift_mgmt), ("🗓 Shifts", "shift_mgmt", self._show_shift_mgmt),
("📋 Activity Log", "log", self._show_log),
("📊 My Shifts", "shifts", self._show_shifts),
("📑 Reports", "reports", self._show_reports), ("📑 Reports", "reports", self._show_reports),
("📋 Activity Log", "log", self._show_log),
("📧 Email Reports", "email", self._show_email_settings), ("📧 Email Reports", "email", self._show_email_settings),
("🤖 AI Summary", "ai_summary", self._show_ai_summary),
("⚙ Settings", "settings", self._show_settings), ("⚙ Settings", "settings", self._show_settings),
]),
("MY WORKSPACE", [
("📊 My Shifts", "shifts", self._show_shifts),
("🤖 AI Summary", "ai_summary", self._show_ai_summary),
("📌 Bid Tracker", "bid_tracker", self._show_bid_tracker),
]),
] ]
else: else:
nav_items = [ nav_groups = [
(None, [
("📊 My Shifts", "shifts", self._show_shifts), ("📊 My Shifts", "shifts", self._show_shifts),
("🤖 AI Summary", "ai_summary", self._show_ai_summary), ("🤖 AI Summary", "ai_summary", self._show_ai_summary),
("📌 Bid Tracker", "bid_tracker", self._show_bid_tracker),
]),
] ]
for label, key, cmd in nav_items: def _make_nav_btn(label, key, cmd):
btn = tk.Button( btn = tk.Button(
sidebar, sidebar,
text=label, text=label,
@@ -180,12 +244,34 @@ class App(tk.Tk):
anchor="w", anchor="w",
font=FONT_BOLD, font=FONT_BOLD,
padx=20, padx=20,
pady=12, pady=10,
cursor="hand2", cursor="hand2",
) )
btn.pack(fill="x") btn.pack(fill="x")
self._nav_buttons[key] = btn self._nav_buttons[key] = btn
for i, (group_label, items) in enumerate(nav_groups):
# Separator between groups (not before the first one)
if i > 0:
ttk.Separator(sidebar, orient="horizontal").pack(
fill="x", pady=4)
# Section header label (skip for user role where label is None)
if group_label:
tk.Label(
sidebar,
text=group_label,
bg=COLOURS["surface"],
fg=COLOURS["text_dim"],
font=(FONT_SMALL[0], 8, "bold"),
anchor="w",
padx=20,
pady=4,
).pack(fill="x")
for label, key, cmd in items:
_make_nav_btn(label, key, cmd)
# Spacer + user info at bottom # Spacer + user info at bottom
ttk.Separator(sidebar, orient="horizontal").pack(fill="x", side="bottom", pady=8) ttk.Separator(sidebar, orient="horizontal").pack(fill="x", side="bottom", pady=8)
user_frame = tk.Frame(sidebar, bg=COLOURS["surface"], pady=10) user_frame = tk.Frame(sidebar, bg=COLOURS["surface"], pady=10)
@@ -329,6 +415,12 @@ class App(tk.Tk):
from views.ai_summary_view import AiSummaryView from views.ai_summary_view import AiSummaryView
AiSummaryView(self.content, self.current_user).pack(fill="both", expand=True) AiSummaryView(self.content, self.current_user).pack(fill="both", expand=True)
def _show_bid_tracker(self):
self._clear_content()
self._set_active_nav("bid_tracker")
from views.bid_tracker_view import BidTrackerView
BidTrackerView(self.content, self.current_user).pack(fill="both", expand=True)
def _on_settings_saved(self): def _on_settings_saved(self):
"""Called after settings are saved — reload config and reconnect.""" """Called after settings are saved — reload config and reconnect."""
from config import reload_db_config from config import reload_db_config
@@ -524,6 +616,7 @@ class App(tk.Tk):
"reports": self._show_reports, "reports": self._show_reports,
"email": self._show_email_settings, "email": self._show_email_settings,
"ai_summary": self._show_ai_summary, "ai_summary": self._show_ai_summary,
"bid_tracker": self._show_bid_tracker,
"settings": self._show_settings, "settings": self._show_settings,
} }
if active in nav_map: if active in nav_map:
+9 -6
View File
@@ -27,16 +27,16 @@ Output
After Building After Building
-------------- --------------
1. Copy config.ini into the dist/WebsiteChecker/ folder before 1. No config.ini required. DB credentials are stored in the OS keychain
distributing to users, OR let users complete the first-run (Windows Credential Manager on Windows, Keychain on macOS).
database setup dialog on first launch. On first launch the Database Setup dialog will appear automatically.
2. app.log will be written to the same folder as the .exe at runtime. 2. app.log will be written to the same folder as the .exe at runtime.
3. The bundled app requires network access to the MySQL server 3. The bundled app requires network access to the MySQL server
on the configured port (default 3306). on the configured port (default 3306).
Prerequisites Prerequisites
------------- -------------
pip install pyinstaller pip install pyinstaller keyring
pip install -r requirements.txt pip install -r requirements.txt
""" """
@@ -102,6 +102,7 @@ def check_environment():
"bcrypt": "bcrypt", "bcrypt": "bcrypt",
"openpyxl": "openpyxl", "openpyxl": "openpyxl",
"cryptography": "cryptography", "cryptography": "cryptography",
"keyring": "keyring",
"matplotlib": "matplotlib", "matplotlib": "matplotlib",
"plyer": "plyer", "plyer": "plyer",
"reportlab": "reportlab", "reportlab": "reportlab",
@@ -255,8 +256,10 @@ def report(args, returncode):
print() print()
print(" Post-build checklist:") print(" Post-build checklist:")
print(" [ ] Copy config.ini into the output folder (or let users") print(" [ ] NO config.ini required — DB credentials are stored in the")
print(" complete the first-run setup dialog on launch)") print(" OS keychain (Windows Credential Manager / macOS Keychain).")
print(" On first launch the Database Setup dialog will appear.")
print(" [ ] Ensure keyring is installed: pip install keyring")
print(" [ ] Test the .exe on a clean machine without Python installed") print(" [ ] Test the .exe on a clean machine without Python installed")
print(" [ ] Verify the MySQL connection works from the target machine") print(" [ ] Verify the MySQL connection works from the target machine")
print(" [ ] Confirm app.log is written next to the .exe at runtime") print(" [ ] Confirm app.log is written next to the .exe at runtime")
-24
View File
@@ -1,24 +0,0 @@
[database]
host = 67.217.62.199
port = 3306
database = webchecker
user = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAm1sPKS5A3aczt5vkmqeO+XXJLmKwA1ojVXeBzQZ9QIwAAAAADoAAAAACAAAgAAAA6CLkACCqBm32SUXekn49rHMseysgdxMtdhywY0+zxO4QAAAA6KGNWrsRGwpImEXQZfuQikAAAABHFvkj2Z2choaPIoFwNryk31E6oZfI11uv/Ms5eIrN0uSC9VSdjYC9IU7SKCkK2z7BYQB2Uy9QoXZF6VFDXE/r
password = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAO3bOCycWnlLWFscvK6zmWysxARgAyABh7eIYDzK/KN8AAAAADoAAAAACAAAgAAAAFU2hwhMfmMnWGTZL16cWfpc2rs5/Oy1lq7aEqeC5GcUQAAAAExqVIbNsn/vH99tw8NMfBUAAAAA+pf+tJVrYOMikcjrPcFLRKS+dR6+4OGBj/BiM4IgR07Zbk8GNxddhdb9Hg8/2D51oaywFNV55lDd9aJcrtnHS
[crypto]
salt = GE453Sa0afGLfzq8EPKdXjvZyAzK499XzWxWq/ZdnFQ=
[groq]
api_key = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAvjBskxdtcNpYs3pBI8ISyRDRKIgTpRQf39CfrILduhUAAAAADoAAAAACAAAgAAAAdkFkBiC1kD4X0L3a6eoe8z2s+O/KYriTZfl8L6qOc+JAAAAABW4zpNffQtP5IMFXi3W/93xl9XCt3lbTxegcdqxOxVXrp1hRyolT5YPFdB1gjL53ywCDA+Ls4yRKGMBekl49Q0AAAAAms4kvJoK2EhZzfvcHkvDhoixVelv89fLw2ZS1yKPtMrNJFaoGd+IkOCcLAnI6eMiy5PYtPe2t2vtlZ09i1nJZ
model = llama-3.3-70b-versatile
[email]
enabled = true
smtp_host = mail.ltservicesinc.com
smtp_port = 465
smtp_user = donotreply@ltservicesinc.com
smtp_password = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAMimxQU449tZCXoe+d12bn9uAi6ZwuSzZGg7Mg/XYa5sAAAAADoAAAAACAAAgAAAAAnuQcfyetLSl/SIMvKqsb9GPv8e0Boh2/KUMnCKrtVcQAAAAU2cmO41/lsCJ3CBdnIYo1EAAAACXyd4+ESZhFnrZKdgWbDCwzVRSRyuOn37sdNpFrHRmGzD3Zy8/FivfcNBbFnfJNheVpeKsld+wnGfOlxiW0Tmm
use_tls = true
recipients = da.nguyen8744@gmail.com
send_time = 18:00
+831 -93
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+673 -57
View File
@@ -308,7 +308,7 @@ def get_all_users():
try: try:
conn = get_connection() conn = get_connection()
cur = conn.cursor(dictionary=True) cur = conn.cursor(dictionary=True)
cur.execute("SELECT id, username, role, full_name, is_active, created_at FROM users ORDER BY username") cur.execute("SELECT id, username, role, full_name, email, is_active, created_at FROM users ORDER BY username")
rows = cur.fetchall() rows = cur.fetchall()
cur.close() cur.close()
return rows return rows
@@ -322,7 +322,7 @@ def get_user_by_id(user_id: int):
try: try:
conn = get_connection() conn = get_connection()
cur = conn.cursor(dictionary=True) cur = conn.cursor(dictionary=True)
cur.execute("SELECT id, username, role, full_name, is_active FROM users WHERE id=%s", (user_id,)) cur.execute("SELECT id, username, role, full_name, email, is_active FROM users WHERE id=%s", (user_id,))
row = cur.fetchone() row = cur.fetchone()
cur.close() cur.close()
return row return row
@@ -331,40 +331,40 @@ def get_user_by_id(user_id: int):
conn.close() conn.close()
def create_user(admin_id, username, password, role, full_name): def create_user(admin_id, username, password, role, full_name, email=None):
conn = None conn = None
try: try:
conn = get_connection() conn = get_connection()
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
"INSERT INTO users (username, password, role, full_name) VALUES (%s,%s,%s,%s)", "INSERT INTO users (username, password, role, full_name, email) VALUES (%s,%s,%s,%s,%s)",
(username, _hash_password(password), role, full_name) (username, _hash_password(password), role, full_name, email or None)
) )
conn.commit() conn.commit()
new_id = cur.lastrowid new_id = cur.lastrowid
cur.close() cur.close()
log_action(admin_id, "CREATE_USER", "users", new_id, log_action(admin_id, "CREATE_USER", "users", new_id,
f"Created user '{username}' role='{role}'.") f"Created user '{username}' role='{role}' email='{email or ''}' .")
return new_id return new_id
finally: finally:
if conn: if conn:
conn.close() conn.close()
def update_user(admin_id, user_id, username, role, full_name, is_active, password=None): def update_user(admin_id, user_id, username, role, full_name, is_active, password=None, email=None):
conn = None conn = None
try: try:
conn = get_connection() conn = get_connection()
cur = conn.cursor() cur = conn.cursor()
if password: if password:
cur.execute( cur.execute(
"UPDATE users SET username=%s, role=%s, full_name=%s, is_active=%s, password=%s WHERE id=%s", "UPDATE users SET username=%s, role=%s, full_name=%s, is_active=%s, email=%s, password=%s WHERE id=%s",
(username, role, full_name, is_active, _hash_password(password), user_id) (username, role, full_name, is_active, email or None, _hash_password(password), user_id)
) )
else: else:
cur.execute( cur.execute(
"UPDATE users SET username=%s, role=%s, full_name=%s, is_active=%s WHERE id=%s", "UPDATE users SET username=%s, role=%s, full_name=%s, is_active=%s, email=%s WHERE id=%s",
(username, role, full_name, is_active, user_id) (username, role, full_name, is_active, email or None, user_id)
) )
conn.commit() conn.commit()
cur.close() cur.close()
@@ -445,6 +445,42 @@ def get_all_websites():
conn.close() conn.close()
def get_existing_website_urls() -> set:
"""Return a set of normalised (lowercased, stripped) URLs already in the DB.
Used by the bulk import dialog to detect duplicates before inserting.
Includes both active and inactive websites so re-importing a soft-deleted
site is also flagged rather than silently creating a second record.
"""
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute("SELECT url FROM websites")
rows = cur.fetchall()
cur.close()
return {(r[0] or "").strip().lower() for r in rows}
finally:
if conn:
conn.close()
def get_existing_website_names() -> set:
"""Return a set of normalised (lowercased, stripped) names already in the DB.
Used alongside get_existing_website_urls() for duplicate detection.
"""
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute("SELECT name FROM websites")
rows = cur.fetchall()
cur.close()
return {(r[0] or "").strip().lower() for r in rows}
finally:
if conn:
conn.close()
def get_website_by_id(website_id: int): def get_website_by_id(website_id: int):
conn = None conn = None
try: try:
@@ -805,20 +841,47 @@ def update_check_note(user_id, website_id, user_note):
conn.close() conn.close()
def get_activity_log(limit=200): def get_activity_log(limit=200, search: str = ""):
"""
Return recent activity_log entries, newest first.
The DB column is created_at; we alias it to logged_at for a consistent
key across both log tables so the view layer never needs to care.
"""
conn = None conn = None
try: try:
conn = get_connection() conn = get_connection()
cur = conn.cursor(dictionary=True) cur = conn.cursor(dictionary=True)
cur.execute(
where = ""
params: list = []
if search:
where = """
WHERE al.action LIKE %s
OR u.username LIKE %s
OR al.entity LIKE %s
OR al.detail LIKE %s
""" """
SELECT al.*, u.username like = f"%{search}%"
params.extend([like, like, like, like])
params.append(limit)
cur.execute(
f"""
SELECT al.id,
al.user_id,
al.action,
al.entity,
al.entity_id,
al.detail,
al.logged_at,
u.username
FROM activity_log al FROM activity_log al
LEFT JOIN users u ON u.id = al.user_id LEFT JOIN users u ON u.id = al.user_id
{where}
ORDER BY al.logged_at DESC ORDER BY al.logged_at DESC
LIMIT %s LIMIT %s
""", """,
(limit,) params,
) )
rows = cur.fetchall() rows = cur.fetchall()
cur.close() cur.close()
@@ -926,15 +989,26 @@ def get_unchecked_report(target_date=None, user_id=None):
# One user_id param slot for the user_filter inside the main query # One user_id param slot for the user_filter inside the main query
user_filter_params = [user_id] if user_id else [] user_filter_params = [user_id] if user_id else []
# The subquery needs: dow_params, (optional user_id for shift_users join) # Fix for Bug #1: MySQL does not allow a derived table (subquery in FROM/JOIN)
# Outer query needs: date_params, user_filter_params, date_params # to reference outer-query aliases (e.g. u.id). The previous approach used
# EXISTS(shift) subquery: dow_params + (user_id if filtering by user) # "AND su.user_id = u.id" inside a derived table when user_id=None, which
# We build params carefully to match the f-string placeholders below. # MySQL rejects with "Unknown column 'u.id' in 'where clause'".
#
# Solution: replace the derived-table JOIN with an EXISTS correlated subquery
# in the WHERE clause. Correlated subqueries CAN reference outer aliases,
# so u.id is always in scope. This works identically for both the
# single-user and all-users cases.
#
# Param order:
# date_params → {date_expr} in SELECT
# user_filter_params → {user_filter} AND u.id = %s
# dow_params → DAYOFWEEK(%s) in EXISTS
# user_filter_params → su.user_id = u.id or %s in EXISTS (always u.id now)
# date_params → DATE(sc.checked_at) = {date_expr}
params = ( params = (
date_params # {date_expr} in SELECT date_params # {date_expr} in SELECT
+ user_filter_params # {user_filter} AND u.id = %s + user_filter_params # {user_filter} AND u.id = %s
+ dow_params # DAYOFWEEK(%s) in shift EXISTS + dow_params # DAYOFWEEK(%s) in shift EXISTS
+ (user_id and [user_id] or []) # su.user_id=%s in shift EXISTS
+ date_params # DATE(sc.checked_at) = {date_expr} + date_params # DATE(sc.checked_at) = {date_expr}
) )
@@ -948,29 +1022,27 @@ def get_unchecked_report(target_date=None, user_id=None):
w.url, w.url,
'Not Checked' AS status 'Not Checked' AS status
FROM users u FROM users u
-- Only websites the user was expected to check on this date JOIN websites w ON w.is_active = 1
JOIN (
SELECT DISTINCT sw.website_id
FROM shift_websites sw
JOIN shifts s ON s.id = sw.shift_id
JOIN shift_users su ON su.shift_id = s.id
JOIN websites w2 ON w2.id = sw.website_id
WHERE s.is_active = 1
AND w2.is_active = 1
AND LOCATE(CAST({dow_expr} AS CHAR), s.days_of_week) > 0
{"AND su.user_id = %s" if user_id else "AND su.user_id = u.id"}
AND (
w2.visibility = 'all'
OR EXISTS (
SELECT 1 FROM website_users wu
WHERE wu.website_id = w2.id AND wu.user_id = su.user_id
)
)
) expected ON 1=1
JOIN websites w ON w.id = expected.website_id
WHERE u.is_active = 1 WHERE u.is_active = 1
AND u.role = 'user' AND u.role = 'user'
{user_filter} {user_filter}
-- Only include websites the user was expected to check via their shifts
AND EXISTS (
SELECT 1
FROM shift_websites sw
JOIN shifts s ON s.id = sw.shift_id
JOIN shift_users su ON su.shift_id = s.id AND su.user_id = u.id
WHERE sw.website_id = w.id
AND s.is_active = 1
AND LOCATE(CAST({dow_expr} AS CHAR), s.days_of_week) > 0
AND (
w.visibility = 'all'
OR EXISTS (
SELECT 1 FROM website_users wu
WHERE wu.website_id = w.id AND wu.user_id = u.id
)
)
)
-- Exclude sites the user DID check on the target date -- Exclude sites the user DID check on the target date
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM shift_checks sc SELECT 1 FROM shift_checks sc
@@ -993,7 +1065,17 @@ def get_unchecked_report(target_date=None, user_id=None):
def get_summary_report(date_from=None, date_to=None): def get_summary_report(date_from=None, date_to=None):
""" """
Per-user per-day summary: total sites checked vs total active sites. Per-user per-day summary: sites checked vs the sites that user was
expected to check on that specific day (shift-scoped total).
The previous implementation used a global COUNT(*) of all active websites
as the denominator, producing misleading percentages — a user in a 3-site
shift who checked all 3 would show 15% against 20 global sites.
The corrected subquery counts the distinct websites in the shifts the user
was assigned to that ran on the check_date's day-of-week. For historical
dates this still uses DAYOFWEEK(check_date) to match the shift schedule.
Columns: check_date, username, full_name, checked_count, total_sites, pct_complete Columns: check_date, username, full_name, checked_count, total_sites, pct_complete
""" """
conn = None conn = None
@@ -1012,24 +1094,63 @@ def get_summary_report(date_from=None, date_to=None):
where_clause = ("WHERE " + " AND ".join(conditions)) if conditions else "" where_clause = ("WHERE " + " AND ".join(conditions)) if conditions else ""
# Fix for Bug #2: MySQL only_full_group_by rejects referencing sc.checked_at
# (the full timestamp) inside correlated subqueries when only DATE(sc.checked_at)
# appears in the GROUP BY clause. Even though sc.checked_at is functionally
# determined by DATE(sc.checked_at) in intent, MySQL strict mode does not
# infer that relationship automatically.
#
# Solution: pre-aggregate in a CTE (agg) that produces a single, unambiguous
# check_date (DATE) and user_id per group. The outer SELECT then references
# agg.check_date — a fully grouped column — inside the correlated subqueries,
# satisfying only_full_group_by completely.
cur.execute( cur.execute(
f""" f"""
WITH agg AS (
SELECT SELECT
DATE(sc.checked_at) AS check_date, DATE(sc.checked_at) AS check_date,
u.username, sc.user_id,
COALESCE(u.full_name, u.username) AS full_name, COUNT(DISTINCT sc.website_id) AS checked_count
COUNT(DISTINCT sc.website_id) AS checked_count,
(SELECT COUNT(*) FROM websites WHERE is_active=1) AS total_sites,
ROUND(
COUNT(DISTINCT sc.website_id) * 100.0 /
NULLIF((SELECT COUNT(*) FROM websites WHERE is_active=1), 0),
1
) AS pct_complete
FROM shift_checks sc FROM shift_checks sc
JOIN users u ON u.id = sc.user_id
{where_clause} {where_clause}
GROUP BY DATE(sc.checked_at), sc.user_id GROUP BY DATE(sc.checked_at), sc.user_id
ORDER BY check_date DESC, u.username )
SELECT
agg.check_date,
u.username,
COALESCE(u.full_name, u.username) AS full_name,
agg.checked_count,
(
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(agg.check_date) AS CHAR),
s2.days_of_week
) > 0
) AS total_sites,
ROUND(
agg.checked_count * 100.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(agg.check_date) AS CHAR),
s2.days_of_week
) > 0
), 0),
1
) AS pct_complete
FROM agg
JOIN users u ON u.id = agg.user_id
ORDER BY agg.check_date DESC, u.username
""", """,
params params
) )
@@ -1430,17 +1551,22 @@ def get_unchecked_sites_for_user(user_id: int):
# ─── AI Criteria CRUD ───────────────────────────────────────────────────────── # ─── AI Criteria CRUD ─────────────────────────────────────────────────────────
def get_all_criteria(): 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 conn = None
try: try:
conn = get_connection() conn = get_connection()
cur = conn.cursor(dictionary=True) cur = conn.cursor(dictionary=True)
cur.execute( cur.execute(
""" """
SELECT c.*, u.username AS creator SELECT id, title, description, is_active, sort_order,
FROM ai_criteria c created_by, created_at, updated_at
LEFT JOIN users u ON u.id = c.created_by FROM ai_criteria
ORDER BY c.sort_order, c.id ORDER BY sort_order, id
""" """
) )
rows = cur.fetchall() rows = cur.fetchall()
@@ -1656,3 +1782,493 @@ def get_ai_analysis_detail(analysis_id: int):
finally: finally:
if conn: if conn:
conn.close() conn.close()
# ─── Bid Tracker ──────────────────────────────────────────────────────────────
def get_all_bids(status_filter=None):
"""
Return all bids ordered by updated_at DESC.
status_filter: optional string to filter by status, e.g. 'open'.
Includes creator username and latest update timestamp.
"""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
where = ""
params = []
if status_filter and status_filter != "all":
where = "WHERE b.status = %s"
params.append(status_filter)
cur.execute(
f"""
SELECT b.*,
COALESCE(u.full_name, u.username) AS creator_name,
u.username AS creator_username,
(SELECT COUNT(*) FROM bid_updates bu WHERE bu.bid_id = b.id)
AS update_count,
(SELECT MAX(bu2.created_at) FROM bid_updates bu2
WHERE bu2.bid_id = b.id) AS last_update_at
FROM bid_tracker b
LEFT JOIN users u ON u.id = b.created_by
{where}
ORDER BY b.updated_at DESC
""",
params
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def get_bid_by_id(bid_id: int):
"""Return a single bid row with creator info."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"""
SELECT b.*,
COALESCE(u.full_name, u.username) AS creator_name,
u.username AS creator_username
FROM bid_tracker b
LEFT JOIN users u ON u.id = b.created_by
WHERE b.id = %s
""",
(bid_id,)
)
row = cur.fetchone()
cur.close()
return row
finally:
if conn:
conn.close()
def create_bid(user_id: int, title: str, url: str,
source: str, notes: str, status: str) -> int:
"""Insert a new bid. Returns the new row id."""
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"""
INSERT INTO bid_tracker (title, url, source, notes, status, created_by)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(title, url, source or None, notes or None, status, user_id)
)
conn.commit()
new_id = cur.lastrowid
cur.close()
log_action(user_id, "CREATE_BID", "bid_tracker", new_id,
f"Created bid '{title}' status={status} url={url[:80]}")
logger.info(f"Bid id={new_id} '{title}' created by user_id={user_id}.")
return new_id
finally:
if conn:
conn.close()
def update_bid(user_id: int, bid_id: int, title: str, url: str,
source: str, notes: str, status: str):
"""Update an existing bid record."""
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"""
UPDATE bid_tracker
SET title=%s, url=%s, source=%s, notes=%s, status=%s
WHERE id=%s
""",
(title, url, source or None, notes or None, status, bid_id)
)
conn.commit()
cur.close()
log_action(user_id, "UPDATE_BID", "bid_tracker", bid_id,
f"Updated bid id={bid_id} '{title}' status={status}")
logger.info(f"Bid id={bid_id} updated by user_id={user_id}.")
finally:
if conn:
conn.close()
def delete_bid(user_id: int, bid_id: int):
"""Hard-delete a bid and all its updates (CASCADE)."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute("SELECT title FROM bid_tracker WHERE id=%s", (bid_id,))
row = cur.fetchone()
title = row["title"] if row else str(bid_id)
cur.execute("DELETE FROM bid_tracker WHERE id=%s", (bid_id,))
conn.commit()
cur.close()
log_action(user_id, "DELETE_BID", "bid_tracker", bid_id,
f"Deleted bid id={bid_id} '{title}'")
logger.info(f"Bid id={bid_id} '{title}' deleted by user_id={user_id}.")
finally:
if conn:
conn.close()
def get_bid_updates(bid_id: int):
"""Return all updates for a bid, newest first."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"""
SELECT bu.*,
COALESCE(u.full_name, u.username) AS author_name,
u.username AS author_username
FROM bid_updates bu
LEFT JOIN users u ON u.id = bu.user_id
WHERE bu.bid_id = %s
ORDER BY bu.created_at DESC
""",
(bid_id,)
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def add_bid_update(user_id: int, bid_id: int, content: str) -> int:
"""Add an update entry to a bid. Also touches bid_tracker.updated_at."""
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO bid_updates (bid_id, user_id, content) VALUES (%s, %s, %s)",
(bid_id, user_id, content)
)
# Touch bid updated_at so it sorts to top of list
cur.execute(
"UPDATE bid_tracker SET updated_at=NOW() WHERE id=%s", (bid_id,))
conn.commit()
new_id = cur.lastrowid
cur.close()
log_action(user_id, "ADD_BID_UPDATE", "bid_updates", new_id,
f"Added update to bid id={bid_id}: {content[:100]}")
logger.info(f"Bid update id={new_id} added to bid id={bid_id} "
f"by user_id={user_id}.")
return new_id
finally:
if conn:
conn.close()
def delete_bid_update(user_id: int, update_id: int):
"""Delete a single bid update entry."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute("SELECT bid_id, content FROM bid_updates WHERE id=%s",
(update_id,))
row = cur.fetchone()
bid_id = row["bid_id"] if row else 0
snippet = (row["content"][:60] if row else "") if row else ""
cur.execute("DELETE FROM bid_updates WHERE id=%s", (update_id,))
conn.commit()
cur.close()
log_action(user_id, "DELETE_BID_UPDATE", "bid_updates", update_id,
f"Deleted update id={update_id} from bid id={bid_id}: {snippet}")
logger.info(f"Bid update id={update_id} deleted by user_id={user_id}.")
finally:
if conn:
conn.close()
# ─── Bid Tracker CRUD ─────────────────────────────────────────────────────────
BID_STATUSES = ("open", "monitoring", "awarded", "no_bid", "cancelled")
def get_all_bids(status_filter: str = "") -> list:
"""
Return all bids ordered by due_date (nulls last), then created_at desc.
When status_filter is given, only bids with that status are returned.
Includes the adder's username and the count of updates per bid.
"""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
where = "WHERE b.status = %s" if status_filter else ""
params = (status_filter,) if status_filter else ()
cur.execute(
f"""
SELECT b.*,
u.username AS added_by_username,
COUNT(bu.id) AS update_count
FROM bid_tracker b
LEFT JOIN users u ON u.id = b.added_by
LEFT JOIN bid_updates bu ON bu.bid_id = b.id
{where}
GROUP BY b.id
ORDER BY b.due_date IS NULL, b.due_date ASC, b.created_at DESC
""",
params,
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def get_bid(bid_id: int) -> dict | None:
"""Return a single bid row with adder username."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"""
SELECT b.*, u.username AS added_by_username
FROM bid_tracker b
LEFT JOIN users u ON u.id = b.added_by
WHERE b.id = %s
""",
(bid_id,),
)
row = cur.fetchone()
cur.close()
return row
finally:
if conn:
conn.close()
def create_bid(user_id: int, title: str, url: str, source: str,
solicitation_number: str, status: str,
due_date, notes: str) -> int:
"""Insert a new bid. Returns new row id."""
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"""
INSERT INTO bid_tracker
(title, url, source, solicitation_number, status, due_date, notes, added_by)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""",
(title, url,
source or None, solicitation_number or None,
status, due_date or None, notes or None,
user_id),
)
conn.commit()
new_id = cur.lastrowid
cur.close()
log_action(user_id, "CREATE_BID", "bid_tracker", new_id,
f"Created bid '{title}' status={status}.")
logger.info(f"Bid id={new_id} '{title}' created by user_id={user_id}.")
return new_id
finally:
if conn:
conn.close()
def update_bid(user_id: int, bid_id: int, title: str, url: str,
source: str, solicitation_number: str, status: str,
due_date, notes: str):
"""Update an existing bid."""
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"""
UPDATE bid_tracker
SET title=%s, url=%s, source=%s, solicitation_number=%s,
status=%s, due_date=%s, notes=%s
WHERE id=%s
""",
(title, url,
source or None, solicitation_number or None,
status, due_date or None, notes or None,
bid_id),
)
conn.commit()
cur.close()
log_action(user_id, "UPDATE_BID", "bid_tracker", bid_id,
f"Updated bid '{title}' status={status}.")
logger.info(f"Bid id={bid_id} updated by user_id={user_id}.")
finally:
if conn:
conn.close()
def delete_bid(user_id: int, bid_id: int):
"""Hard-delete a bid and all its updates (CASCADE)."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute("SELECT title FROM bid_tracker WHERE id=%s", (bid_id,))
row = cur.fetchone()
title = row["title"] if row else str(bid_id)
cur.execute("DELETE FROM bid_tracker WHERE id=%s", (bid_id,))
conn.commit()
cur.close()
log_action(user_id, "DELETE_BID", "bid_tracker", bid_id,
f"Deleted bid '{title}'.")
logger.info(f"Bid id={bid_id} '{title}' deleted by user_id={user_id}.")
finally:
if conn:
conn.close()
# ─── Bid Updates CRUD ─────────────────────────────────────────────────────────
def get_bid_updates(bid_id: int) -> list:
"""Return all updates for a bid, newest first."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"""
SELECT bu.*, u.username AS posted_by_username,
COALESCE(u.full_name, u.username) AS posted_by_full_name
FROM bid_updates bu
LEFT JOIN users u ON u.id = bu.user_id
WHERE bu.bid_id = %s
ORDER BY bu.created_at DESC
""",
(bid_id,),
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def add_bid_update(user_id: int, bid_id: int, content: str) -> int:
"""Post a new update on a bid. Returns new row id."""
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO bid_updates (bid_id, user_id, content) VALUES (%s, %s, %s)",
(bid_id, user_id, content),
)
conn.commit()
new_id = cur.lastrowid
cur.close()
log_action(user_id, "ADD_BID_UPDATE", "bid_updates", new_id,
f"Posted update on bid_id={bid_id}.")
logger.info(f"Bid update id={new_id} posted on bid_id={bid_id} by user_id={user_id}.")
return new_id
finally:
if conn:
conn.close()
def delete_bid_update(user_id: int, update_id: int):
"""Delete a single bid update. Any user can delete their own; admin can delete any."""
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute("DELETE FROM bid_updates WHERE id=%s", (update_id,))
conn.commit()
cur.close()
log_action(user_id, "DELETE_BID_UPDATE", "bid_updates", update_id,
f"Deleted bid update id={update_id}.")
logger.info(f"Bid update id={update_id} deleted by user_id={user_id}.")
finally:
if conn:
conn.close()
# ─── Application Log (DB-backed logging) ─────────────────────────────────────
def get_app_log(limit: int = 500, level_filter: str = "",
search: str = "") -> list:
"""
Return recent application log entries from app_log.
level_filter : one of DEBUG/INFO/WARNING/ERROR/CRITICAL — empty = all
search : substring match against logger_name or message
"""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
conditions = []
params: list = []
if level_filter:
conditions.append("level = %s")
params.append(level_filter)
if search:
conditions.append("(logger_name LIKE %s OR message LIKE %s)")
like = f"%{search}%"
params.extend([like, like])
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
params.append(limit)
cur.execute(
f"""
SELECT id, level, logger_name, message, logged_at
FROM app_log
{where}
ORDER BY logged_at DESC
LIMIT %s
""",
params,
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def purge_app_log(older_than_days: int = 30):
"""
Delete app_log entries older than older_than_days days.
Called from the admin log view's Purge button.
"""
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"DELETE FROM app_log WHERE logged_at < NOW() - INTERVAL %s DAY",
(older_than_days,),
)
deleted = cur.rowcount
conn.commit()
cur.close()
logger.info(f"App log purged: {deleted} records older than "
f"{older_than_days} days deleted.")
return deleted
finally:
if conn:
conn.close()
+1
View File
@@ -10,3 +10,4 @@ pypdf>=3.0.0
python-docx>=1.0.0 python-docx>=1.0.0
pywin32>=306 pywin32>=306
docx2txt>=0.8 docx2txt>=0.8
keyring
+108 -20
View File
@@ -2,14 +2,18 @@
utils/crypto.py Fernet symmetric encryption for website credentials. utils/crypto.py Fernet symmetric encryption for website credentials.
Key derivation: Key derivation:
- A 32-byte random salt is generated on first use and stored in config.ini - A 32-byte random salt is generated on first use and stored in the
under [crypto] / salt. app_settings table (key: 'crypto.salt') instead of config.ini.
- The Fernet key is derived from the salt + a fixed application secret - The Fernet key is derived from the salt + a fixed application secret
using PBKDF2-HMAC-SHA256 (100,000 iterations). using PBKDF2-HMAC-SHA256 (100,000 iterations).
- This means credentials are tied to the specific config.ini file on the - Because the salt lives in the shared MySQL database, any machine that
operator's machine; moving config.ini to another machine retains access. connects to the same DB can decrypt credentials without needing a local
config.ini making the app fully portable across machines.
Migration: Migration:
- On first start after this change, if config.ini still contains a
[crypto]/salt entry it is automatically migrated to app_settings and
removed from the file.
- _decrypt() tries Fernet first; if that fails it returns the raw value - _decrypt() tries Fernet first; if that fails it returns the raw value
unchanged so that plaintext legacy credentials are still readable. unchanged so that plaintext legacy credentials are still readable.
- Callers should re-encrypt on next write (update_website handles this). - Callers should re-encrypt on next write (update_website handles this).
@@ -18,7 +22,6 @@ Migration:
import base64 import base64
import logging import logging
import os import os
import configparser
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
@@ -27,30 +30,116 @@ from cryptography.hazmat.primitives import hashes
logger = logging.getLogger("crypto") logger = logging.getLogger("crypto")
_APP_SECRET = b"WebsiteChecker-v1-CredentialKey" _APP_SECRET = b"WebsiteChecker-v1-CredentialKey"
_CONFIG_FILE = "config.ini"
_ITERATIONS = 100_000 _ITERATIONS = 100_000
_SETTING_KEY = "crypto.salt"
_fernet: "Fernet | None" = None _fernet: "Fernet | None" = None
# ─── Key bootstrap ──────────────────────────────────────────────────────────── # ─── Key bootstrap ────────────────────────────────────────────────────────────
def _ensure_app_settings_table() -> bool:
"""
Guarantee the app_settings table exists before we try to read/write it.
Returns True if the table is available, False if it could not be created
(e.g. the DB pool itself is not yet ready).
This guard is necessary because crypto.py can be called during login
before initialize_database() has had a chance to run on a fresh install
or an upgraded database that doesn't yet have the app_settings table.
"""
try:
from config import get_connection
conn = get_connection()
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS app_settings (
key_name VARCHAR(100) NOT NULL PRIMARY KEY,
value TEXT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
)
conn.commit()
cur.close()
conn.close()
return True
except Exception as e:
logger.warning(f"Crypto: could not ensure app_settings table: {e}")
return False
def _get_or_create_salt() -> bytes: def _get_or_create_salt() -> bytes:
"""Read salt from config.ini [crypto] section; create and persist if absent.""" """
Read the Fernet salt from app_settings, falling back to config.ini for
legacy installs, and generating a fresh salt for brand-new installs.
Order of precedence:
1. app_settings table (primary shared across machines via the DB)
2. config.ini [crypto]/salt (legacy migration path)
3. Generate a new random salt and persist it to app_settings
The table is created here if it doesn't yet exist, so this function is
safe to call before initialize_database() has run.
"""
# ── Step 1: try config.ini first (fastest, no DB needed yet) ─────────────
# Reading config.ini for the salt is always safe — it doesn't require the
# app_settings table to exist. If found, we attempt to also persist it to
# the DB (best-effort), but we use the value regardless.
legacy_b64 = None
try:
import configparser, os as _os
_cfg_file = "config.ini"
if _os.path.exists(_cfg_file):
cfg = configparser.ConfigParser() cfg = configparser.ConfigParser()
cfg.read(_CONFIG_FILE, encoding="utf-8") cfg.read(_cfg_file, encoding="utf-8")
if cfg.has_option("crypto", "salt"):
legacy_b64 = cfg.get("crypto", "salt")
except Exception as e:
logger.warning(f"Crypto: could not read config.ini for salt: {e}")
if "crypto" in cfg and cfg["crypto"].get("salt"): # ── Step 2: ensure app_settings table exists ──────────────────────────────
return base64.b64decode(cfg["crypto"]["salt"]) table_ok = _ensure_app_settings_table()
# Generate a fresh 32-byte salt # ── Step 3: try to read salt from DB ──────────────────────────────────────
if table_ok:
try:
from config import get_setting
raw = get_setting(_SETTING_KEY, "")
if raw:
return base64.b64decode(raw)
except Exception as e:
logger.warning(f"Crypto: could not read salt from app_settings: {e}")
# ── Step 4: migrate legacy salt from config.ini → DB ─────────────────────
if legacy_b64:
if table_ok:
try:
from config import set_setting
set_setting(_SETTING_KEY, legacy_b64)
logger.info("Crypto: migrated salt from config.ini to app_settings.")
except Exception as e:
logger.warning(f"Crypto: could not persist migrated salt to DB: {e}")
else:
logger.info("Crypto: using salt from config.ini (app_settings not available yet).")
return base64.b64decode(legacy_b64)
# ── Step 5: generate a fresh salt ────────────────────────────────────────
salt = os.urandom(32) salt = os.urandom(32)
if "crypto" not in cfg: b64_salt = base64.b64encode(salt).decode("ascii")
cfg["crypto"] = {} if table_ok:
cfg["crypto"]["salt"] = base64.b64encode(salt).decode("ascii") try:
from config import set_setting
with open(_CONFIG_FILE, "w", encoding="utf-8") as fh: set_setting(_SETTING_KEY, b64_salt)
cfg.write(fh) logger.info("Crypto: generated and persisted new salt to app_settings.")
logger.info("Crypto: generated and persisted new credential encryption salt.") except Exception as e:
logger.warning(f"Crypto: could not persist new salt to DB: {e}")
else:
logger.warning(
"Crypto: generated a new salt but app_settings is not available — "
"salt will NOT persist across restarts until the table is created."
)
return salt return salt
@@ -74,7 +163,7 @@ def _get_fernet() -> Fernet:
def reset_fernet(): def reset_fernet():
"""Force key reload — call after config.ini is replaced (e.g. settings save).""" """Force key reload — call if the salt is ever rotated."""
global _fernet global _fernet
_fernet = None _fernet = None
@@ -107,7 +196,6 @@ def decrypt(ciphertext: str) -> str:
if not ciphertext: if not ciphertext:
return ciphertext return ciphertext
if not ciphertext.startswith("enc:"): if not ciphertext.startswith("enc:"):
# Legacy plaintext — return unchanged; will be re-encrypted on next save
return ciphertext return ciphertext
try: try:
token = ciphertext[4:].encode("ascii") token = ciphertext[4:].encode("ascii")
+224 -84
View File
@@ -5,32 +5,34 @@ Runs a background daemon thread that wakes every minute, checks whether
the configured send_time (HH:MM) has been reached today, and sends the the configured send_time (HH:MM) has been reached today, and sends the
summary report via SMTP if it hasn't been sent yet. summary report via SMTP if it hasn't been sent yet.
Configuration (config.ini [email] section): Configuration is stored in the app_settings database table (not config.ini):
enabled = true/false
smtp_host = smtp.example.com Key Description
smtp_port = 587
smtp_user = sender@example.com email.enabled 'true' or 'false'
smtp_password= secret email.smtp_host SMTP server hostname
use_tls = true email.smtp_port SMTP port number (string)
recipients = admin@example.com, manager@example.com email.smtp_user Sender email address / SMTP login
send_time = 18:00 (24-hour HH:MM, local time) email.smtp_password SMTP password (Fernet-encrypted)
email.security 'starttls' | 'ssl' | 'none'
email.recipients Comma-separated recipient addresses
email.send_time HH:MM (24-hour local time)
email.last_sent_date ISO date of last successful send (YYYY-MM-DD)
Call start() once after login succeeds (admin only). Call start() once after login succeeds (admin only).
Call stop() on logout/shutdown. Call stop() on logout/shutdown.
""" """
import configparser
import datetime import datetime
import logging import logging
import os
import smtplib import smtplib
import threading import threading
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.utils import formatdate, make_msgid, formataddr
logger = logging.getLogger("scheduler") logger = logging.getLogger("scheduler")
CONFIG_FILE = "config.ini"
_scheduler_thread: "threading.Thread | None" = None _scheduler_thread: "threading.Thread | None" = None
_stop_event = threading.Event() _stop_event = threading.Event()
@@ -38,61 +40,184 @@ _stop_event = threading.Event()
# ─── Config helpers ─────────────────────────────────────────────────────────── # ─── Config helpers ───────────────────────────────────────────────────────────
def load_email_config() -> dict: def load_email_config() -> dict:
from utils.config_crypto import decrypt_value """Load email settings from app_settings table."""
cfg = configparser.ConfigParser() from config import get_settings_dict
if not os.path.exists(CONFIG_FILE): from utils.crypto import decrypt
return {} s = get_settings_dict("email.")
cfg.read(CONFIG_FILE, encoding="utf-8") security = s.get("email.security", "")
if "email" not in cfg: if not security:
return {} # Legacy: fall back from boolean use_tls if security key absent
s = cfg["email"] use_tls = s.get("email.use_tls", "true").lower() == "true"
security = "starttls" if use_tls else "none"
return { return {
"enabled": s.getboolean("enabled", fallback=False), "enabled": s.get("email.enabled", "false").lower() == "true",
"smtp_host": s.get("smtp_host", ""), "smtp_host": s.get("email.smtp_host", ""),
"smtp_port": s.getint("smtp_port", fallback=587), "smtp_port": int(s.get("email.smtp_port", "587") or "587"),
"smtp_user": s.get("smtp_user", ""), "smtp_user": s.get("email.smtp_user", ""),
"smtp_password": decrypt_value(s.get("smtp_password", "")), "smtp_password": decrypt(s.get("email.smtp_password", "")),
"use_tls": s.getboolean("use_tls", fallback=True), "security": security,
"recipients": [r.strip() for r in s.get("recipients", "").split(",") if r.strip()], "use_tls": security == "starttls",
"send_time": s.get("send_time", "18:00"), "recipients": [r.strip() for r in
s.get("email.recipients", "").split(",") if r.strip()],
"send_time": s.get("email.send_time", "18:00"),
} }
def save_email_config(enabled: bool, smtp_host: str, smtp_port: int, def save_email_config(enabled: bool, smtp_host: str, smtp_port: int,
smtp_user: str, smtp_password: str, use_tls: bool, smtp_user: str, smtp_password: str, security: str,
recipients: str, send_time: str): recipients: str, send_time: str):
from utils.config_crypto import encrypt_value """Persist email settings to app_settings table."""
cfg = configparser.ConfigParser() from config import get_setting, set_setting
cfg.read(CONFIG_FILE, encoding="utf-8") from utils.crypto import encrypt
cfg["email"] = { # Preserve last_sent_date — do not overwrite it on a normal save
"enabled": str(enabled).lower(), last_sent = get_setting("email.last_sent_date", "")
"smtp_host": smtp_host, pairs = {
"smtp_port": str(smtp_port), "email.enabled": str(enabled).lower(),
"smtp_user": smtp_user, "email.smtp_host": smtp_host,
"smtp_password": encrypt_value(smtp_password), "email.smtp_port": str(smtp_port),
"use_tls": str(use_tls).lower(), "email.smtp_user": smtp_user,
"recipients": recipients, "email.smtp_password": encrypt(smtp_password),
"send_time": send_time, "email.security": security,
"email.use_tls": str(security == "starttls").lower(),
"email.recipients": recipients,
"email.send_time": send_time,
} }
with open(CONFIG_FILE, "w", encoding="utf-8") as fh: for k, v in pairs.items():
cfg.write(fh) set_setting(k, v)
logger.info("Email configuration saved (password encrypted).") if last_sent:
set_setting("email.last_sent_date", last_sent)
logger.info(f"Email configuration saved to app_settings (security={security}).")
def test_smtp_connection(smtp_host, smtp_port, smtp_user, smtp_password, use_tls) -> tuple: def _make_smtp_server(smtp_host: str, smtp_port: int,
security: str) -> "smtplib.SMTP":
""" """
Attempt a connection without sending mail. Open and return a ready-to-login SMTP connection.
Returns (success: bool, message: str). security: "starttls" | "ssl" | "none"
Explicit ehlo() calls are required on Windows smtplib's automatic
greeting is unreliable there and causes 'Connection unexpectedly closed'
on many servers (Office 365, Exchange, Google Workspace) when omitted.
""" """
try: if security == "ssl":
if use_tls: server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=15)
server = smtplib.SMTP(smtp_host, smtp_port, timeout=8) server.ehlo()
elif security == "starttls":
server = smtplib.SMTP(smtp_host, smtp_port, timeout=15)
server.ehlo()
server.starttls() server.starttls()
server.ehlo()
else: else:
server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=8) server = smtplib.SMTP(smtp_host, smtp_port, timeout=15)
server.ehlo()
return server
def test_smtp_connection(smtp_host: str, smtp_port: int,
smtp_user: str, smtp_password: str,
security: str) -> tuple:
"""
Step-by-step SMTP diagnostic. Returns (success: bool, message: str).
"""
import socket
try:
addr = socket.getaddrinfo(smtp_host, smtp_port,
socket.AF_UNSPEC, socket.SOCK_STREAM)
if not addr:
raise OSError("No addresses returned")
ip = addr[0][4][0]
logger.info(f"SMTP test: {smtp_host} resolved to {ip}")
except OSError as e:
return False, (
f"Step 1 FAILED — DNS: Cannot resolve '{smtp_host}'.\n"
f"Check the hostname and your network connection.\n({e})"
)
try:
sock = socket.create_connection((smtp_host, smtp_port), timeout=8)
sock.close()
logger.info(f"SMTP test: TCP connect to {smtp_host}:{smtp_port} OK")
except OSError as e:
return False, (
f"Step 2 FAILED — TCP: Cannot reach {smtp_host}:{smtp_port}.\n"
f"The port may be blocked by a firewall or the server is down.\n({e})"
)
try:
server = _make_smtp_server(smtp_host, smtp_port, security)
logger.info(f"SMTP test: TLS/connection OK (security={security})")
except smtplib.SMTPConnectError as e:
return False, (f"Step 3 FAILED — SMTP connect: {e}\nTry a different Security mode or port.")
except smtplib.SMTPException as e:
return False, (f"Step 3 FAILED — TLS handshake: {e}\nTry switching Security mode.")
except OSError as e:
return False, (f"Step 3 FAILED — connection dropped: {e}\nTry switching Security mode or port.")
try:
server.login(smtp_user, smtp_password) server.login(smtp_user, smtp_password)
server.quit() server.quit()
return True, "Connection successful." logger.info("SMTP test: authentication OK")
return True, (
f"All steps passed.\nConnected to {smtp_host}:{smtp_port} "
f"({security.upper()}) and authenticated successfully."
)
except smtplib.SMTPAuthenticationError as e:
try: server.quit()
except Exception: pass
return False, (
f"Step 4 FAILED — Authentication: username or password rejected.\n"
f"For Gmail/Google Workspace use an App Password.\n({e})"
)
except smtplib.SMTPException as e:
return False, f"Step 4 FAILED — SMTP error during login: {e}"
except Exception as e:
return False, f"Step 4 FAILED — Unexpected error: {e}"
def send_test_email(smtp_host: str, smtp_port: int,
smtp_user: str, smtp_password: str,
security: str, recipients: list) -> tuple:
"""Send a real test email through the full pipeline."""
try:
subject = "Website Checker — SMTP Test"
body = (
"<html><body style='font-family:Segoe UI,Arial,sans-serif;color:#1a1a2e'>"
"<h3 style='color:#5b4de8'>Website Checker — SMTP Test</h3>"
"<p>This is a test email confirming your SMTP configuration is working.</p>"
"<p>You can now enable daily reports from the Email Settings panel.</p>"
"</body></html>"
)
plain = (
"Website Checker — SMTP Test\n\n"
"This is a test email confirming your SMTP configuration is working.\n"
"You can now enable daily reports from the Email Settings panel."
)
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = formataddr(("Website Checker", smtp_user))
msg["To"] = ", ".join(recipients)
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain=smtp_user.split("@")[-1] if "@" in smtp_user else "webchecker")
msg["X-Mailer"] = "WebChecker"
msg.attach(MIMEText(plain, "plain", "utf-8"))
msg.attach(MIMEText(body, "html", "utf-8"))
server = _make_smtp_server(smtp_host, smtp_port, security)
server.login(smtp_user, smtp_password)
server.sendmail(smtp_user, recipients, msg.as_string())
server.quit()
logger.info(f"Test email sent to {recipients} via {smtp_host}:{smtp_port}")
return True, f"Test email sent successfully to: {', '.join(recipients)}"
except smtplib.SMTPAuthenticationError as e:
return False, (f"Authentication failed — check username and password.\n"
f"For Gmail/Google Workspace use an App Password.\n({e})")
except smtplib.SMTPConnectError as e:
return False, f"Could not connect to {smtp_host}:{smtp_port}{e}"
except smtplib.SMTPException as e:
return False, f"SMTP error: {e}"
except OSError as e:
return False, f"Network error: {e}"
except Exception as e: except Exception as e:
return False, str(e) return False, str(e)
@@ -111,7 +236,12 @@ def _build_html_report() -> str:
rows = stats.get("user_stats", []) rows = stats.get("user_stats", [])
table_rows = "" table_rows = ""
skipped_no_shift = 0
for r in rows: for r in rows:
total = int(r.get("total_sites") or 0)
if total == 0:
skipped_no_shift += 1
continue
pct = float(r.get("pct_complete") or 0) pct = float(r.get("pct_complete") or 0)
color = "#388e3c" if pct >= 100 else "#f57c00" if pct > 0 else "#d32f2f" color = "#388e3c" if pct >= 100 else "#f57c00" if pct > 0 else "#d32f2f"
table_rows += ( table_rows += (
@@ -119,11 +249,21 @@ def _build_html_report() -> str:
f"<td style='padding:8px 12px'>{r['username']}</td>" f"<td style='padding:8px 12px'>{r['username']}</td>"
f"<td style='padding:8px 12px'>{r['full_name'] or ''}</td>" f"<td style='padding:8px 12px'>{r['full_name'] or ''}</td>"
f"<td style='padding:8px 12px;text-align:center'>{int(r['checked_count'] or 0)}</td>" f"<td style='padding:8px 12px;text-align:center'>{int(r['checked_count'] or 0)}</td>"
f"<td style='padding:8px 12px;text-align:center'>{int(r['total_sites'] or 0)}</td>" f"<td style='padding:8px 12px;text-align:center'>{total}</td>"
f"<td style='padding:8px 12px;text-align:center;" f"<td style='padding:8px 12px;text-align:center;"
f"color:{color};font-weight:bold'>{pct:.0f}%</td>" f"color:{color};font-weight:bold'>{pct:.0f}%</td>"
f"</tr>" f"</tr>"
) )
if not table_rows:
table_rows = (
"<tr><td colspan='5' style='padding:12px;color:#6b6b80;"
"text-align:center'>No users with active shifts today.</td></tr>"
)
no_shift_note = (
f"<p style='color:#6b6b80;font-size:11px'>"
f"{skipped_no_shift} user(s) had no shifts scheduled today and are "
f"excluded from this report.</p>"
) if skipped_no_shift else ""
return f""" return f"""
<html><body style="font-family:Segoe UI,Arial,sans-serif;color:#1a1a2e"> <html><body style="font-family:Segoe UI,Arial,sans-serif;color:#1a1a2e">
@@ -142,6 +282,7 @@ def _build_html_report() -> str:
</thead> </thead>
<tbody>{table_rows}</tbody> <tbody>{table_rows}</tbody>
</table> </table>
{no_shift_note}
<p style="color:#6b6b80;font-size:12px;margin-top:24px"> <p style="color:#6b6b80;font-size:12px;margin-top:24px">
Sent automatically by Website Checker at {datetime.datetime.now().strftime('%H:%M')}. Sent automatically by Website Checker at {datetime.datetime.now().strftime('%H:%M')}.
</p> </p>
@@ -154,34 +295,46 @@ def _send_report(cfg: dict):
html = _build_html_report() html = _build_html_report()
today = datetime.date.today().strftime("%d %b %Y") today = datetime.date.today().strftime("%d %b %Y")
subject = f"Website Checker — Daily Report {today}" subject = f"Website Checker — Daily Report {today}"
smtp_user = cfg["smtp_user"]
plain = (
f"Website Checker — Daily Report {today}\n\n"
"Please view this report in an HTML-capable email client for full formatting.\n"
"This email was sent automatically by Website Checker."
)
msg = MIMEMultipart("alternative") msg = MIMEMultipart("alternative")
msg["Subject"] = subject msg["Subject"] = subject
msg["From"] = cfg["smtp_user"] msg["From"] = formataddr(("Website Checker", smtp_user))
msg["To"] = ", ".join(cfg["recipients"]) msg["To"] = ", ".join(cfg["recipients"])
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain=smtp_user.split("@")[-1] if "@" in smtp_user else "webchecker")
msg["X-Mailer"] = "WebChecker"
msg.attach(MIMEText(plain, "plain", "utf-8"))
msg.attach(MIMEText(html, "html", "utf-8")) msg.attach(MIMEText(html, "html", "utf-8"))
try: try:
if cfg["use_tls"]: security = cfg.get("security", "starttls")
server = smtplib.SMTP(cfg["smtp_host"], cfg["smtp_port"], timeout=10) server = _make_smtp_server(cfg["smtp_host"], cfg["smtp_port"], security)
server.starttls() server.login(smtp_user, cfg["smtp_password"])
else: server.sendmail(smtp_user, cfg["recipients"], msg.as_string())
server = smtplib.SMTP_SSL(cfg["smtp_host"], cfg["smtp_port"], timeout=10)
server.login(cfg["smtp_user"], cfg["smtp_password"])
server.sendmail(cfg["smtp_user"], cfg["recipients"], msg.as_string())
server.quit() server.quit()
logger.info(f"Daily report emailed to: {cfg['recipients']}") logger.info(f"Daily report emailed to: {cfg['recipients']}")
except smtplib.SMTPAuthenticationError as e:
logger.error(f"Failed to send daily report — authentication error: {e}")
except smtplib.SMTPConnectError as e:
logger.error(f"Failed to send daily report — connection error: {e}")
except smtplib.SMTPException as e:
logger.error(f"Failed to send daily report — SMTP error: {e}")
except OSError as e:
logger.error(f"Failed to send daily report — network error: {e}")
except Exception as e: except Exception as e:
logger.error(f"Failed to send daily report email: {e}") logger.error(f"Failed to send daily report email: {e}")
def _get_last_sent_date() -> "datetime.date | None": def _get_last_sent_date() -> "datetime.date | None":
"""Read the last-sent date from config.ini [email] last_sent_date key.""" """Read the last-sent date from app_settings."""
cfg = configparser.ConfigParser() from config import get_setting
if not os.path.exists(CONFIG_FILE): raw = get_setting("email.last_sent_date", "")
return None
cfg.read(CONFIG_FILE, encoding="utf-8")
raw = cfg.get("email", "last_sent_date", fallback="")
if not raw: if not raw:
return None return None
try: try:
@@ -191,25 +344,18 @@ def _get_last_sent_date() -> "datetime.date | None":
def _set_last_sent_date(d: "datetime.date"): def _set_last_sent_date(d: "datetime.date"):
"""Persist the last-sent date to config.ini [email] last_sent_date key.""" """Persist the last-sent date to app_settings."""
cfg = configparser.ConfigParser() from config import set_setting
if os.path.exists(CONFIG_FILE): set_setting("email.last_sent_date", d.isoformat())
cfg.read(CONFIG_FILE, encoding="utf-8")
if "email" not in cfg:
cfg["email"] = {}
cfg["email"]["last_sent_date"] = d.isoformat()
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
cfg.write(fh)
# ─── Scheduler loop ─────────────────────────────────────────────────────────── # ─── Scheduler loop ───────────────────────────────────────────────────────────
def _scheduler_loop(): def _scheduler_loop():
# Seed from persisted value so a restart after send_time does not re-send.
last_sent_date = _get_last_sent_date() last_sent_date = _get_last_sent_date()
while not _stop_event.is_set(): while not _stop_event.is_set():
_stop_event.wait(60) # sleep 60 seconds between checks _stop_event.wait(60)
if _stop_event.is_set(): if _stop_event.is_set():
break break
@@ -236,16 +382,10 @@ def _scheduler_loop():
def start(): def start():
"""Start the background scheduler thread. Call once after successful login.""" """Start the background scheduler thread. Call once after successful login."""
global _scheduler_thread, _stop_event global _scheduler_thread, _stop_event
# Guard against double-start: if a thread is already alive (e.g. admin
# logs out and back in), stop it cleanly before spawning a new one.
# Without this guard, _stop_event.clear() would unblock the sleeping
# thread while a second thread also starts, resulting in two scheduler
# threads firing simultaneously and potentially sending duplicate emails.
if _scheduler_thread is not None and _scheduler_thread.is_alive(): if _scheduler_thread is not None and _scheduler_thread.is_alive():
logger.info("Email scheduler already running - stopping before restart.") logger.info("Email scheduler already running - stopping before restart.")
_stop_event.set() _stop_event.set()
_scheduler_thread.join(timeout=5) _scheduler_thread.join(timeout=5)
# Create a fresh Event so there is no residual set-state from a prior stop()
_stop_event = threading.Event() _stop_event = threading.Event()
_scheduler_thread = threading.Thread(target=_scheduler_loop, _scheduler_thread = threading.Thread(target=_scheduler_loop,
name="EmailScheduler", daemon=True) name="EmailScheduler", daemon=True)
+321 -23
View File
@@ -1,10 +1,30 @@
""" """
views/admin_log_view.py Admin panel: Activity Log tab. views/admin_log_view.py Admin panel: Logs
Two tabs:
Activity Log user-action audit trail (existing, from activity_log table)
App Log application-level logging (new, from app_log table)
""" """
import tkinter as tk import tkinter as tk
from tkinter import ttk from tkinter import ttk, messagebox
from utils.ui_helpers import COLOURS, FONT_HEADING, show_error import logging
from utils.ui_helpers import (
COLOURS, FONT, FONT_BOLD, FONT_SMALL, FONT_HEADING,
show_error, show_info,
)
logger = logging.getLogger("admin_log_view")
# Colour map for log level badges
_LEVEL_COLOURS = {
"DEBUG": "text_dim",
"INFO": "text",
"WARNING": "warning",
"ERROR": "danger",
"CRITICAL": "danger",
}
class AdminLogView(ttk.Frame): class AdminLogView(ttk.Frame):
@@ -12,39 +32,317 @@ class AdminLogView(ttk.Frame):
super().__init__(parent) super().__init__(parent)
self.current_user = current_user self.current_user = current_user
self._build_ui() self._build_ui()
self._load()
def _build_ui(self): def _build_ui(self):
toolbar = ttk.Frame(self) # Page header
toolbar.pack(fill="x", pady=(0, 10)) hdr = ttk.Frame(self)
ttk.Label(toolbar, text="Activity Log", style="Heading.TLabel").pack(side="left") hdr.pack(fill="x", pady=(0, 8))
ttk.Label(hdr, text="Logs", style="Heading.TLabel").pack(side="left")
# Notebook with two tabs
nb = ttk.Notebook(self)
nb.pack(fill="both", expand=True)
# ── Tab 1 — Activity Log ──────────────────────────────────────────────
act_tab = ttk.Frame(nb)
nb.add(act_tab, text="📋 Activity Log")
self._build_activity_tab(act_tab)
# ── Tab 2 — App Log ───────────────────────────────────────────────────
app_tab = ttk.Frame(nb)
nb.add(app_tab, text="🖥 App Log")
self._build_app_log_tab(app_tab)
nb.bind("<<NotebookTabChanged>>", self._on_tab_changed)
self._nb = nb
# Load the default tab
self._load_activity()
# ── Activity Log tab ──────────────────────────────────────────────────────
def _build_activity_tab(self, parent):
C = COLOURS
toolbar = tk.Frame(parent, bg=C["surface"], pady=8, padx=10)
toolbar.pack(fill="x")
ttk.Button(toolbar, text="↻ Refresh", style="Ghost.TButton", ttk.Button(toolbar, text="↻ Refresh", style="Ghost.TButton",
command=self._load).pack(side="right") command=self._load_activity).pack(side="right")
# Search
tk.Label(toolbar, text="Search:",
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL).pack(side="left", padx=(0, 4))
self._act_search_var = tk.StringVar()
self._act_search_var.trace_add("write", lambda *_: self._load_activity())
tk.Entry(toolbar, textvariable=self._act_search_var,
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT, width=24).pack(
side="left", ipady=4)
cols = ("Time", "User", "Action", "Entity", "Entity ID", "Detail") cols = ("Time", "User", "Action", "Entity", "Entity ID", "Detail")
self.tree = ttk.Treeview(self, columns=cols, show="headings", selectmode="browse") widths = [140, 100, 140, 100, 70, 320]
widths = [140, 100, 130, 100, 70, 320] self._act_tree = ttk.Treeview(
parent, columns=cols, show="headings", selectmode="browse")
for col, w in zip(cols, widths): for col, w in zip(cols, widths):
self.tree.heading(col, text=col) self._act_tree.heading(col, text=col)
self.tree.column(col, width=w, anchor="w") self._act_tree.column(col, width=w, anchor="w")
self.tree.pack(fill="both", expand=True)
vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview) vsb = ttk.Scrollbar(parent, orient="vertical",
self.tree.configure(yscrollcommand=vsb.set) command=self._act_tree.yview)
vsb.place(relx=1, rely=0, relheight=1, anchor="ne") self._act_tree.configure(yscrollcommand=vsb.set)
vsb.pack(side="right", fill="y")
self._act_tree.pack(fill="both", expand=True)
def _load(self): # Status bar
self._act_status = tk.Label(
parent, text="", bg=C["surface2"], fg=C["text_dim"],
font=FONT_SMALL, anchor="w")
self._act_status.pack(fill="x", side="bottom")
def _load_activity(self, *_):
from models import get_activity_log from models import get_activity_log
self.tree.delete(*self.tree.get_children()) search = self._act_search_var.get().strip() \
if hasattr(self, "_act_search_var") else ""
self._act_tree.delete(*self._act_tree.get_children())
try: try:
for entry in get_activity_log(): # Pass search to the DB query so filtering happens server-side.
self.tree.insert("", "end", values=( entries = get_activity_log(limit=500, search=search)
str(entry["logged_at"])[:16], except Exception as e:
show_error(f"Failed to load activity log:\n{e}")
return
for entry in entries:
self._act_tree.insert("", "end", values=(
str(entry.get("logged_at", ""))[:16],
entry.get("username") or "", entry.get("username") or "",
entry["action"], entry.get("action") or "",
entry.get("entity") or "", entry.get("entity") or "",
entry.get("entity_id") or "", entry.get("entity_id") or "",
entry.get("detail") or "", entry.get("detail") or "",
)) ))
self._act_status.config(text=f" {len(entries)} records")
# ── App Log tab ───────────────────────────────────────────────────────────
def _build_app_log_tab(self, parent):
C = COLOURS
toolbar = tk.Frame(parent, bg=C["surface"], pady=8, padx=10)
toolbar.pack(fill="x")
ttk.Button(toolbar, text="↻ Refresh", style="Ghost.TButton",
command=self._load_app_log).pack(side="right", padx=(4, 0))
# Purge button
tk.Button(
toolbar, text="🗑 Purge Old Entries",
command=self._purge_app_log,
bg=C["surface2"], fg=C["danger"],
activebackground=C["danger"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=8, pady=4,
).pack(side="right", padx=(4, 0))
# Level filter
tk.Label(toolbar, text="Level:",
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL).pack(side="left", padx=(0, 4))
self._level_var = tk.StringVar(value="All")
level_cb = ttk.Combobox(
toolbar, textvariable=self._level_var,
values=["All", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
state="readonly", width=10,
)
level_cb.pack(side="left")
level_cb.bind("<<ComboboxSelected>>", lambda _: self._load_app_log())
# Search
tk.Label(toolbar, text="Search:",
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL).pack(side="left", padx=(12, 4))
self._app_search_var = tk.StringVar()
self._app_search_var.trace_add("write", lambda *_: self._load_app_log())
tk.Entry(toolbar, textvariable=self._app_search_var,
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT, width=24).pack(
side="left", ipady=4)
# Treeview
cols = ("Time", "Level", "Logger", "Message")
widths = [140, 75, 130, 480]
self._app_tree = ttk.Treeview(
parent, columns=cols, show="headings", selectmode="browse")
for col, w in zip(cols, widths):
self._app_tree.heading(col, text=col)
self._app_tree.column(
col, width=w,
anchor="center" if col == "Level" else "w",
)
# Level colour tags
for level, colour_key in _LEVEL_COLOURS.items():
self._app_tree.tag_configure(level, foreground=C[colour_key])
# Bold for WARNING and above
self._app_tree.tag_configure("WARNING", foreground=C["warning"],
font=FONT_BOLD)
self._app_tree.tag_configure("ERROR", foreground=C["danger"],
font=FONT_BOLD)
self._app_tree.tag_configure("CRITICAL", foreground=C["danger"],
font=FONT_BOLD)
vsb = ttk.Scrollbar(parent, orient="vertical",
command=self._app_tree.yview)
self._app_tree.configure(yscrollcommand=vsb.set)
vsb.pack(side="right", fill="y")
self._app_tree.pack(fill="both", expand=True)
# Detail strip — shows full message when a row is selected
detail_frame = tk.Frame(parent, bg=C["surface2"], pady=6, padx=10)
detail_frame.pack(fill="x", side="bottom")
self._app_detail_var = tk.StringVar(
value="Select a row to see the full message.")
tk.Label(
detail_frame, textvariable=self._app_detail_var,
bg=C["surface2"], fg=C["text"],
font=FONT_SMALL, anchor="w",
justify="left", wraplength=900,
).pack(fill="x")
self._app_tree.bind("<<TreeviewSelect>>", self._on_app_row_select)
# Status bar
self._app_status = tk.Label(
parent, text="", bg=C["surface2"], fg=C["text_dim"],
font=FONT_SMALL, anchor="w")
self._app_status.pack(fill="x", side="bottom")
# Store full messages keyed by tree iid for the detail strip
self._app_full_messages: dict[str, str] = {}
def _load_app_log(self, *_):
from models import get_app_log
level = self._level_var.get()
level_filter = "" if level == "All" else level
search = self._app_search_var.get().strip()
self._app_tree.delete(*self._app_tree.get_children())
self._app_full_messages.clear()
self._app_detail_var.set("Select a row to see the full message.")
try:
entries = get_app_log(
limit=500,
level_filter=level_filter,
search=search,
)
except Exception as e: except Exception as e:
show_error(f"Failed to load activity log:\n{e}") show_error(f"Failed to load app log:\n{e}")
return
for entry in entries:
lvl = entry.get("level", "INFO")
msg_full = entry.get("message", "")
# Truncate long messages in the tree; full text in detail strip
msg_short = msg_full[:120] + ("" if len(msg_full) > 120 else "")
iid = str(entry["id"])
self._app_tree.insert(
"", "end", iid=iid, tags=(lvl,),
values=(
str(entry.get("logged_at", ""))[:19],
lvl,
entry.get("logger_name", ""),
msg_short,
),
)
self._app_full_messages[iid] = msg_full
count = len(entries)
self._app_status.config(text=f" {count} records")
def _on_app_row_select(self, event=None):
sel = self._app_tree.selection()
if not sel:
return
iid = sel[0]
full = self._app_full_messages.get(iid, "")
self._app_detail_var.set(full)
def _purge_app_log(self):
"""Ask for confirmation, then delete log entries older than N days."""
days_var = tk.StringVar(value="30")
dlg = tk.Toplevel(self)
dlg.title("Purge App Log")
dlg.configure(bg=COLOURS["bg"])
dlg.resizable(False, False)
dlg.grab_set()
w, h = 340, 170
x = (dlg.winfo_screenwidth() - w) // 2
y = (dlg.winfo_screenheight() - h) // 2
dlg.geometry(f"{w}x{h}+{x}+{y}")
tk.Label(dlg,
text="Delete app log entries older than:",
bg=COLOURS["bg"], fg=COLOURS["text"],
font=FONT_BOLD).pack(pady=(20, 8))
row = tk.Frame(dlg, bg=COLOURS["bg"])
row.pack()
ttk.Spinbox(row, from_=1, to=365,
textvariable=days_var, width=6).pack(side="left")
tk.Label(row, text=" days",
bg=COLOURS["bg"], fg=COLOURS["text"],
font=FONT).pack(side="left")
def _do_purge():
try:
days = int(days_var.get())
if days < 1:
raise ValueError
except ValueError:
show_error("Please enter a valid number of days (1365).")
return
if not messagebox.askyesno(
"Confirm Purge",
f"Delete all app log entries older than {days} days?\n"
"This cannot be undone.",
parent=dlg,
):
return
try:
from models import purge_app_log
deleted = purge_app_log(days)
dlg.destroy()
show_info(f"{deleted} old log record(s) deleted.")
self._load_app_log()
except Exception as e:
show_error(f"Purge failed:\n{e}")
btn_frame = tk.Frame(dlg, bg=COLOURS["bg"])
btn_frame.pack(pady=12)
tk.Button(btn_frame, text="Purge",
command=_do_purge,
bg=COLOURS["danger"], fg=COLOURS["white"],
activebackground="#c94444", activeforeground=COLOURS["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=12, pady=6).pack(side="left", padx=(0, 8))
tk.Button(btn_frame, text="Cancel",
command=dlg.destroy,
bg=COLOURS["surface2"], fg=COLOURS["text"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=12, pady=6).pack(side="left")
# ── Tab switch ────────────────────────────────────────────────────────────
def _on_tab_changed(self, event=None):
try:
idx = self._nb.index(self._nb.select())
if idx == 0:
self._load_activity()
else:
self._load_app_log()
except Exception:
pass
+38 -6
View File
@@ -450,12 +450,26 @@ class ShiftDialog(tk.Toplevel):
) )
self._user_picker.grid(row=0, column=0, sticky="nsew", pady=(0, 8)) self._user_picker.grid(row=0, column=0, sticky="nsew", pady=(0, 8))
# Websites picker # Websites picker — include check_type suffix in label and colour map
_TYPE_SUFFIX = {"daily": " [D]", "weekly": " [W]"}
_TYPE_COLOUR = {
"daily": COLOURS.get("accent", "#5B4DE8"),
"weekly": COLOURS.get("warning", "#F57C00"),
}
site_items = [
(w["id"], w["name"] + _TYPE_SUFFIX.get(w.get("check_type", "daily"), ""))
for w in self._all_websites
]
site_colours = {
w["id"]: _TYPE_COLOUR.get(w.get("check_type", "daily"), COLOURS["text"])
for w in self._all_websites
}
self._site_picker = _DualListPicker( self._site_picker = _DualListPicker(
pane, pane,
title="Assigned Websites", title="Assigned Websites",
all_items=[(w["id"], w["name"]) for w in self._all_websites], all_items=site_items,
allow_reorder=True, allow_reorder=True,
item_colours=site_colours,
) )
self._site_picker.grid(row=1, column=0, sticky="nsew") self._site_picker.grid(row=1, column=0, sticky="nsew")
@@ -541,11 +555,13 @@ class _DualListPicker(ttk.Frame):
Supports optional drag-to-reorder on the assigned list. Supports optional drag-to-reorder on the assigned list.
""" """
def __init__(self, parent, title: str, all_items: list, def __init__(self, parent, title: str, all_items: list,
allow_reorder=False): allow_reorder=False, item_colours: dict = None):
super().__init__(parent) super().__init__(parent)
self._all_items = all_items # [(id, label), ...] self._all_items = all_items # [(id, label), ...]
self._allow_reorder = allow_reorder self._allow_reorder = allow_reorder
self._drag_start = None self._drag_start = None
# item_colours: {id: colour_str} — applied per-item after refresh
self._item_colours = item_colours or {}
self._build(title) self._build(title)
def _build(self, title: str): def _build(self, title: str):
@@ -555,7 +571,19 @@ class _DualListPicker(ttk.Frame):
ttk.Label(self, text=title, ttk.Label(self, text=title,
style="Heading.TLabel").grid( style="Heading.TLabel").grid(
row=0, column=0, columnspan=3, sticky="w", pady=(4, 6)) row=0, column=0, columnspan=3, sticky="w", pady=(4, 2))
# Colour legend — only shown when item_colours are provided
if self._item_colours:
legend = tk.Frame(self, bg=COLOURS["bg"])
legend.grid(row=0, column=0, columnspan=3, sticky="e", pady=(4, 2))
for lbl_text, colour in [
("● Daily", COLOURS.get("accent", "#5B4DE8")),
("● Weekly", COLOURS.get("warning", "#F57C00")),
]:
tk.Label(legend, text=lbl_text,
bg=COLOURS["bg"], fg=colour,
font=FONT_SMALL).pack(side="left", padx=(0, 10))
# ── Available list ──────────────────────────────────────────────────── # ── Available list ────────────────────────────────────────────────────
avail_frame = tk.Frame(self, bg=COLOURS["surface"]) avail_frame = tk.Frame(self, bg=COLOURS["surface"])
@@ -632,12 +660,16 @@ class _DualListPicker(ttk.Frame):
def _refresh_listboxes(self): def _refresh_listboxes(self):
self._avail_lb.delete(0, "end") self._avail_lb.delete(0, "end")
for _, lbl in self._avail_data: for idx, (id_, lbl) in enumerate(self._avail_data):
self._avail_lb.insert("end", lbl) self._avail_lb.insert("end", lbl)
if id_ in self._item_colours:
self._avail_lb.itemconfig(idx, fg=self._item_colours[id_])
self._assign_lb.delete(0, "end") self._assign_lb.delete(0, "end")
for _, lbl in self._assign_data: for idx, (id_, lbl) in enumerate(self._assign_data):
self._assign_lb.insert("end", lbl) self._assign_lb.insert("end", lbl)
if id_ in self._item_colours:
self._assign_lb.itemconfig(idx, fg=self._item_colours[id_])
def _add(self): def _add(self):
sel = list(self._avail_lb.curselection()) sel = list(self._avail_lb.curselection())
+193 -17
View File
@@ -6,7 +6,7 @@ import tkinter as tk
from tkinter import ttk from tkinter import ttk
import logging import logging
from utils.ui_helpers import ( 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 show_error, show_info, confirm_delete
) )
@@ -44,9 +44,9 @@ class AdminUsersView(ttk.Frame):
command=self._reset_password).pack(side="right", padx=(0, 8)) command=self._reset_password).pack(side="right", padx=(0, 8))
# Treeview # Treeview
cols = ("ID", "Username", "Full Name", "Role", "Active", "Created") cols = ("ID", "Username", "Full Name", "Email", "Role", "Active", "Created")
self.tree = ttk.Treeview(self, columns=cols, show="headings", selectmode="browse") self.tree = ttk.Treeview(self, columns=cols, show="headings", selectmode="browse")
widths = [40, 140, 180, 80, 60, 160] widths = [40, 130, 160, 180, 80, 60, 140]
for col, w in zip(cols, widths): for col, w in zip(cols, widths):
self.tree.heading(col, text=col) self.tree.heading(col, text=col)
self.tree.column(col, width=w, anchor="center" if w < 120 else "w") self.tree.column(col, width=w, anchor="center" if w < 120 else "w")
@@ -68,8 +68,8 @@ class AdminUsersView(ttk.Frame):
created = str(u["created_at"])[:16] if u["created_at"] else "" created = str(u["created_at"])[:16] if u["created_at"] else ""
self.tree.insert("", "end", iid=str(u["id"]), self.tree.insert("", "end", iid=str(u["id"]),
values=(u["id"], u["username"], values=(u["id"], u["username"],
u["full_name"] or "", u["role"], u["full_name"] or "", u["email"] or "",
active, created)) u["role"], active, created))
except Exception as e: except Exception as e:
show_error(f"Failed to load users:\n{e}") show_error(f"Failed to load users:\n{e}")
@@ -134,6 +134,7 @@ class AdminUsersView(ttk.Frame):
user_data["full_name"], user_data["full_name"],
user_data["is_active"], user_data["is_active"],
password=pwd, password=pwd,
email=user_data.get("email"),
) )
log_action( log_action(
self.current_user["id"], "RESET_PASSWORD", "users", uid, self.current_user["id"], "RESET_PASSWORD", "users", uid,
@@ -143,15 +144,9 @@ class AdminUsersView(ttk.Frame):
f"Password reset for user id={uid} '{username}' " f"Password reset for user id={uid} '{username}' "
f"by admin '{self.current_user['username']}'." f"by admin '{self.current_user['username']}'."
) )
# Copy to clipboard # Show the temporary password in a purpose-built secure dialog
self.clipboard_clear() # instead of show_info (which leaves the password visible indefinitely)
self.clipboard_append(pwd) _PasswordResetDialog(self, username=username, password=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."
)
except Exception as e: except Exception as e:
show_error(f"Password reset failed:\n{e}") show_error(f"Password reset failed:\n{e}")
@@ -208,7 +203,7 @@ class UserDialog(tk.Toplevel):
def _centre(self): def _centre(self):
self.update_idletasks() self.update_idletasks()
w, h = 420, 420 w, h = 440, 490
x = (self.winfo_screenwidth() - w) // 2 x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2 y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}") self.geometry(f"{w}x{h}+{x}+{y}")
@@ -251,12 +246,21 @@ class UserDialog(tk.Toplevel):
ttk.Checkbutton(form, variable=self.active_var).grid(row=5, column=1, ttk.Checkbutton(form, variable=self.active_var).grid(row=5, column=1,
sticky="w", pady=6) sticky="w", pady=6)
ttk.Label(form, text="Email (optional)").grid(row=6, column=0, sticky="w",
padx=(0, 10), pady=6)
self.email_var = tk.StringVar()
ttk.Entry(form, textvariable=self.email_var).grid(
row=6, column=1, sticky="ew", pady=6)
ttk.Label(form, text="Used for system reminders and notifications.",
style="Dim.TLabel").grid(row=7, column=1, sticky="w")
if self.is_edit: if self.is_edit:
d = self.user_data d = self.user_data
self.full_name_var.set(d.get("full_name") or "") self.full_name_var.set(d.get("full_name") or "")
self.username_var.set(d.get("username") or "") self.username_var.set(d.get("username") or "")
self.role_var.set(d.get("role") or "user") self.role_var.set(d.get("role") or "user")
self.active_var.set(bool(d.get("is_active", 1))) self.active_var.set(bool(d.get("is_active", 1)))
self.email_var.set(d.get("email") or "")
btn_frame = ttk.Frame(self) btn_frame = ttk.Frame(self)
btn_frame.pack(fill="x", padx=24, pady=(16, 20)) btn_frame.pack(fill="x", padx=24, pady=(16, 20))
@@ -270,6 +274,7 @@ class UserDialog(tk.Toplevel):
password = self.password_var.get() password = self.password_var.get()
role = self.role_var.get() role = self.role_var.get()
is_active = int(self.active_var.get()) is_active = int(self.active_var.get())
email = self.email_var.get().strip() or None
if not username: if not username:
show_error("Username is required.") show_error("Username is required.")
@@ -277,6 +282,9 @@ class UserDialog(tk.Toplevel):
if not self.is_edit and not password: if not self.is_edit and not password:
show_error("Password is required for new users.") show_error("Password is required for new users.")
return return
if email and "@" not in email:
show_error("Please enter a valid email address.")
return
try: try:
if self.is_edit: if self.is_edit:
@@ -284,15 +292,183 @@ class UserDialog(tk.Toplevel):
update_user(self.current_user["id"], update_user(self.current_user["id"],
self.user_data["id"], self.user_data["id"],
username, role, full_name, is_active, username, role, full_name, is_active,
password if password else None) password if password else None,
email=email)
logger.info(f"User id={self.user_data['id']} updated by admin.") logger.info(f"User id={self.user_data['id']} updated by admin.")
show_info("User updated successfully.") show_info("User updated successfully.")
else: else:
from models import create_user from models import create_user
create_user(self.current_user["id"], username, password, role, full_name) create_user(self.current_user["id"], username, password, role, full_name, email=email)
logger.info(f"New user '{username}' created by admin.") logger.info(f"New user '{username}' created by admin.")
show_info("User created successfully.") show_info("User created successfully.")
self.on_save() self.on_save()
self.destroy() self.destroy()
except Exception as e: except Exception as e:
show_error(f"Save failed:\n{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
+437
View File
@@ -34,6 +34,9 @@ class AdminWebsitesView(ttk.Frame):
ttk.Button(toolbar, text=" Add Website", ttk.Button(toolbar, text=" Add Website",
command=self._open_add).pack(side="right", padx=(4, 0)) command=self._open_add).pack(side="right", padx=(4, 0))
ttk.Button(toolbar, text="📥 Import",
style="Ghost.TButton",
command=self._open_import).pack(side="right", padx=(4, 0))
ttk.Button(toolbar, text="✎ Edit", ttk.Button(toolbar, text="✎ Edit",
style="Ghost.TButton", style="Ghost.TButton",
command=self._open_edit).pack(side="right", padx=(4, 0)) command=self._open_edit).pack(side="right", padx=(4, 0))
@@ -88,6 +91,10 @@ class AdminWebsitesView(ttk.Frame):
# ─── Actions ────────────────────────────────────────────────────────────── # ─── Actions ──────────────────────────────────────────────────────────────
def _open_import(self):
"""Open the bulk import dialog."""
BulkImportDialog(self, self.current_user, on_complete=self._load_websites)
def _open_add(self): def _open_add(self):
WebsiteDialog(self, self.current_user, website_data=None, WebsiteDialog(self, self.current_user, website_data=None,
on_save=self._load_websites) on_save=self._load_websites)
@@ -427,6 +434,21 @@ class WebsiteDialog(tk.Toplevel):
if not name or not url: if not name or not url:
show_error("Name and URL are required.") show_error("Name and URL are required.")
return 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: if visibility == "assigned" and not assigned_user_ids:
show_error("Please assign at least one user, or set visibility to All Users.") show_error("Please assign at least one user, or set visibility to All Users.")
return return
@@ -463,3 +485,418 @@ class WebsiteDialog(tk.Toplevel):
self.destroy() self.destroy()
except Exception as e: except Exception as e:
show_error(f"Save failed:\n{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)
# ─── Bulk Import Dialog ────────────────────────────────────────────────────────
class BulkImportDialog(tk.Toplevel):
"""
Modal dialog for bulk-importing websites from a CSV or Excel file.
Workflow:
1. User picks a .csv or .xlsx/.xls file (or downloads the template).
2. File is parsed and previewed in a treeview (up to 200 rows shown).
3. User confirms rows are inserted via create_website(); duplicates skipped.
Expected columns (case-insensitive, order-independent):
name * website display name (required)
url * full URL (required)
check_type 'daily' or 'weekly' (default: daily)
note optional description
visibility 'all' or 'assigned' (default: all)
"""
_TEMPLATE_PATH = "website_import_template.xlsx"
_REQUIRED_COLS = {"name", "url"}
_ALLOWED_TYPES = {"daily", "weekly"}
_ALLOWED_VIS = {"all", "assigned"}
def __init__(self, parent, current_user: dict, on_complete):
super().__init__(parent)
self.current_user = current_user
self.on_complete = on_complete
self._rows: list = [] # parsed preview rows
self.title("Import Websites")
self.configure(bg=COLOURS["bg"])
self.resizable(True, True)
self.grab_set()
self._build_ui()
self._centre()
def _centre(self):
self.update_idletasks()
w, h = 780, 540
x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}")
# ── UI ────────────────────────────────────────────────────────────────────
def _build_ui(self):
C = COLOURS
ttk.Label(self, text="Import Websites",
style="Heading.TLabel").pack(anchor="w", padx=24, pady=(20, 4))
# ── File picker row ───────────────────────────────────────────────────
picker = tk.Frame(self, bg=C["bg"])
picker.pack(fill="x", padx=24, pady=(0, 8))
tk.Label(picker, text="File:", bg=C["bg"], fg=C["text"],
font=FONT_SMALL).pack(side="left")
self._file_var = tk.StringVar()
tk.Entry(picker, textvariable=self._file_var, state="readonly",
readonlybackground=C["surface2"], fg=C["text"],
relief="flat", width=50, font=FONT_SMALL).pack(
side="left", padx=(6, 6), fill="x", expand=True)
ttk.Button(picker, text="Browse…",
command=self._browse).pack(side="left", padx=(0, 6))
ttk.Button(picker, text="⬇ Download Template",
style="Ghost.TButton",
command=self._download_template).pack(side="left")
# ── Status label ──────────────────────────────────────────────────────
self._status_lbl = tk.Label(
self, text="Select a .xlsx or .csv file to preview.",
bg=C["bg"], fg=C["text_dim"], font=FONT_SMALL, anchor="w")
self._status_lbl.pack(fill="x", padx=24, pady=(0, 6))
# ── Preview treeview ──────────────────────────────────────────────────
tree_frame = tk.Frame(self, bg=C["bg"])
tree_frame.pack(fill="both", expand=True, padx=24)
cols = ("name", "url", "check_type", "note", "visibility", "status")
widths = [150, 210, 80, 150, 80, 110]
self._tree = ttk.Treeview(tree_frame, columns=cols,
show="headings", selectmode="none", height=12)
for col, w in zip(cols, widths):
self._tree.heading(col, text=col.capitalize())
self._tree.column(col, width=w, anchor="w")
self._tree.tag_configure("skip", foreground=C["text_dim"])
self._tree.tag_configure("valid", foreground=C["text"])
self._tree.tag_configure("dup_url", foreground=C["warning"])
self._tree.tag_configure("dup_name", foreground=C["warning"])
vsb = ttk.Scrollbar(tree_frame, orient="vertical",
command=self._tree.yview)
self._tree.configure(yscrollcommand=vsb.set)
vsb.pack(side="right", fill="y")
self._tree.pack(side="left", fill="both", expand=True)
# ── Footer buttons ────────────────────────────────────────────────────
ttk.Separator(self, orient="horizontal").pack(fill="x", padx=0, pady=8)
btn_row = ttk.Frame(self)
btn_row.pack(fill="x", padx=24, pady=(0, 16))
self._import_btn = ttk.Button(
btn_row, text="Import",
command=self._do_import, state="disabled")
self._import_btn.pack(side="right", padx=(6, 0))
ttk.Button(btn_row, text="Cancel", style="Ghost.TButton",
command=self.destroy).pack(side="right")
# ── File handling ─────────────────────────────────────────────────────────
def _browse(self):
from tkinter import filedialog
path = filedialog.askopenfilename(
title="Select import file",
filetypes=[
("Spreadsheets", "*.xlsx *.xls *.csv"),
("Excel", "*.xlsx *.xls"),
("CSV", "*.csv"),
("All files", "*.*"),
],
)
if not path:
return
self._file_var.set(path)
self._parse_file(path)
def _parse_file(self, path: str):
"""Parse the selected file and populate the preview treeview."""
self._tree.delete(*self._tree.get_children())
self._rows.clear()
self._import_btn.config(state="disabled")
try:
rows = self._read_file(path)
except Exception as e:
self._status_lbl.config(
text=f"Error reading file: {e}", fg=COLOURS["danger"])
return
if not rows:
self._status_lbl.config(
text="No data rows found.", fg=COLOURS["warning"])
return
# Fetch existing URLs and names once for the entire preview pass
try:
from models import get_existing_website_urls, get_existing_website_names
existing_urls = get_existing_website_urls()
existing_names = get_existing_website_names()
except Exception:
existing_urls = set()
existing_names = set()
valid_count = 0
skip_count = 0
dup_count = 0
for row in rows[:200]: # preview cap
name = (row.get("name") or "").strip()
url = (row.get("url") or "").strip()
ct = (row.get("check_type") or "daily").strip().lower()
note = (row.get("note") or "").strip()
vis = (row.get("visibility") or "all").strip().lower()
# Normalise / default
if ct not in self._ALLOWED_TYPES:
ct = "daily"
if vis not in self._ALLOWED_VIS:
vis = "all"
# Classify the row
skip = not name or not url
dup_url = not skip and url.lower() in existing_urls
dup_name = not skip and not dup_url and name.lower() in existing_names
if skip:
tag = "skip"
status = "⚠ Missing field"
skip_count += 1
elif dup_url:
tag = "dup_url"
status = "⚠ Duplicate URL"
dup_count += 1
elif dup_name:
tag = "dup_name"
status = "⚠ Duplicate name"
dup_count += 1
else:
tag = "valid"
status = "✔ Will import"
valid_count += 1
self._tree.insert("", "end", tags=(tag,),
values=(name, url, ct, note[:50], vis, status))
self._rows.append({
"name": name, "url": url,
"check_type": ct, "note": note, "visibility": vis,
"_skip": skip or dup_url or dup_name,
})
total = len(rows)
shown = min(total, 200)
more = f" ({total - shown} more not shown)" if total > 200 else ""
parts = [f"{valid_count} will import"]
if dup_count:
parts.append(f"{dup_count} duplicate(s) skipped")
if skip_count:
parts.append(f"{skip_count} missing required field(s)")
self._status_lbl.config(
text="Preview: " + ", ".join(parts) + more + ". "
"(Duplicates are highlighted in orange.)",
fg=COLOURS["text_dim"],
)
if valid_count:
self._import_btn.config(state="normal")
def _read_file(self, path: str) -> list:
"""Return list of dicts from CSV or Excel. Header row normalised to lowercase."""
import os
ext = os.path.splitext(path)[1].lower()
if ext == ".csv":
return self._read_csv(path)
elif ext in (".xlsx", ".xls"):
return self._read_excel(path)
else:
raise ValueError(f"Unsupported file type: {ext}")
def _read_csv(self, path: str) -> list:
import csv
rows = []
with open(path, newline="", encoding="utf-8-sig") as fh:
reader = csv.DictReader(fh)
for row in reader:
rows.append({k.strip().lower(): v for k, v in row.items()})
return rows
def _read_excel(self, path: str) -> list:
try:
import openpyxl
except ImportError:
raise ImportError(
"openpyxl is required for Excel import.\n"
"Install it with: pip install openpyxl"
)
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
ws = wb.active
rows_iter = ws.iter_rows(values_only=True)
headers = None
result = []
for row in rows_iter:
# Skip completely empty rows
if all(v is None for v in row):
continue
if headers is None:
headers = [str(c).strip().lower() if c is not None else "" for c in row]
continue
row_dict = {headers[i]: (str(v).strip() if v is not None else "")
for i, v in enumerate(row) if i < len(headers)}
result.append(row_dict)
wb.close()
return result
# ── Import ────────────────────────────────────────────────────────────────
def _do_import(self):
"""Insert all valid, non-duplicate rows via create_website().
Duplicate detection is done in the application layer by fetching
existing URLs and names from the DB immediately before inserting.
This is necessary because there is no UNIQUE constraint on the
websites table, so relying on a DB error (1062) would not work.
"""
from models import (
create_website,
get_existing_website_urls,
get_existing_website_names,
)
to_import = [r for r in self._rows if not r["_skip"]]
if not to_import:
show_error("No valid rows to import.\n\n"
"All rows are either missing required fields "
"or are duplicates of existing websites.")
return
# Re-fetch existing state at import time — user may have been on
# the preview screen for a while; another admin could have added
# a site in the interim.
try:
existing_urls = get_existing_website_urls()
existing_names = get_existing_website_names()
except Exception as e:
show_error(f"Could not verify existing websites:\n{e}")
return
inserted = 0
dup_url = 0
dup_name = 0
errors = []
for r in to_import:
url_key = r["url"].strip().lower()
name_key = r["name"].strip().lower()
if url_key in existing_urls:
dup_url += 1
logger.info(f"[IMPORT] Skipped — duplicate URL: {r['url']}")
continue
if name_key in existing_names:
dup_name += 1
logger.info(f"[IMPORT] Skipped — duplicate name: {r['name']}")
continue
try:
create_website(
admin_id=self.current_user["id"],
name=r["name"],
url=r["url"],
check_type=r["check_type"],
note=r["note"],
credentials=[],
visibility=r["visibility"],
)
# Track inserted URL/name so intra-file duplicates are
# also caught (e.g. the same URL appears twice in the CSV).
existing_urls.add(url_key)
existing_names.add(name_key)
inserted += 1
logger.info(
f"[IMPORT] Website '{r['name']}' ({r['url']}) imported "
f"by admin_id={self.current_user['id']}."
)
except Exception as e:
errors.append(f"{r['name']}: {e}")
logger.error(f"[IMPORT] Failed to import '{r['name']}': {e}")
msg = f"Import complete.\n\n{inserted} website(s) imported."
if dup_url:
msg += f"\n{dup_url} row(s) skipped — URL already exists."
if dup_name:
msg += f"\n{dup_name} row(s) skipped — name already exists."
if errors:
msg += f"\n{len(errors)} error(s):\n" + "\n".join(errors[:5])
show_info(msg)
self.on_complete()
self.destroy()
# ── Template download ─────────────────────────────────────────────────────
def _download_template(self):
import os, shutil
from tkinter import filedialog
src = self._TEMPLATE_PATH
if not os.path.exists(src):
show_error(
"Template file not found.\n"
f"Expected at: {os.path.abspath(src)}"
)
return
dest = filedialog.asksaveasfilename(
title="Save import template as…",
initialfile="website_import_template.xlsx",
defaultextension=".xlsx",
filetypes=[("Excel Workbook", "*.xlsx")],
)
if not dest:
return
shutil.copy2(src, dest)
logger.info(f"Import template downloaded to: {dest}")
show_info(f"Template saved to:\n{dest}")
+437 -29
View File
@@ -167,16 +167,12 @@ class AiSummaryView(ttk.Frame):
def _load_config(self): def _load_config(self):
try: try:
import configparser from config import get_setting
from config import CONFIG_FILE from utils.crypto import decrypt
from utils.config_crypto import decrypt_value raw_key = get_setting("groq.api_key", "")
cfg = configparser.ConfigParser() if raw_key:
cfg.read(CONFIG_FILE, encoding="utf-8") self._api_key_var.set(decrypt(raw_key))
if cfg.has_section(_CFG_SECTION): model = get_setting("groq.model", GROQ_MODELS[0])
raw_key = cfg.get(_CFG_SECTION, _CFG_KEY_KEY, fallback="")
self._api_key_var.set(decrypt_value(raw_key))
model = cfg.get(_CFG_SECTION, _CFG_KEY_MODEL,
fallback=GROQ_MODELS[0])
if model in GROQ_MODELS: if model in GROQ_MODELS:
self._model_var.set(model) self._model_var.set(model)
except Exception as e: except Exception as e:
@@ -184,18 +180,10 @@ class AiSummaryView(ttk.Frame):
def _save_config(self): def _save_config(self):
try: try:
import configparser from config import set_setting
from config import CONFIG_FILE from utils.crypto import encrypt
from utils.config_crypto import encrypt_value set_setting("groq.api_key", encrypt(self._api_key_var.get().strip()))
cfg = configparser.ConfigParser() set_setting("groq.model", self._model_var.get())
cfg.read(CONFIG_FILE, encoding="utf-8")
if not cfg.has_section(_CFG_SECTION):
cfg.add_section(_CFG_SECTION)
cfg.set(_CFG_SECTION, _CFG_KEY_KEY,
encrypt_value(self._api_key_var.get().strip()))
cfg.set(_CFG_SECTION, _CFG_KEY_MODEL, self._model_var.get())
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
cfg.write(fh)
except Exception as e: except Exception as e:
logger.warning(f"Could not save Groq config: {e}") logger.warning(f"Could not save Groq config: {e}")
@@ -217,9 +205,17 @@ class AiSummaryView(ttk.Frame):
self._build_criteria_panel() 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") 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"]) left = tk.Frame(pane, bg=C["bg"])
pane.add(left, minsize=260, width=300) pane.add(left, minsize=260, width=300)
@@ -229,6 +225,12 @@ class AiSummaryView(ttk.Frame):
pane.add(right, minsize=350) pane.add(right, minsize=350)
self._build_output_panel(right) 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("<<NotebookTabChanged>>", self._on_tab_changed)
status_bar = tk.Frame(self, bg=C["surface2"], pady=4) status_bar = tk.Frame(self, bg=C["surface2"], pady=4)
status_bar.pack(fill="x", side="bottom") status_bar.pack(fill="x", side="bottom")
tk.Label(status_bar, textvariable=self._status_var, tk.Label(status_bar, textvariable=self._status_var,
@@ -361,6 +363,20 @@ class AiSummaryView(ttk.Frame):
).pack(side="left", padx=(0, 4)) ).pack(side="left", padx=(0, 4))
self._criteria_tree.bind("<Double-1>", self._criteria_tree.bind("<Double-1>",
lambda _: self._open_edit_criterion()) lambda _: self._open_edit_criterion())
else:
# User (read-only): provide a View Details button and double-click binding
btn_bar = tk.Frame(self._criteria_card, bg=C["surface"], pady=4)
btn_bar.pack(fill="x")
tk.Button(
btn_bar, text="👁 View Details",
command=self._open_view_criterion,
bg=C["surface2"], fg=C["text"],
activebackground=C["accent"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=8, pady=3,
).pack(side="left", padx=(0, 4))
self._criteria_tree.bind("<Double-1>",
lambda _: self._open_view_criterion())
tk.Label( tk.Label(
self._criteria_card, self._criteria_card,
@@ -430,6 +446,27 @@ class AiSummaryView(ttk.Frame):
CriterionDialog(self, self.current_user, CriterionDialog(self, self.current_user,
criterion_data=data, on_save=self._load_criteria_tree) criterion_data=data, on_save=self._load_criteria_tree)
def _open_view_criterion(self):
"""Open a read-only detail dialog for the selected criterion (user role)."""
cid = self._get_selected_criterion_id()
if not cid:
show_error("Please select a criterion to view.")
return
try:
from models import get_all_criteria
all_rows = {r["id"]: r for r in get_all_criteria()}
data = all_rows.get(cid)
except Exception as e:
show_error(f"Could not load criterion: {e}")
return
if not data:
show_error("Criterion not found.")
return
logger.info(
f"Criterion id={cid} viewed by user_id={self.current_user['id']}."
)
CriterionDetailDialog(self, data)
def _delete_criterion(self): def _delete_criterion(self):
cid = self._get_selected_criterion_id() cid = self._get_selected_criterion_id()
if not cid: if not cid:
@@ -858,12 +895,24 @@ class AiSummaryView(ttk.Frame):
) )
full_output = header + result 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: except Exception as exc:
self.after(0, lambda e=exc: self._on_error(e)) 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._stop_spinner()
self._set_output_text(text) self._set_output_text(text)
if verdict: if verdict:
@@ -876,6 +925,25 @@ class AiSummaryView(ttk.Frame):
f"'{self.current_user.get('username')}' " f"'{self.current_user.get('username')}' "
f"({len(self._files)} file(s)). Verdict: {verdict or 'N/A'}." 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): def _on_error(self, exc: Exception):
self._stop_spinner() self._stop_spinner()
@@ -904,6 +972,241 @@ class AiSummaryView(ttk.Frame):
self._progress.pack_forget() self._progress.pack_forget()
self._run_btn.config(state="normal", text="✨ Analyze with AI") 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("<Double-1>",
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("<<TreeviewSelect>>", 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 ------------------------------------------------------------ # -- Status bar ------------------------------------------------------------
def _set_status(self, msg: str): def _set_status(self, msg: str):
@@ -914,6 +1217,109 @@ class AiSummaryView(ttk.Frame):
# Criterion Add / Edit Dialog (admin only) # Criterion Add / Edit Dialog (admin only)
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
class CriterionDetailDialog(tk.Toplevel):
"""Read-only modal dialog that displays the full details of an AI evaluation criterion.
Shown to regular users who click 'View Details' or double-click a row in the
criteria treeview. Admins continue to use CriterionDialog (edit mode) instead.
"""
def __init__(self, parent, criterion_data: dict):
super().__init__(parent)
self._data = criterion_data
self.title("Criterion Details")
self.configure(bg=COLOURS["bg"])
self.resizable(False, False)
self.grab_set()
self._build_ui()
self._centre()
def _centre(self):
self.update_idletasks()
w, h = 560, 380
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
d = self._data
# ── Header ────────────────────────────────────────────────────────────
hdr = tk.Frame(self, bg=C["surface"], padx=24, pady=14)
hdr.pack(fill="x")
status_text = "Active" if d.get("is_active") else "Inactive"
status_color = C["accent"] if d.get("is_active") else C["danger"]
tk.Label(
hdr,
text=d.get("title") or "Untitled",
bg=C["surface"], fg=C["text"],
font=FONT_HEADING, wraplength=480, justify="left",
).pack(anchor="w")
meta_row = tk.Frame(hdr, bg=C["surface"])
meta_row.pack(anchor="w", pady=(4, 0))
tk.Label(
meta_row,
text=f"Sort order: {d.get('sort_order', 0)}",
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL,
).pack(side="left")
tk.Label(
meta_row, text=" · ",
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL,
).pack(side="left")
tk.Label(
meta_row, text=status_text,
bg=C["surface"], fg=status_color,
font=FONT_SMALL,
).pack(side="left")
ttk.Separator(self, orient="horizontal").pack(fill="x", padx=0)
# ── Description (scrollable, read-only) ───────────────────────────────
body = tk.Frame(self, bg=C["bg"], padx=24, pady=16)
body.pack(fill="both", expand=True)
tk.Label(
body, text="Description",
bg=C["bg"], fg=C["text_dim"],
font=FONT_SMALL,
).pack(anchor="w", pady=(0, 4))
txt_frame = tk.Frame(body, bg=C["surface2"])
txt_frame.pack(fill="both", expand=True)
vsb = ttk.Scrollbar(txt_frame, orient="vertical")
vsb.pack(side="right", fill="y")
txt = tk.Text(
txt_frame, wrap="word",
bg=C["surface2"], fg=C["text"],
relief="flat", font=FONT,
state="normal",
yscrollcommand=vsb.set,
padx=10, pady=8,
)
txt.insert("1.0", d.get("description") or "No description provided.")
txt.config(state="disabled") # read-only after inserting content
txt.pack(fill="both", expand=True)
vsb.config(command=txt.yview)
# ── Footer ────────────────────────────────────────────────────────────
ttk.Separator(self, orient="horizontal").pack(fill="x")
footer = ttk.Frame(self)
footer.pack(fill="x", padx=24, pady=12)
ttk.Button(
footer, text="Close",
command=self.destroy,
).pack(side="right")
class CriterionDialog(tk.Toplevel): class CriterionDialog(tk.Toplevel):
"""Modal dialog for creating or editing an AI evaluation criterion.""" """Modal dialog for creating or editing an AI evaluation criterion."""
@@ -927,14 +1333,16 @@ class CriterionDialog(tk.Toplevel):
self.title("Edit Criterion" if self.is_edit else "Add Criterion") self.title("Edit Criterion" if self.is_edit else "Add Criterion")
self.configure(bg=COLOURS["bg"]) self.configure(bg=COLOURS["bg"])
self.resizable(False, False) self.resizable(False, True) # allow vertical resize for varied DPI/fonts
self.grab_set() self.grab_set()
self._build_ui() self._build_ui()
self._centre() self._centre()
def _centre(self): def _centre(self):
self.update_idletasks() self.update_idletasks()
w, h = 560, 400 # Height increased to 520 so buttons are always visible at standard DPI.
# The dialog is vertically resizable so higher-DPI systems can expand it.
w, h = 560, 520
x = (self.winfo_screenwidth() - w) // 2 x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2 y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}") self.geometry(f"{w}x{h}+{x}+{y}")
@@ -970,7 +1378,7 @@ class CriterionDialog(tk.Toplevel):
desc_vsb = ttk.Scrollbar(desc_frame, orient="vertical") desc_vsb = ttk.Scrollbar(desc_frame, orient="vertical")
desc_vsb.pack(side="right", fill="y") desc_vsb.pack(side="right", fill="y")
self._desc_txt = tk.Text( self._desc_txt = tk.Text(
desc_frame, height=6, wrap="word", desc_frame, height=5, wrap="word",
bg=C["surface2"], fg=C["text"], bg=C["surface2"], fg=C["text"],
insertbackground=C["text"], insertbackground=C["text"],
relief="flat", font=FONT, relief="flat", font=FONT,
+761
View File
@@ -0,0 +1,761 @@
"""
views/bid_tracker_view.py Bid / Opportunity Follow-Up Tracker
Any logged-in user can:
- View all tracked bids and their latest status
- Add a new bid (URL + metadata)
- Post updates on any bid visible to everyone
- Edit or delete their own bids (admins can edit/delete any bid)
Layout:
Left pane : filterable bid list with status badges
Right pane : detail panel metadata + scrollable update timeline
"""
import os
import logging
import tkinter as tk
from tkinter import ttk
import webbrowser
from utils.ui_helpers import (
COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL,
show_error, show_info, confirm_delete,
)
logger = logging.getLogger("bid_tracker_view")
# Status display config: (label, colour_key)
STATUS_META = {
"open": ("🟢 Open", "success"),
"monitoring": ("🔵 Monitoring", "accent"),
"awarded": ("🏆 Awarded", "warning"),
"no_bid": ("⛔ No Bid", "danger"),
"cancelled": ("🚫 Cancelled", "text_dim"),
}
class BidTrackerView(ttk.Frame):
def __init__(self, parent, current_user: dict):
super().__init__(parent)
self.current_user = current_user
self._is_admin = current_user.get("role") == "admin"
self._selected_bid_id = None
self._build_ui()
self._load_bids()
# ── Layout ────────────────────────────────────────────────────────────────
def _build_ui(self):
C = COLOURS
# Page header
hdr = ttk.Frame(self)
hdr.pack(fill="x", pady=(0, 10))
ttk.Label(hdr, text="📌 Bid Tracker",
style="Heading.TLabel").pack(side="left")
ttk.Label(hdr,
text="Track potential opportunities and follow up on updates.",
style="Dim.TLabel").pack(side="left", padx=(12, 0))
# Toolbar
toolbar = tk.Frame(self, bg=C["surface"], pady=8, padx=12)
toolbar.pack(fill="x", pady=(0, 8))
tk.Button(
toolbar, text=" Add Bid",
command=self._open_add_dialog,
bg=C["accent"], fg=C["white"],
activebackground=C["accent_hover"], activeforeground=C["white"],
relief="flat", font=FONT_BOLD, cursor="hand2",
padx=12, pady=5,
).pack(side="left")
tk.Button(
toolbar, text="↻ Refresh",
command=self._load_bids,
bg=C["surface2"], fg=C["text"],
activebackground=C["accent"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=5,
).pack(side="left", padx=(6, 0))
# Status filter
tk.Label(toolbar, text="Filter:",
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL).pack(side="left", padx=(16, 4))
self._filter_var = tk.StringVar(value="All")
filter_cb = ttk.Combobox(
toolbar, textvariable=self._filter_var,
values=["All"] + [v[0] for v in STATUS_META.values()],
state="readonly", width=14,
)
filter_cb.pack(side="left")
filter_cb.bind("<<ComboboxSelected>>", lambda _: self._load_bids())
# Search
tk.Label(toolbar, text="Search:",
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL).pack(side="left", padx=(16, 4))
self._search_var = tk.StringVar()
self._search_var.trace_add("write", lambda *_: self._load_bids())
tk.Entry(
toolbar, textvariable=self._search_var,
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT, width=22,
).pack(side="left", ipady=4)
# Two-pane split
pane = tk.PanedWindow(self, orient="horizontal",
bg=C["border"], sashwidth=4, sashrelief="flat")
pane.pack(fill="both", expand=True)
left = tk.Frame(pane, bg=C["bg"])
pane.add(left, minsize=300, width=360)
self._build_bid_list(left)
right = tk.Frame(pane, bg=C["bg"])
pane.add(right, minsize=380)
self._build_detail_panel(right)
def _build_bid_list(self, parent):
C = COLOURS
# Treeview
cols = ("Title", "Status", "Due Date", "Updates")
self._tree = ttk.Treeview(
parent, columns=cols, show="headings",
selectmode="browse",
)
col_widths = {"Title": 160, "Status": 90, "Due Date": 80, "Updates": 60}
for col in cols:
self._tree.heading(col, text=col)
self._tree.column(col, width=col_widths[col],
anchor="w" if col == "Title" else "center")
# Colour tags per status
self._tree.tag_configure("open", foreground=C["success"])
self._tree.tag_configure("monitoring", foreground=C["accent"])
self._tree.tag_configure("awarded", foreground=C["warning"])
self._tree.tag_configure("no_bid", foreground=C["danger"])
self._tree.tag_configure("cancelled", foreground=C["text_dim"])
vsb = ttk.Scrollbar(parent, orient="vertical",
command=self._tree.yview)
self._tree.configure(yscrollcommand=vsb.set)
vsb.pack(side="right", fill="y")
self._tree.pack(side="left", fill="both", expand=True)
self._tree.bind("<<TreeviewSelect>>", self._on_bid_select)
self._tree.bind("<Double-1>", lambda _: self._open_edit_dialog())
def _build_detail_panel(self, parent):
C = COLOURS
# Action toolbar (right pane)
act = tk.Frame(parent, bg=C["surface"], pady=8, padx=12)
act.pack(fill="x")
self._edit_btn = tk.Button(
act, text="✎ Edit Bid",
command=self._open_edit_dialog,
bg=C["surface2"], fg=C["text"],
activebackground=C["accent"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=4,
)
self._edit_btn.pack(side="left", padx=(0, 4))
self._del_btn = tk.Button(
act, text="✕ Delete",
command=self._delete_bid,
bg=C["surface2"], fg=C["danger"],
activebackground=C["danger"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=4,
)
self._del_btn.pack(side="left")
self._open_btn = tk.Button(
act, text="↗ Open URL",
command=self._open_url,
bg=C["surface2"], fg=C["accent"],
activebackground=C["accent"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=4,
)
self._open_btn.pack(side="right")
# Metadata card
self._meta_frame = tk.Frame(parent, bg=C["surface"], padx=14, pady=10)
self._meta_frame.pack(fill="x", pady=(0, 4))
self._meta_title = tk.Label(
self._meta_frame, text="Select a bid to view details.",
bg=C["surface"], fg=C["text"],
font=FONT_BOLD, wraplength=480, justify="left",
)
self._meta_title.pack(anchor="w")
self._meta_sub = tk.Label(
self._meta_frame, text="",
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL, wraplength=480, justify="left",
)
self._meta_sub.pack(anchor="w", pady=(2, 0))
self._meta_notes = tk.Label(
self._meta_frame, text="",
bg=C["surface"], fg=C["text"],
font=FONT_SMALL, wraplength=480, justify="left",
)
self._meta_notes.pack(anchor="w", pady=(4, 0))
# ── Updates section ───────────────────────────────────────────────
upd_hdr = tk.Frame(parent, bg=C["bg"], pady=4, padx=12)
upd_hdr.pack(fill="x")
tk.Label(upd_hdr, text="📝 Updates",
bg=C["bg"], fg=C["text"],
font=FONT_BOLD).pack(side="left")
# Post update entry
compose = tk.Frame(parent, bg=C["surface"], padx=12, pady=8)
compose.pack(fill="x")
self._update_txt = tk.Text(
compose, height=3, wrap="word",
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT,
)
self._update_txt.pack(fill="x", pady=(0, 4))
# Placeholder behaviour
self._update_txt.insert("1.0", "Write an update, e.g. 'Amendment 1 issued — due date extended to...'")
self._update_txt.config(fg=C["text_dim"])
self._update_txt.bind("<FocusIn>", self._on_update_focus_in)
self._update_txt.bind("<FocusOut>", self._on_update_focus_out)
tk.Button(
compose, text="📤 Post Update",
command=self._post_update,
bg=C["accent"], fg=C["white"],
activebackground=C["accent_hover"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=4,
).pack(anchor="e")
# Scrollable update timeline
timeline_frame = tk.Frame(parent, bg=C["bg"])
timeline_frame.pack(fill="both", expand=True, padx=4, pady=(4, 0))
vsb = ttk.Scrollbar(timeline_frame, orient="vertical")
vsb.pack(side="right", fill="y")
self._update_canvas = tk.Canvas(
timeline_frame, bg=C["bg"],
highlightthickness=0,
yscrollcommand=vsb.set,
)
self._update_canvas.pack(side="left", fill="both", expand=True)
vsb.config(command=self._update_canvas.yview)
self._update_inner = tk.Frame(self._update_canvas, bg=C["bg"])
self._canvas_window = self._update_canvas.create_window(
(0, 0), window=self._update_inner, anchor="nw")
self._update_inner.bind("<Configure>", self._on_inner_configure)
self._update_canvas.bind("<Configure>", self._on_canvas_configure)
# Mousewheel scrolling
self._update_canvas.bind("<Enter>",
lambda _: self._update_canvas.bind_all(
"<MouseWheel>", self._on_mousewheel))
self._update_canvas.bind("<Leave>",
lambda _: self._update_canvas.unbind_all("<MouseWheel>"))
self._set_detail_buttons_state("disabled")
# ── Canvas / scroll helpers ───────────────────────────────────────────────
def _on_inner_configure(self, event=None):
self._update_canvas.configure(
scrollregion=self._update_canvas.bbox("all"))
def _on_canvas_configure(self, event):
self._update_canvas.itemconfig(
self._canvas_window, width=event.width)
def _on_mousewheel(self, event):
self._update_canvas.yview_scroll(
int(-1 * (event.delta / 120)), "units")
# ── Placeholder helpers ───────────────────────────────────────────────────
def _on_update_focus_in(self, event=None):
C = COLOURS
if self._update_txt.cget("fg") == C["text_dim"]:
self._update_txt.delete("1.0", "end")
self._update_txt.config(fg=C["text"])
def _on_update_focus_out(self, event=None):
C = COLOURS
if not self._update_txt.get("1.0", "end-1c").strip():
self._update_txt.delete("1.0", "end")
self._update_txt.insert("1.0",
"Write an update, e.g. 'Amendment 1 issued — due date extended to...'")
self._update_txt.config(fg=C["text_dim"])
def _get_update_text(self) -> str:
C = COLOURS
txt = self._update_txt.get("1.0", "end-1c").strip()
if self._update_txt.cget("fg") == C["text_dim"]:
return ""
return txt
# ── Data loading ─────────────────────────────────────────────────────────
def _load_bids(self, *_):
from models import get_all_bids
# Map display label back to DB key for filter
filter_label = self._filter_var.get()
status_key = ""
for k, (label, _) in STATUS_META.items():
if label == filter_label:
status_key = k
break
search = self._search_var.get().lower().strip()
self._tree.delete(*self._tree.get_children())
try:
bids = get_all_bids(status_filter=status_key)
except Exception as e:
show_error(f"Failed to load bids:\n{e}")
return
for bid in bids:
if search and search not in (bid.get("title") or "").lower() \
and search not in (bid.get("source") or "").lower() \
and search not in (bid.get("solicitation_number") or "").lower():
continue
status = bid.get("status", "open")
label, _ = STATUS_META.get(status, (status, "text"))
due = str(bid.get("due_date") or "")
upd_count = bid.get("update_count", 0)
self._tree.insert(
"", "end", iid=str(bid["id"]), tags=(status,),
values=(bid["title"], label, due, upd_count),
)
# Restore selection if still present
if self._selected_bid_id and str(self._selected_bid_id) in \
self._tree.get_children():
self._tree.selection_set(str(self._selected_bid_id))
self._tree.see(str(self._selected_bid_id))
else:
self._selected_bid_id = None
self._clear_detail()
def _on_bid_select(self, event=None):
sel = self._tree.selection()
if not sel:
return
self._selected_bid_id = int(sel[0])
self._load_detail(self._selected_bid_id)
self._set_detail_buttons_state("normal")
def _load_detail(self, bid_id: int):
from models import get_bid, get_bid_updates
C = COLOURS
bid = get_bid(bid_id)
if not bid:
return
status = bid.get("status", "open")
label, colour_key = STATUS_META.get(status, (status, "text"))
due = str(bid.get("due_date") or "Not specified")
sol = bid.get("solicitation_number") or ""
src = bid.get("source") or ""
added_by = bid.get("added_by_username") or ""
added_at = str(bid.get("created_at") or "")[:16]
self._meta_title.config(text=bid["title"])
self._meta_sub.config(
text=(f"{label} | Due: {due} | Sol #: {sol} | "
f"Source: {src} | Added by: {added_by} ({added_at})")
)
notes = bid.get("notes") or ""
self._meta_notes.config(
text=f"Notes: {notes}" if notes else "",
)
# Hide edit/delete for non-owners unless admin
is_owner = bid.get("added_by") == self.current_user["id"]
can_edit = self._is_admin or is_owner
state = "normal" if can_edit else "disabled"
self._edit_btn.config(state=state)
self._del_btn.config(state=state)
# Render update timeline
for widget in self._update_inner.winfo_children():
widget.destroy()
updates = get_bid_updates(bid_id)
if not updates:
tk.Label(
self._update_inner,
text="No updates yet. Be the first to post one.",
bg=C["bg"], fg=C["text_dim"],
font=FONT_SMALL,
).pack(anchor="w", padx=8, pady=8)
else:
for upd in updates:
self._render_update_card(upd)
self._update_canvas.yview_moveto(0)
def _render_update_card(self, upd: dict):
C = COLOURS
card = tk.Frame(self._update_inner, bg=C["surface"],
padx=10, pady=8)
card.pack(fill="x", padx=4, pady=(0, 4))
# Header row
hdr = tk.Frame(card, bg=C["surface"])
hdr.pack(fill="x")
poster = upd.get("posted_by_full_name") or upd.get("posted_by_username") or "Unknown"
dt_str = str(upd.get("created_at") or "")[:16]
tk.Label(
hdr, text=f"👤 {poster}",
bg=C["surface"], fg=C["text"],
font=FONT_BOLD,
).pack(side="left")
tk.Label(
hdr, text=dt_str,
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL,
).pack(side="left", padx=(8, 0))
# Delete button — own update or admin
is_own = upd.get("user_id") == self.current_user["id"]
if is_own or self._is_admin:
tk.Button(
hdr, text="",
command=lambda uid=upd["id"]: self._delete_update(uid),
bg=C["surface"], fg=C["text_dim"],
activebackground=C["danger"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
).pack(side="right")
# Content
tk.Label(
card, text=upd.get("content", ""),
bg=C["surface"], fg=C["text"],
font=FONT, wraplength=460, justify="left", anchor="w",
).pack(fill="x", pady=(4, 0))
def _clear_detail(self):
self._meta_title.config(text="Select a bid to view details.")
self._meta_sub.config(text="")
self._meta_notes.config(text="")
for widget in self._update_inner.winfo_children():
widget.destroy()
self._set_detail_buttons_state("disabled")
def _set_detail_buttons_state(self, state: str):
self._edit_btn.config(state=state)
self._del_btn.config(state=state)
self._open_btn.config(state=state)
# ── Actions ───────────────────────────────────────────────────────────────
def _open_url(self):
if not self._selected_bid_id:
return
from models import get_bid
bid = get_bid(self._selected_bid_id)
if bid and bid.get("url"):
try:
webbrowser.open(bid["url"])
except Exception as e:
show_error(f"Could not open URL:\n{e}")
def _open_add_dialog(self):
BidDialog(self, self.current_user,
bid_data=None, on_save=self._load_bids)
def _open_edit_dialog(self):
if not self._selected_bid_id:
show_error("Please select a bid to edit.")
return
from models import get_bid
bid = get_bid(self._selected_bid_id)
if not bid:
show_error("Bid not found.")
return
# Enforce ownership
is_owner = bid.get("added_by") == self.current_user["id"]
if not self._is_admin and not is_owner:
show_error("You can only edit bids you added.")
return
BidDialog(self, self.current_user,
bid_data=bid, on_save=self._after_edit)
def _after_edit(self):
"""Reload list and refresh detail panel after an edit."""
self._load_bids()
if self._selected_bid_id:
self._load_detail(self._selected_bid_id)
def _delete_bid(self):
if not self._selected_bid_id:
return
from models import get_bid, delete_bid
bid = get_bid(self._selected_bid_id)
if not bid:
return
is_owner = bid.get("added_by") == self.current_user["id"]
if not self._is_admin and not is_owner:
show_error("You can only delete bids you added.")
return
if confirm_delete(bid["title"]):
try:
delete_bid(self.current_user["id"], self._selected_bid_id)
self._selected_bid_id = None
self._load_bids()
self._clear_detail()
show_info("Bid deleted.")
except Exception as e:
show_error(f"Delete failed:\n{e}")
def _post_update(self):
if not self._selected_bid_id:
show_error("Please select a bid first.")
return
content = self._get_update_text()
if not content:
show_error("Please write an update before posting.")
return
try:
from models import add_bid_update
add_bid_update(self.current_user["id"],
self._selected_bid_id, content)
# Clear the text box and reset placeholder
self._update_txt.delete("1.0", "end")
self._on_update_focus_out()
# Refresh the update timeline
self._load_detail(self._selected_bid_id)
# Refresh update count in the tree
self._load_bids()
if self._selected_bid_id and \
str(self._selected_bid_id) in self._tree.get_children():
self._tree.selection_set(str(self._selected_bid_id))
except Exception as e:
show_error(f"Failed to post update:\n{e}")
def _delete_update(self, update_id: int):
from tkinter import messagebox
if not messagebox.askyesno(
"Delete Update",
"Are you sure you want to delete this update?"):
return
try:
from models import delete_bid_update
delete_bid_update(self.current_user["id"], update_id)
self._load_detail(self._selected_bid_id)
self._load_bids()
if self._selected_bid_id and \
str(self._selected_bid_id) in self._tree.get_children():
self._tree.selection_set(str(self._selected_bid_id))
except Exception as e:
show_error(f"Failed to delete update:\n{e}")
# ── Add / Edit Bid Dialog ─────────────────────────────────────────────────────
class BidDialog(tk.Toplevel):
"""Modal dialog for adding or editing a bid/opportunity."""
def __init__(self, parent, current_user: dict,
bid_data, on_save):
super().__init__(parent)
self.current_user = current_user
self.bid_data = bid_data
self.on_save = on_save
self.is_edit = bid_data is not None
self.title("Edit Bid" if self.is_edit else "Add Bid")
self.configure(bg=COLOURS["bg"])
self.resizable(False, False)
self.grab_set()
self._build_ui()
self._centre()
def _centre(self):
self.update_idletasks()
w, h = 560, 520
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
ttk.Label(self,
text="Edit Opportunity" if self.is_edit else "Add Opportunity",
style="Heading.TLabel").pack(anchor="w", padx=24, pady=(20, 4))
form = ttk.Frame(self)
form.pack(fill="x", padx=24, pady=8)
form.columnconfigure(1, weight=1)
def lbl(text, row):
ttk.Label(form, text=text).grid(
row=row, column=0, sticky="w", padx=(0, 12), pady=6)
def entry(row, show=None):
var = tk.StringVar()
e = tk.Entry(form, textvariable=var,
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT, show=show or "")
e.grid(row=row, column=1, sticky="ew", ipady=5, pady=6)
return var
# Row 0 — Title
lbl("Title *", 0)
self._title_var = entry(0)
# Row 1 — URL
lbl("URL *", 1)
self._url_var = entry(1)
# Row 2 — Source
lbl("Source", 2)
self._source_var = entry(2)
# Row 3 — Solicitation #
lbl("Solicitation #", 3)
self._sol_var = entry(3)
# Row 4 — Status
lbl("Status", 4)
from models import BID_STATUSES
self._status_var = tk.StringVar(value="open")
status_frame = tk.Frame(form, bg=C["bg"])
status_frame.grid(row=4, column=1, sticky="w", pady=6)
for s in BID_STATUSES:
label, _ = STATUS_META.get(s, (s, "text"))
tk.Radiobutton(
status_frame, text=label,
variable=self._status_var, value=s,
bg=C["bg"], fg=C["text"],
activebackground=C["bg"], activeforeground=C["accent"],
selectcolor=C["surface2"],
font=FONT_SMALL, cursor="hand2",
).pack(side="left", padx=(0, 8))
# Row 5 — Due date
lbl("Due Date", 5)
self._due_var = tk.StringVar()
tk.Entry(form, textvariable=self._due_var,
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT, width=14).grid(
row=5, column=1, sticky="w", ipady=5, pady=6)
ttk.Label(form, text="YYYY-MM-DD",
style="Dim.TLabel").grid(
row=5, column=1, sticky="e", pady=6)
# Row 6 — Notes
lbl("Notes", 6)
notes_frame = tk.Frame(form, bg=C["surface2"])
notes_frame.grid(row=6, column=1, sticky="ew", pady=6)
notes_vsb = ttk.Scrollbar(notes_frame, orient="vertical")
notes_vsb.pack(side="right", fill="y")
self._notes_txt = tk.Text(
notes_frame, height=4, wrap="word",
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT,
yscrollcommand=notes_vsb.set,
)
self._notes_txt.pack(fill="both", expand=True, padx=4, pady=4)
notes_vsb.config(command=self._notes_txt.yview)
# Pre-populate when editing
if self.is_edit:
d = self.bid_data
self._title_var.set(d.get("title") or "")
self._url_var.set(d.get("url") or "")
self._source_var.set(d.get("source") or "")
self._sol_var.set(d.get("solicitation_number") or "")
self._status_var.set(d.get("status") or "open")
self._due_var.set(str(d.get("due_date") or ""))
self._notes_txt.insert("1.0", d.get("notes") or "")
ttk.Separator(self, orient="horizontal").pack(fill="x", padx=24, pady=10)
btn_frame = ttk.Frame(self)
btn_frame.pack(fill="x", padx=24, pady=(0, 20))
ttk.Button(btn_frame, text="Save",
command=self._save).pack(side="right", padx=(6, 0))
ttk.Button(btn_frame, text="Cancel", style="Ghost.TButton",
command=self.destroy).pack(side="right")
def _save(self):
title = self._title_var.get().strip()
url = self._url_var.get().strip()
source = self._source_var.get().strip()
sol = self._sol_var.get().strip()
status = self._status_var.get()
due_raw = self._due_var.get().strip()
notes = self._notes_txt.get("1.0", "end-1c").strip()
if not title:
show_error("Title is required.")
return
if not url:
show_error("URL is required.")
return
# Normalise URL
if "://" not in url:
url = "https://" + url
# Validate due date if provided
due_date = None
if due_raw:
import datetime
try:
due_date = datetime.date.fromisoformat(due_raw)
except ValueError:
show_error("Due date must be in YYYY-MM-DD format, e.g. 2025-12-31")
return
try:
if self.is_edit:
from models import update_bid
update_bid(
self.current_user["id"], self.bid_data["id"],
title, url, source, sol, status, due_date, notes,
)
show_info("Bid updated successfully.")
else:
from models import create_bid
create_bid(
self.current_user["id"],
title, url, source, sol, status, due_date, notes,
)
show_info("Bid added successfully.")
self.on_save()
self.destroy()
except Exception as e:
show_error(f"Save failed:\n{e}")
+165 -75
View File
@@ -1,6 +1,6 @@
""" """
views/email_settings_view.py SMTP / scheduled report configuration dialog. views/email_settings_view.py SMTP / scheduled report configuration dialog.
Admin-only. Persists to config.ini [email] section via utils/scheduler.py. Admin-only. Persists to the app_settings database table via utils/scheduler.py.
""" """
import tkinter as tk import tkinter as tk
@@ -15,6 +15,13 @@ from utils.ui_helpers import (
logger = logging.getLogger("email_settings_view") logger = logging.getLogger("email_settings_view")
# Security mode -> (label, default_port)
_SECURITY_MODES = {
"starttls": ("STARTTLS (port 587, most common)", 587),
"ssl": ("SSL / TLS (port 465, Gmail direct)", 465),
"none": ("None (port 25, internal relay only)", 25),
}
class EmailSettingsView(tk.Toplevel): class EmailSettingsView(tk.Toplevel):
def __init__(self, master, current_user: dict): def __init__(self, master, current_user: dict):
@@ -31,119 +38,159 @@ class EmailSettingsView(tk.Toplevel):
def _centre(self): def _centre(self):
self.update_idletasks() self.update_idletasks()
w, h = 500, 560 w, h = 540, 610
x = (self.winfo_screenwidth() - w) // 2 x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2 y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}") self.geometry(f"{w}x{h}+{x}+{y}")
def _build_ui(self): def _build_ui(self):
C = COLOURS
# Header # Header
hdr = tk.Frame(self, bg=COLOURS["accent"], pady=14) hdr = tk.Frame(self, bg=C["accent"], pady=14)
hdr.pack(fill="x") hdr.pack(fill="x")
tk.Label(hdr, text="Daily Report Email Settings", tk.Label(hdr, text="Daily Report Email Settings",
font=FONT_BOLD, bg=COLOURS["accent"], font=FONT_BOLD, bg=C["accent"],
fg=COLOURS["white"]).pack() fg=C["white"]).pack()
tk.Label(hdr, tk.Label(hdr,
text="Send an HTML completion report to recipients on a daily schedule.", text="Send an HTML completion report to recipients on a daily schedule.",
font=FONT_SMALL, bg=COLOURS["accent"], font=FONT_SMALL, bg=C["accent"],
fg=COLOURS["white"]).pack(pady=(2, 0)) fg=C["white"]).pack(pady=(2, 0))
# Form # Form
form = tk.Frame(self, bg=COLOURS["bg"], padx=32, pady=16) form = tk.Frame(self, bg=C["bg"], padx=32, pady=16)
form.pack(fill="both", expand=True) form.pack(fill="both", expand=True)
form.columnconfigure(1, weight=1) form.columnconfigure(1, weight=1)
# Enable toggle self._fields = [] # widgets to enable/disable with the checkbox
tk.Label(form, text="Enable daily emails", bg=COLOURS["bg"],
fg=COLOURS["text_dim"], font=FONT_SMALL).grid(
row=0, column=0, sticky="w", padx=(0, 12), pady=6)
self.enabled_var = tk.BooleanVar(value=False)
tk.Checkbutton(form, variable=self.enabled_var,
bg=COLOURS["bg"],
activebackground=COLOURS["bg"],
selectcolor=COLOURS["surface2"],
command=self._toggle_fields).grid(
row=0, column=1, sticky="w", pady=6)
def field(label, row, show=None, width=28): def lbl(text, row):
tk.Label(form, text=label, bg=COLOURS["bg"], tk.Label(form, text=text, bg=C["bg"], fg=C["text_dim"],
fg=COLOURS["text_dim"], font=FONT_SMALL, font=FONT_SMALL, anchor="w").grid(
anchor="w").grid(row=row, column=0, sticky="w", row=row, column=0, sticky="w", padx=(0, 12), pady=5)
padx=(0, 12), pady=5)
def entry(row, show=None, width=28):
var = tk.StringVar() var = tk.StringVar()
ent = tk.Entry(form, textvariable=var, width=width, ent = tk.Entry(form, textvariable=var, width=width,
bg=COLOURS["surface2"], fg=COLOURS["text"], bg=C["surface2"], fg=C["text"],
insertbackground=COLOURS["text"], insertbackground=C["text"],
relief="flat", font=FONT, show=show or "") relief="flat", font=FONT, show=show or "")
ent.grid(row=row, column=1, sticky="ew", ipady=6, pady=5) ent.grid(row=row, column=1, sticky="ew", ipady=6, pady=5)
self._fields.append(ent) self._fields.append(ent)
return var return var
self._fields = [] # Row 0 — Enable toggle
self.smtp_host_var = field("SMTP Host", 1) lbl("Enable daily emails", 0)
self.smtp_port_var = field("SMTP Port", 2, width=8) self.enabled_var = tk.BooleanVar(value=False)
self.smtp_user_var = field("SMTP Username", 3) tk.Checkbutton(form, variable=self.enabled_var,
self.smtp_pass_var = field("SMTP Password", 4, show="") bg=C["bg"], activebackground=C["bg"],
selectcolor=C["surface2"],
command=self._toggle_fields).grid(
row=0, column=1, sticky="w", pady=6)
# TLS toggle # Row 1 — SMTP Host
tk.Label(form, text="Use STARTTLS", bg=COLOURS["bg"], lbl("SMTP Host", 1)
fg=COLOURS["text_dim"], font=FONT_SMALL).grid( self.smtp_host_var = entry(1)
row=5, column=0, sticky="w", padx=(0, 12), pady=5)
self.tls_var = tk.BooleanVar(value=True)
tls_cb = tk.Checkbutton(form, variable=self.tls_var,
bg=COLOURS["bg"],
activebackground=COLOURS["bg"],
selectcolor=COLOURS["surface2"])
tls_cb.grid(row=5, column=1, sticky="w", pady=5)
self._fields.append(tls_cb)
self.recipients_var = field("Recipients (comma-sep)", 6) # Row 2 — Security mode (replaces the old Use STARTTLS checkbox)
self.send_time_var = field("Send Time (HH:MM)", 7, width=8) lbl("Security", 2)
self.security_var = tk.StringVar(value="starttls")
sec_frame = tk.Frame(form, bg=C["bg"])
sec_frame.grid(row=2, column=1, sticky="w", pady=5)
for key, (label, _) in _SECURITY_MODES.items():
rb = tk.Radiobutton(
sec_frame, text=label,
variable=self.security_var, value=key,
command=self._on_security_change,
bg=C["bg"], fg=C["text"],
activebackground=C["bg"], activeforeground=C["accent"],
selectcolor=C["surface2"], font=FONT_SMALL, cursor="hand2",
)
rb.pack(anchor="w")
self._fields.append(rb)
# Row 3 — SMTP Port (auto-filled when security mode changes)
lbl("SMTP Port", 3)
self.smtp_port_var = entry(3, width=8)
self.smtp_port_var.set("587") self.smtp_port_var.set("587")
# Row 4 — Username
lbl("SMTP Username", 4)
self.smtp_user_var = entry(4)
# Row 5 — Password
lbl("SMTP Password", 5)
self.smtp_pass_var = entry(5, show="")
# Row 6 — Recipients
lbl("Recipients (comma-sep)", 6)
self.recipients_var = entry(6)
# Row 7 — Send time
lbl("Send Time (HH:MM)", 7)
self.send_time_var = entry(7, width=8)
self.send_time_var.set("18:00") self.send_time_var.set("18:00")
# Status line # Row 8 — Status line (multi-line for diagnostic output)
self._status_var = tk.StringVar() self._status_var = tk.StringVar()
self._status_lbl = tk.Label(form, textvariable=self._status_var, self._status_lbl = tk.Label(
bg=COLOURS["bg"], fg=COLOURS["text_dim"], form, textvariable=self._status_var,
font=FONT_SMALL, wraplength=380, anchor="w") bg=C["bg"], fg=C["text_dim"],
self._status_lbl.grid(row=8, column=0, columnspan=2, sticky="ew", pady=(6, 0)) font=FONT_SMALL, wraplength=460,
anchor="w", justify="left",
)
self._status_lbl.grid(row=8, column=0, columnspan=2,
sticky="ew", pady=(6, 0))
# Buttons # Buttons
btn_row = tk.Frame(self, bg=COLOURS["bg"], padx=32, pady=14) btn_row = tk.Frame(self, bg=C["bg"], padx=32, pady=14)
btn_row.pack(fill="x") btn_row.pack(fill="x")
self._save_btn = tk.Button( self._save_btn = tk.Button(
btn_row, text="Save Settings", btn_row, text="Save Settings",
command=self._save, command=self._save,
bg=COLOURS["accent"], fg=COLOURS["white"], bg=C["accent"], fg=C["white"],
activebackground=COLOURS["accent_hover"], activebackground=C["accent_hover"],
activeforeground=COLOURS["white"], activeforeground=C["white"],
relief="flat", font=FONT_BOLD, cursor="hand2", relief="flat", font=FONT_BOLD, cursor="hand2",
padx=14, pady=8) padx=14, pady=8)
self._save_btn.pack(side="right", padx=(8, 0)) self._save_btn.pack(side="right", padx=(8, 0))
self._send_btn = tk.Button(
btn_row, text="📧 Send Test Email",
command=self._send_test_email,
bg=C["success"], fg=C["white"],
activebackground="#3d9140",
activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=8)
self._send_btn.pack(side="right", padx=(8, 0))
self._test_btn = tk.Button( self._test_btn = tk.Button(
btn_row, text="Test Connection", btn_row, text="🔌 Test Connection",
command=self._test, command=self._test,
bg=COLOURS["surface2"], fg=COLOURS["text"], bg=C["surface2"], fg=C["text"],
activebackground=COLOURS["surface2"], activebackground=C["surface2"],
activeforeground=COLOURS["accent"], activeforeground=C["accent"],
relief="flat", font=FONT_SMALL, cursor="hand2", relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=8) padx=10, pady=8)
self._test_btn.pack(side="right") self._test_btn.pack(side="right")
tk.Button(btn_row, text="Cancel", tk.Button(btn_row, text="Cancel",
command=self.destroy, command=self.destroy,
bg=COLOURS["surface"], fg=COLOURS["text_dim"], bg=C["surface"], fg=C["text_dim"],
activebackground=COLOURS["surface2"], activebackground=C["surface2"],
relief="flat", font=FONT_SMALL, cursor="hand2", relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=8).pack(side="left") padx=10, pady=8).pack(side="left")
self._toggle_fields() self._toggle_fields()
def _on_security_change(self):
"""Auto-fill the port when the security mode radio changes."""
mode = self.security_var.get()
self.smtp_port_var.set(str(_SECURITY_MODES[mode][1]))
def _toggle_fields(self): def _toggle_fields(self):
"""Enable/disable SMTP fields based on the enabled checkbox."""
state = "normal" if self.enabled_var.get() else "disabled" state = "normal" if self.enabled_var.get() else "disabled"
for w in self._fields: for w in self._fields:
try: try:
@@ -161,7 +208,11 @@ class EmailSettingsView(tk.Toplevel):
self.smtp_port_var.set(str(cfg.get("smtp_port", 587))) self.smtp_port_var.set(str(cfg.get("smtp_port", 587)))
self.smtp_user_var.set(cfg.get("smtp_user", "")) self.smtp_user_var.set(cfg.get("smtp_user", ""))
self.smtp_pass_var.set(cfg.get("smtp_password", "")) self.smtp_pass_var.set(cfg.get("smtp_password", ""))
self.tls_var.set(cfg.get("use_tls", True)) # Load security mode; fall back from legacy use_tls bool
security = cfg.get("security", "")
if not security:
security = "starttls" if cfg.get("use_tls", True) else "none"
self.security_var.set(security if security in _SECURITY_MODES else "starttls")
self.recipients_var.set(", ".join(cfg.get("recipients", []))) self.recipients_var.set(", ".join(cfg.get("recipients", [])))
self.send_time_var.set(cfg.get("send_time", "18:00")) self.send_time_var.set(cfg.get("send_time", "18:00"))
self._toggle_fields() self._toggle_fields()
@@ -171,13 +222,14 @@ class EmailSettingsView(tk.Toplevel):
port_str = self.smtp_port_var.get().strip() port_str = self.smtp_port_var.get().strip()
user = self.smtp_user_var.get().strip() user = self.smtp_user_var.get().strip()
password = self.smtp_pass_var.get() password = self.smtp_pass_var.get()
use_tls = self.tls_var.get() security = self.security_var.get()
recipients = self.recipients_var.get().strip() recipients = self.recipients_var.get().strip()
send_time = self.send_time_var.get().strip() send_time = self.send_time_var.get().strip()
enabled = self.enabled_var.get() enabled = self.enabled_var.get()
if enabled and (not host or not user or not recipients): if enabled and (not host or not user or not recipients):
self._set_status("Host, username, and recipients are required when enabled.", "danger") self._set_status(
"Host, username, and recipients are required when enabled.", "danger")
return None return None
try: try:
port = int(port_str) port = int(port_str)
@@ -186,7 +238,6 @@ class EmailSettingsView(tk.Toplevel):
except ValueError: except ValueError:
self._set_status("Port must be a number between 1 and 65535.", "danger") self._set_status("Port must be a number between 1 and 65535.", "danger")
return None return None
# Validate send_time
try: try:
h, m = map(int, send_time.split(":")) h, m = map(int, send_time.split(":"))
assert 0 <= h <= 23 and 0 <= m <= 59 assert 0 <= h <= 23 and 0 <= m <= 59
@@ -194,7 +245,7 @@ class EmailSettingsView(tk.Toplevel):
self._set_status("Send time must be in HH:MM format (e.g. 18:00).", "danger") self._set_status("Send time must be in HH:MM format (e.g. 18:00).", "danger")
return None return None
return enabled, host, port, user, password, use_tls, recipients, send_time return enabled, host, port, user, password, security, recipients, send_time
def _set_status(self, msg, level="dim"): def _set_status(self, msg, level="dim"):
colours = {"danger": COLOURS["danger"], "success": COLOURS["success"], colours = {"danger": COLOURS["danger"], "success": COLOURS["success"],
@@ -202,23 +253,61 @@ class EmailSettingsView(tk.Toplevel):
self._status_lbl.config(fg=colours.get(level, COLOURS["text_dim"])) self._status_lbl.config(fg=colours.get(level, COLOURS["text_dim"]))
self._status_var.set(msg) self._status_var.set(msg)
def _lock_buttons(self):
self._test_btn.config(state="disabled")
self._send_btn.config(state="disabled")
self._save_btn.config(state="disabled")
def _unlock_buttons(self):
self._test_btn.config(state="normal")
self._send_btn.config(state="normal")
self._save_btn.config(state="normal")
def _test(self): def _test(self):
result = self._get_fields() result = self._get_fields()
if result is None: if result is None:
return return
enabled, host, port, user, password, use_tls, recipients, send_time = result enabled, host, port, user, password, security, recipients, send_time = result
if not host or not user:
self._set_status("Please fill in host and username before testing.", "warning")
return
self._set_status("Testing SMTP connection...", "dim") self._set_status("Running diagnostics — please wait...", "dim")
self._test_btn.config(state="disabled") self._lock_buttons()
self._save_btn.config(state="disabled")
def _run(): def _run():
from utils.scheduler import test_smtp_connection from utils.scheduler import test_smtp_connection
ok, msg = test_smtp_connection(host, port, user, password, use_tls) ok, msg = test_smtp_connection(host, port, user, password, security)
level = "success" if ok else "danger" level = "success" if ok else "danger"
self.after(0, lambda: self._set_status(msg, level)) self.after(0, lambda: self._set_status(msg, level))
self.after(0, lambda: self._test_btn.config(state="normal")) self.after(0, self._unlock_buttons)
self.after(0, lambda: self._save_btn.config(state="normal"))
threading.Thread(target=_run, daemon=True).start()
def _send_test_email(self):
result = self._get_fields()
if result is None:
return
enabled, host, port, user, password, security, recipients, send_time = result
recip_list = [r.strip() for r in recipients.split(",") if r.strip()]
if not recip_list:
self._set_status("Please enter at least one recipient.", "danger")
return
if not host or not user:
self._set_status("Please fill in host and username before sending.", "warning")
return
self._set_status("Sending test email — please wait...", "dim")
self._lock_buttons()
def _run():
from utils.scheduler import send_test_email
ok, msg = send_test_email(host, port, user, password,
security, recip_list)
level = "success" if ok else "danger"
self.after(0, lambda: self._set_status(msg, level))
self.after(0, self._unlock_buttons)
threading.Thread(target=_run, daemon=True).start() threading.Thread(target=_run, daemon=True).start()
@@ -226,14 +315,15 @@ class EmailSettingsView(tk.Toplevel):
result = self._get_fields() result = self._get_fields()
if result is None: if result is None:
return return
enabled, host, port, user, password, use_tls, recipients, send_time = result enabled, host, port, user, password, security, recipients, send_time = result
from utils.scheduler import save_email_config from utils.scheduler import save_email_config
save_email_config(enabled, host, port, user, password, use_tls, save_email_config(enabled, host, port, user, password, security,
recipients, send_time) recipients, send_time)
from models import log_action from models import log_action
log_action(self.current_user["id"], "UPDATE_EMAIL_SETTINGS", "config", log_action(self.current_user["id"], "UPDATE_EMAIL_SETTINGS", "config",
None, f"Email reports {'enabled' if enabled else 'disabled'}.") None, f"Email reports {'enabled' if enabled else 'disabled'} "
f"security={security}.")
show_info("Email settings saved successfully.") show_info("Email settings saved successfully.")
logger.info(f"Email settings saved by {self.current_user['username']}.") logger.info(f"Email settings saved by {self.current_user['username']}.")
self.destroy() self.destroy()
+23 -14
View File
@@ -1,13 +1,12 @@
""" """
views/settings_view.py Database connection settings dialog. views/settings_view.py Database connection settings dialog.
Shown automatically on first run (no config.ini) and accessible Shown automatically on first run (no stored credentials) and accessible
via the admin sidebar Settings nav item at any time. via the admin sidebar Settings nav item at any time.
Writes connection details to config.ini via config.save_config(). Credentials are saved to the OS native keychain (Windows Credential Manager,
Does NOT store the password in plaintext beyond what config.ini holds macOS Keychain, or Linux Secret Service) via the keyring library.
(which is acceptable for a locally-run desktop tool; operators should No config.ini or local file is written the app is fully portable.
restrict file-system access to config.ini in production).
""" """
import tkinter as tk import tkinter as tk
@@ -42,7 +41,7 @@ class SettingsView(tk.Toplevel):
self.title("Database Setup" if first_run else "Connection Settings") self.title("Database Setup" if first_run else "Connection Settings")
self.configure(bg=COLOURS["bg"]) self.configure(bg=COLOURS["bg"])
self.resizable(False, False) self.resizable(False, True) # allow vertical resize for high-DPI
self.grab_set() self.grab_set()
if first_run: if first_run:
@@ -58,7 +57,9 @@ class SettingsView(tk.Toplevel):
def _centre(self): def _centre(self):
self.update_idletasks() self.update_idletasks()
w, h = 480, 480 # Height increased to 560 to ensure button row is always visible.
# Vertical resize allowed for high-DPI / large-font environments.
w, h = 480, 560
x = (self.winfo_screenwidth() - w) // 2 x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2 y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}") self.geometry(f"{w}x{h}+{x}+{y}")
@@ -77,6 +78,12 @@ class SettingsView(tk.Toplevel):
font=FONT_SMALL, bg=COLOURS["accent"], font=FONT_SMALL, bg=COLOURS["accent"],
fg=COLOURS["white"]).pack(pady=(2, 0)) fg=COLOURS["white"]).pack(pady=(2, 0))
# ── Buttons (packed before form so they anchor to bottom) ─────────────
# Packing the button row before the expanding form guarantees it is
# always visible even when the form content exceeds the window height.
btn_row = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=16)
btn_row.pack(side="bottom", fill="x")
# ── Form ────────────────────────────────────────────────────────────── # ── Form ──────────────────────────────────────────────────────────────
form = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=20) form = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=20)
form.pack(fill="both", expand=True) form.pack(fill="both", expand=True)
@@ -116,7 +123,8 @@ class SettingsView(tk.Toplevel):
# ── Hint ────────────────────────────────────────────────────────────── # ── Hint ──────────────────────────────────────────────────────────────
hint = ( hint = (
"Settings are saved to config.ini in the application folder.\n" "Credentials are saved to your OS keychain (Windows Credential Manager, "
"macOS Keychain, or Linux Secret Service) — no config.ini required.\n"
"Ensure the database user has CREATE, INSERT, UPDATE, DELETE privileges." "Ensure the database user has CREATE, INSERT, UPDATE, DELETE privileges."
) )
tk.Label(form, text=hint, bg=COLOURS["bg"], tk.Label(form, text=hint, bg=COLOURS["bg"],
@@ -124,10 +132,7 @@ class SettingsView(tk.Toplevel):
justify="left", wraplength=380).grid( justify="left", wraplength=380).grid(
row=6, column=0, columnspan=2, sticky="w", pady=(8, 0)) row=6, column=0, columnspan=2, sticky="w", pady=(8, 0))
# ── Buttons ─────────────────────────────────────────────────────────── # ── Buttons (btn_row already packed at top of _build_ui) ─────────────
btn_row = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=16)
btn_row.pack(fill="x")
self._save_btn = tk.Button( self._save_btn = tk.Button(
btn_row, text="Save & Connect", btn_row, text="Save & Connect",
command=self._save, command=self._save,
@@ -263,9 +268,13 @@ class SettingsView(tk.Toplevel):
) )
conn.close() conn.close()
from config import save_config from config import save_config, reload_db_config
save_config(host, port, database, user, password) save_config(host, port, database, user, password)
logger.info(f"Settings saved: {user}@{host}:{port}/{database}") # Immediately update the in-memory DB_CONFIG and reset the
# connection pool so the app connects with the new credentials
# without requiring a restart.
reload_db_config()
logger.info(f"Settings saved and applied: {user}@{host}:{port}/{database}")
self.after(0, self._on_save_success) self.after(0, self._on_save_success)
except Exception as e: except Exception as e:
+43 -10
View File
@@ -581,7 +581,15 @@ class UserDashboardView(ttk.Frame):
self._notify_job = self.after(60_000, self._schedule_notifications) self._notify_job = self.after(60_000, self._schedule_notifications)
def _check_notifications(self): def _check_notifications(self):
"""Fire a desktop notification if a shift ends within NOTIFY_MINUTES_BEFORE.""" """
Fire a single consolidated desktop notification listing ALL unchecked
sites whose shift ends within NOTIFY_MINUTES_BEFORE minutes.
Previously the loop fired one notification per site, which could
produce a burst of popups when multiple sites were unchecked.
Now one notification is fired per shift-end-time group, listing all
unchecked sites for that shift together.
"""
from models import get_unchecked_sites_for_user from models import get_unchecked_sites_for_user
import datetime as dt import datetime as dt
try: try:
@@ -590,6 +598,10 @@ class UserDashboardView(ttk.Frame):
return return
now = dt.datetime.now().time() now = dt.datetime.now().time()
# Group unchecked sites by their shift end_time so one notification
# covers all sites in the same shift.
groups = {} # end_t -> {"diff": int, "names": [str]}
for site in unchecked: for site in unchecked:
end_time = site.get("end_time") end_time = site.get("end_time")
if not end_time: if not end_time:
@@ -606,20 +618,41 @@ class UserDashboardView(ttk.Frame):
except Exception: except Exception:
continue continue
# Compute minutes until shift end
now_mins = now.hour * 60 + now.minute now_mins = now.hour * 60 + now.minute
end_mins = end_t.hour * 60 + end_t.minute end_mins = end_t.hour * 60 + end_t.minute
diff = end_mins - now_mins diff = end_mins - now_mins
key = (site["id"], end_t) if 0 < diff <= NOTIFY_MINUTES_BEFORE:
if 0 < diff <= NOTIFY_MINUTES_BEFORE and key not in self._notified_sites: if end_t not in groups:
self._notified_sites.add(key) groups[end_t] = {"diff": diff, "names": []}
self._fire_notification(site["name"], diff, end_t) groups[end_t]["names"].append(site["name"])
def _fire_notification(self, site_name: str, minutes_left: int, end_time): # Fire one notification per end-time group that has not been notified yet
title = "Shift Reminder" for end_t, info in groups.items():
message = (f"'{site_name}' is unchecked — " key = (frozenset(info["names"]), end_t)
f"shift ends at {end_time.strftime('%H:%M')} " if key in self._notified_sites:
continue
self._notified_sites.add(key)
self._fire_notification(info["names"], info["diff"], end_t)
def _fire_notification(self, site_names: list, minutes_left: int, end_time):
"""
Fire one consolidated desktop notification for all unchecked sites
in a shift group.
site_names: list of unchecked website names
"""
title = "Shift Reminder — Unchecked Sites"
count = len(site_names)
if count == 1:
body = f"{site_names[0]} is unchecked."
else:
# Show up to 3 names inline; append "+ N more" if longer
preview = ", ".join(site_names[:3])
body = (f"{preview}" if count <= 3
else f"{preview} + {count - 3} more")
body = f"{count} sites unchecked: {body}."
message = (f"{body} Shift ends at "
f"{end_time.strftime('%H:%M')} "
f"({minutes_left} min remaining).") f"({minutes_left} min remaining).")
logger.info(f"Notification: {message}") logger.info(f"Notification: {message}")