Compare commits
10
Commits
f4eea48e4d
...
727a422c63
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
727a422c63 | ||
|
|
71a53232a8 | ||
|
|
5d6ca5a039 | ||
|
|
f2a8d3f15b | ||
|
|
88b5e85c5b | ||
|
|
5f2a4e4749 | ||
|
|
07bae2a724 | ||
|
|
6ec439bafe | ||
|
|
262c71e93b | ||
|
|
58c6218c14 |
@@ -1,21 +1,23 @@
|
||||
# CLAUDE.md — Website Checker: Complete Technical Knowledge Base
|
||||
|
||||
This document captures the full architecture, design decisions, database schema, module inventory,
|
||||
feature set, and operational notes for the **Website Checker** desktop application.
|
||||
This document is the authoritative technical reference for the **Website Checker**
|
||||
desktop application. Keep it current whenever code changes are made.
|
||||
|
||||
---
|
||||
|
||||
## 1. Application Overview
|
||||
|
||||
Website Checker is a Python/Tkinter desktop application for shift-based website monitoring.
|
||||
Regular users log in, view websites assigned to their shift, open each site, and mark it checked.
|
||||
Administrators manage users, websites, shifts, credentials, and run reports.
|
||||
Website Checker is a Python/Tkinter desktop application for shift-based website
|
||||
monitoring backed by a remote MySQL database. Regular users log in, view websites
|
||||
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`
|
||||
- **Database:** Remote MySQL 5.7+ / MariaDB 10.3+ via `mysql-connector-python`
|
||||
- **Entry point:** `app.py` → `class App(tk.Tk)`
|
||||
- **Default window:** 1100×700, minimum 960×620
|
||||
- **Default theme:** Light (toggleable to Dark)
|
||||
- **Platform:** Windows desktop (primary); macOS/Linux supported with limitations
|
||||
|
||||
---
|
||||
|
||||
@@ -28,21 +30,23 @@ website_checker/
|
||||
├── models.py All database access (data layer)
|
||||
├── requirements.txt
|
||||
├── CLAUDE.md This file
|
||||
├── README.md Setup and deployment guide
|
||||
├── USER_MANUAL.md End-user documentation
|
||||
├── utils/
|
||||
│ ├── config_crypto.py Windows DPAPI encryption for config.ini credentials
|
||||
│ ├── crypto.py Fernet credential encryption (website passwords)
|
||||
│ ├── 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
|
||||
└── views/
|
||||
├── admin_dashboard_view.py Admin home — KPI cards + per-user progress table
|
||||
├── admin_log_view.py Activity log treeview
|
||||
├── admin_shifts_view.py Shift CRUD + PDF export
|
||||
├── admin_users_view.py User CRUD
|
||||
├── admin_shifts_view.py Shift CRUD + PDF export + inactive filter toggle
|
||||
├── admin_users_view.py User CRUD + secure password reset dialog
|
||||
├── 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)
|
||||
├── 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
|
||||
├── reports_view.py Shift Detail / Unchecked / Summary / Chart tabs
|
||||
├── settings_view.py DB connection settings dialog
|
||||
@@ -71,9 +75,11 @@ smtp_host=smtp.example.com
|
||||
smtp_port=587
|
||||
smtp_user=sender@example.com
|
||||
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
|
||||
send_time=18:00
|
||||
last_sent_date=2025-01-01 ; ISO date, prevents re-send after restart
|
||||
|
||||
[groq]
|
||||
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.
|
||||
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
|
||||
| Column | Type | Notes |
|
||||
@@ -111,7 +118,7 @@ Safe ALTER TABLE migrations run automatically for columns added post-deployment.
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO | |
|
||||
| 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 |
|
||||
| visibility | ENUM('all','assigned') | assigned = only users in website_users table |
|
||||
| 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.
|
||||
|
||||
### activity_log
|
||||
`action` codes: LOGIN, LOGOUT, SESSION_TIMEOUT, ACCOUNT_LOCKED, CREATE/UPDATE/DELETE_USER,
|
||||
CHANGE_PASSWORD, CHANGE_PASSWORD_FAIL, RESET_PASSWORD, CREATE/UPDATE/DELETE_WEBSITE,
|
||||
ADD/REMOVE_CREDENTIAL, CREATE/UPDATE/DELETE_SHIFT, CHECK_WEBSITE, UPDATE_NOTE,
|
||||
`action` codes: LOGIN, LOGOUT, SESSION_TIMEOUT, ACCOUNT_LOCKED,
|
||||
CREATE/UPDATE/DELETE_USER, CHANGE_PASSWORD, CHANGE_PASSWORD_FAIL, RESET_PASSWORD,
|
||||
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,
|
||||
CREATE/UPDATE/DELETE_AI_CRITERION.
|
||||
|
||||
@@ -146,15 +154,16 @@ E.g. "23456" = Mon–Fri. Queried with `LOCATE(DAYOFWEEK(CURDATE()), days_of_wee
|
||||
### website_users (junction): (website_id, user_id) PK — for visibility='assigned'
|
||||
|
||||
### 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 |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO | |
|
||||
| title | VARCHAR(200) | Short label |
|
||||
| description | TEXT | Full criterion text sent to the AI |
|
||||
| title | VARCHAR(200) | Short label shown in treeview |
|
||||
| description | TEXT | Full criterion text sent to the AI prompt |
|
||||
| is_active | TINYINT(1) | Inactive criteria excluded from AI prompt |
|
||||
| sort_order | INT | Controls display and prompt ordering |
|
||||
| created_by | INT FK users SET NULL | |
|
||||
| created_at / updated_at | DATETIME | |
|
||||
|
||||
### ai_analysis_log
|
||||
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
|
||||
|
||||
### 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
|
||||
- DB_CONFIG loaded from config.ini at import; placeholders if file missing
|
||||
- get_connection() → pool (pool_size=5)
|
||||
- initialize_database() → idempotent DDL + ALTER TABLE migrations
|
||||
- initialize_database() → idempotent DDL + ALTER TABLE migrations + table confirmation logs
|
||||
- load_config() / save_config() → decrypt/encrypt DPAPI fields transparently
|
||||
- migrate_plaintext_config() → one-time migration; encrypts any plain-text creds on startup
|
||||
- config_exists() → True if host, database, user are set
|
||||
- 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.
|
||||
- encrypt_value(plaintext) → "dpapi:<base64>" string
|
||||
- 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
|
||||
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
|
||||
- PBKDF2-HMAC-SHA256, 100,000 iterations, 32-byte salt from config.ini [crypto]
|
||||
- encrypt(plaintext) → "enc:" + base64(Fernet token)
|
||||
@@ -218,64 +243,97 @@ Password strength: PW_MIN_LENGTH=8, requires upper + digit + special char
|
||||
### utils/scheduler.py
|
||||
- Daemon thread; polls every 60 seconds
|
||||
- Sends HTML email once per day when now >= send_time
|
||||
- smtp_password is DPAPI-encrypted on save (encrypt_value) / decrypted on load (decrypt_value)
|
||||
- last_sent_date in-memory (resets on restart)
|
||||
- start() / stop() called from app.py on login/logout (admin only)
|
||||
- smtp_password DPAPI-encrypted on save / decrypted on load
|
||||
- last_sent_date persisted to config.ini [email] last_sent_date — survives restarts
|
||||
- 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)*
|
||||
AI-powered document analysis panel. Accessible from the sidebar for both admin and user roles.
|
||||
### views/email_settings_view.py
|
||||
- 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:
|
||||
- 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)
|
||||
- Regular users use the stored API key transparently
|
||||
- Groq API key and model selection visible only to admins
|
||||
- Regular users use stored API key transparently
|
||||
- Background thread for AI calls (prevents UI freeze)
|
||||
- Extraction prompt focused on procurement/solicitation fields:
|
||||
Solicitation Number/Type, Set-Aside, Description, Work Site, Pre-Proposal Conference,
|
||||
POC, Square Footage, Driving Distance from office (2815 Hartland Rd, Falls Church VA),
|
||||
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
|
||||
- Notebook layout: ✨ Analyze tab + 🕑 History tab
|
||||
- Extraction prompt: 12 labeled fields per document + 4-section Overall Summary
|
||||
- Driving distance calculated from 2815 Hartland Rd, Falls Church VA (AI estimate)
|
||||
- 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)
|
||||
2. docx2txt (pure Python)
|
||||
3. Raw ASCII scrape from binary
|
||||
|
||||
URL safety: _validate_and_normalise_url() rejects non-http/https schemes.
|
||||
|
||||
### views/admin_websites_view.py
|
||||
- Website CRUD dialog now has a **collapsible credentials section** (hidden by default)
|
||||
- "🔑 Show Credentials" / "🔒 Hide Credentials" toggle button
|
||||
- Auto-expands when editing a site that already has saved credentials
|
||||
- "+ Add Credential" auto-expands the section if collapsed
|
||||
- **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`
|
||||
- Website CRUD dialog with collapsible credentials section (hidden by default)
|
||||
- _on_visibility_change uses before=self._cred_body (not creds_container — same parent)
|
||||
- _validate_and_normalise_url() called in _save() before DB write
|
||||
- URL validation: prepends https:// if no scheme; rejects javascript:/data:/file: etc.
|
||||
|
||||
### 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
|
||||
Key internals:
|
||||
- _health_cache: {website_id: ("ok"|"slow"|"down", ms)} — daemon threads, HEAD request, 6s timeout
|
||||
- Dot colours: green (<=3000ms), amber (>3000ms), red (error)
|
||||
- Mousewheel: Enter/Leave scoped; unbind_all in destroy()
|
||||
- Keyboard shortcuts: self.bind() stored in _shortcut_ids; unbound in destroy()
|
||||
- Notifications: after(60_000) loop; plyer first, fallback to borderless Toplevel toast
|
||||
- **Credentials popup no longer opens automatically on link click**
|
||||
- "🔑 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
|
||||
(2s flash + clipboard auto-cleared after 15s for security); 👁 toggle to reveal password
|
||||
- _health_cache: {website_id: ("ok"|"slow"|"restricted"|"down", ms)}
|
||||
- ok: 2xx/3xx ≤3000ms; slow: 2xx/3xx >3000ms; restricted: 4xx; down: 5xx/network error
|
||||
- Notifications: single consolidated reminder per shift end-time group (not per site)
|
||||
- Groups unchecked sites by end_time; fires one notification listing all sites
|
||||
- _notified_sites key: (frozenset(names), end_t)
|
||||
- Keyboard shortcuts: stored in _shortcut_ids; unbound in destroy()
|
||||
- Credentials popup: 📋 copy (username 2s flash; password 2s flash + 15s clipboard wipe)
|
||||
|
||||
### views/reports_view.py
|
||||
- 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
|
||||
- check_login_allowed(username) called before authenticate()
|
||||
- Locked: form disabled; 1-second countdown; re-enables when timer reaches 0
|
||||
- Not-yet-locked failed attempt: shows "N attempt(s) remaining before lockout"
|
||||
|
||||
### views/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
|
||||
@@ -287,10 +345,14 @@ Key internals:
|
||||
| Config credential protection | Windows DPAPI (CryptProtectData) — user-account-scoped |
|
||||
| Encrypted fields | DB user, DB password, SMTP password, Groq API key |
|
||||
| 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 |
|
||||
| Self-service change | Requires current password; strength meter; same-as-current guard |
|
||||
| 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
|
||||
`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
|
||||
- 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
|
||||
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)
|
||||
|
||||
### 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
|
||||
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 | APP_VERSION | "1.0.0" |
|
||||
| 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 | LOCKOUT_MINUTES | 15 |
|
||||
| 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 |
|
||||
| ai_summary_view.py | MAX_CHARS_PER_FILE | 14,000 |
|
||||
| ai_summary_view.py | _OFFICE_ADDRESS | "2815 Hartland Road, Falls Church, VA 22043, USA" |
|
||||
| ai_summary_view.py | CriterionDialog._DESC_SOFT_LIMIT | 500 (chars, soft guidance only) |
|
||||
| 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).
|
||||
|
||||
7. The email scheduler `last_sent_date` is now persisted to `config.ini`
|
||||
`[email] last_sent_date` (ISO format). A restart after the configured
|
||||
`send_time` will not re-send the report on the same day.
|
||||
7. The email scheduler last_sent_date is persisted to config.ini [email] last_sent_date
|
||||
(ISO format). A restart after the configured send_time will not re-send that day.
|
||||
|
||||
8. All log messages use ASCII only (hyphens not em-dashes/arrows) to prevent
|
||||
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
|
||||
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).
|
||||
Avoid having hundreds of sites with credentials as this adds DB round-trips per render.
|
||||
10. Theme toggle destroys and rebuilds the entire shell. Any unsaved in-memory
|
||||
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
|
||||
```
|
||||
|
||||
stdlib used: tkinter, csv, smtplib, urllib.request, configparser, threading, calendar,
|
||||
datetime, base64, re, tempfile
|
||||
stdlib used: tkinter, csv, smtplib, urllib.request, urllib.parse, configparser,
|
||||
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.
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
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
|
||||
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
|
||||
- llama-3.3-70b-versatile (default, recommended)
|
||||
- llama-3.1-8b-instant
|
||||
- llama-3.1-8b-instant (faster, smaller)
|
||||
- gemma2-9b-it
|
||||
- mixtral-8x7b-32768
|
||||
|
||||
### Prompt Strategy
|
||||
The prompt instructs the AI to extract 12 labeled fields per document:
|
||||
Solicitation Number, Type, Set-Aside, Description/Scope, Work Site, Pre-Proposal Conference,
|
||||
POC, Square Footage, **Driving Distance from 2815 Hartland Rd Falls Church VA** (calculated by AI),
|
||||
Extraction: 12 labeled fields per document:
|
||||
Solicitation Number, Type, Set-Aside, Description/Scope, Work Site,
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
@@ -1,136 +1,192 @@
|
||||
# Website Checker — Desktop App
|
||||
# Website Checker
|
||||
|
||||
A Tkinter-based desktop application for shift-based website monitoring,
|
||||
backed by a remote MySQL database.
|
||||
A Windows desktop application for shift-based website monitoring.
|
||||
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/
|
||||
├── app.py ← Entry point & main application shell
|
||||
├── config.py ← DB config, connection pool, schema init
|
||||
├── models.py ← All database access (CRUD + logging)
|
||||
├── app.py
|
||||
├── config.py
|
||||
├── models.py
|
||||
├── requirements.txt
|
||||
├── utils/
|
||||
│ └── ui_helpers.py ← Theme, colour constants, reusable widgets
|
||||
└── 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
### 2. Install dependencies
|
||||
|
||||
```bash
|
||||
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
|
||||
DB_CONFIG = {
|
||||
"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
|
||||
}
|
||||
```sql
|
||||
CREATE DATABASE website_checker CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
> **Important:** Create the database on your MySQL server first:
|
||||
> ```sql
|
||||
> CREATE DATABASE website_checker CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
> ```
|
||||
Grant a dedicated user full access to this database.
|
||||
|
||||
### 3. Run the application
|
||||
### 4. Run the application
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
On first launch, the app automatically creates all required tables and seeds a
|
||||
default admin account:
|
||||
On first launch, a **Database Settings** dialog appears. Enter your MySQL host,
|
||||
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 |
|
||||
|----------|------------|
|
||||
| Username | `admin` |
|
||||
| Password | `admin123` |
|
||||
|
||||
**Change the admin password immediately after first login.**
|
||||
**Change this password immediately** via the sidebar → Change Password.
|
||||
|
||||
---
|
||||
|
||||
## Feature Overview
|
||||
|
||||
### Admin Role
|
||||
### For Regular Users
|
||||
|
||||
| Feature | Description |
|
||||
|---|---|
|
||||
| User Management | Create, edit, deactivate, and delete users; assign admin or regular role |
|
||||
| Website Management | Add sites with name, URL, multiple login credentials, and notes |
|
||||
| Activity Log | View a chronological audit trail of all create/edit/delete/login events |
|
||||
| Shift Dashboard | Admins can also use the shift checklist like regular users |
|
||||
| **My Shift checklist** | View all websites assigned to your shift for today |
|
||||
| **Site health indicator** | Colour dot shows if site is reachable (green/amber/red) |
|
||||
| **One-click check-off** | Click the checkbox or press Space to mark a site checked |
|
||||
| **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 |
|
||||
|---|---|
|
||||
| Login | Secure username/password login |
|
||||
| Shift Checklist | See all active websites; click URL to open in browser; check off each site |
|
||||
| Credentials Popup | Clicking a site shows its stored login credentials with a password reveal toggle |
|
||||
| Notes | Write or update a per-site note for each shift day |
|
||||
| Progress Bar | Visual indicator of how many sites have been checked this shift |
|
||||
| **Dashboard** | KPI cards (users online, sites checked today, completion %) + per-user progress table |
|
||||
| **Website Management** | Add/edit/delete websites; set visibility (all users or assigned only); store credentials |
|
||||
| **User Management** | Create/edit/deactivate/delete users; assign roles; reset passwords securely |
|
||||
| **Shift Management** | Create/edit shifts with days-of-week, start/end times, assigned users and websites |
|
||||
| **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 |
|
||||
| `CREATE_USER` | Admin creates a new user |
|
||||
| `UPDATE_USER` | Admin edits a user |
|
||||
| `DELETE_USER` | Admin deletes a user |
|
||||
| `CREATE_WEBSITE` | Admin adds a website |
|
||||
| `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.
|
||||
| `app.py` | Entry point — run this |
|
||||
| `config.ini` | Auto-created; stores DB/SMTP/Groq settings (encrypted) |
|
||||
| `app.log` | Application log — check here when troubleshooting |
|
||||
| `CLAUDE.md` | Full technical reference for developers |
|
||||
| `USER_MANUAL.md` | Step-by-step guide for end users |
|
||||
|
||||
---
|
||||
|
||||
## Database Schema (auto-created on first run)
|
||||
## Troubleshooting
|
||||
|
||||
- `users` — application accounts with role-based access
|
||||
- `websites` — monitored sites
|
||||
- `website_credentials` — multiple username/password pairs per site
|
||||
- `shift_checks` — one record per user per site per day
|
||||
- `activity_log` — full audit trail
|
||||
| Symptom | Resolution |
|
||||
|---|---|
|
||||
| "Cannot connect to database" | Verify host/port/credentials in Settings; check MySQL firewall |
|
||||
| "SMTP error: Connection unexpectedly closed" | Wrong security mode — try SSL/TLS on port 465 |
|
||||
| "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.
|
||||
- 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.
|
||||
| Username | Password |
|
||||
|---|---|
|
||||
| `admin` | `admin123` |
|
||||
|
||||
Change immediately after first login.
|
||||
+689
@@ -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 (0–9)
|
||||
- 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.*
|
||||
@@ -65,9 +65,24 @@ class App(tk.Tk):
|
||||
SettingsView(self, on_save_callback=on_complete, first_run=True)
|
||||
|
||||
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:
|
||||
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:
|
||||
messagebox.showerror(
|
||||
"Database Error",
|
||||
@@ -79,7 +94,8 @@ class App(tk.Tk):
|
||||
# Re-open setup so the user can correct the credentials
|
||||
self._show_setup(on_complete=self._init_db)
|
||||
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:
|
||||
from config import migrate_plaintext_config
|
||||
migrate_plaintext_config()
|
||||
@@ -124,9 +140,46 @@ class App(tk.Tk):
|
||||
child.destroy()
|
||||
|
||||
# ── Sidebar ──────────────────────────────────────────────────────────
|
||||
sidebar = tk.Frame(self, bg=COLOURS["surface"], width=200)
|
||||
sidebar.pack(side="left", fill="y")
|
||||
sidebar.pack_propagate(False)
|
||||
# The sidebar outer frame holds the fixed-width column.
|
||||
# Inside it we place a Canvas + Scrollbar so that the nav items are
|
||||
# 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
|
||||
brand = tk.Frame(sidebar, bg=COLOURS["surface"], pady=20)
|
||||
@@ -148,26 +201,37 @@ class App(tk.Tk):
|
||||
self._nav_buttons = {}
|
||||
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":
|
||||
nav_items = [
|
||||
("🏠 Dashboard", "dashboard", self._show_dashboard),
|
||||
("👤 Users", "users", self._show_users),
|
||||
("🌐 Websites", "websites", self._show_websites),
|
||||
("🗓 Shifts", "shift_mgmt", self._show_shift_mgmt),
|
||||
("📋 Activity Log", "log", self._show_log),
|
||||
("📊 My Shifts", "shifts", self._show_shifts),
|
||||
("📑 Reports", "reports", self._show_reports),
|
||||
("📧 Email Reports","email", self._show_email_settings),
|
||||
("🤖 AI Summary", "ai_summary", self._show_ai_summary),
|
||||
("⚙ Settings", "settings", self._show_settings),
|
||||
nav_groups = [
|
||||
("MANAGEMENT", [
|
||||
("🏠 Dashboard", "dashboard", self._show_dashboard),
|
||||
("👤 Users", "users", self._show_users),
|
||||
("🌐 Websites", "websites", self._show_websites),
|
||||
("🗓 Shifts", "shift_mgmt", self._show_shift_mgmt),
|
||||
("📑 Reports", "reports", self._show_reports),
|
||||
("📋 Activity Log", "log", self._show_log),
|
||||
("📧 Email Reports", "email", self._show_email_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:
|
||||
nav_items = [
|
||||
("📊 My Shifts", "shifts", self._show_shifts),
|
||||
("🤖 AI Summary", "ai_summary", self._show_ai_summary),
|
||||
nav_groups = [
|
||||
(None, [
|
||||
("📊 My Shifts", "shifts", self._show_shifts),
|
||||
("🤖 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(
|
||||
sidebar,
|
||||
text=label,
|
||||
@@ -180,12 +244,34 @@ class App(tk.Tk):
|
||||
anchor="w",
|
||||
font=FONT_BOLD,
|
||||
padx=20,
|
||||
pady=12,
|
||||
pady=10,
|
||||
cursor="hand2",
|
||||
)
|
||||
btn.pack(fill="x")
|
||||
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
|
||||
ttk.Separator(sidebar, orient="horizontal").pack(fill="x", side="bottom", pady=8)
|
||||
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
|
||||
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):
|
||||
"""Called after settings are saved — reload config and reconnect."""
|
||||
from config import reload_db_config
|
||||
@@ -523,8 +615,9 @@ class App(tk.Tk):
|
||||
"shifts": self._show_shifts,
|
||||
"reports": self._show_reports,
|
||||
"email": self._show_email_settings,
|
||||
"ai_summary": self._show_ai_summary,
|
||||
"settings": self._show_settings,
|
||||
"ai_summary": self._show_ai_summary,
|
||||
"bid_tracker": self._show_bid_tracker,
|
||||
"settings": self._show_settings,
|
||||
}
|
||||
if active in nav_map:
|
||||
nav_map[active]()
|
||||
@@ -564,4 +657,4 @@ class App(tk.Tk):
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = App()
|
||||
app.mainloop()
|
||||
app.mainloop()
|
||||
+9
-6
@@ -27,16 +27,16 @@ Output
|
||||
|
||||
After Building
|
||||
--------------
|
||||
1. Copy config.ini into the dist/WebsiteChecker/ folder before
|
||||
distributing to users, OR let users complete the first-run
|
||||
database setup dialog on first launch.
|
||||
1. No config.ini required. DB credentials are stored in the OS keychain
|
||||
(Windows Credential Manager on Windows, Keychain on macOS).
|
||||
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.
|
||||
3. The bundled app requires network access to the MySQL server
|
||||
on the configured port (default 3306).
|
||||
|
||||
Prerequisites
|
||||
-------------
|
||||
pip install pyinstaller
|
||||
pip install pyinstaller keyring
|
||||
pip install -r requirements.txt
|
||||
"""
|
||||
|
||||
@@ -102,6 +102,7 @@ def check_environment():
|
||||
"bcrypt": "bcrypt",
|
||||
"openpyxl": "openpyxl",
|
||||
"cryptography": "cryptography",
|
||||
"keyring": "keyring",
|
||||
"matplotlib": "matplotlib",
|
||||
"plyer": "plyer",
|
||||
"reportlab": "reportlab",
|
||||
@@ -255,8 +256,10 @@ def report(args, returncode):
|
||||
|
||||
print()
|
||||
print(" Post-build checklist:")
|
||||
print(" [ ] Copy config.ini into the output folder (or let users")
|
||||
print(" complete the first-run setup dialog on launch)")
|
||||
print(" [ ] NO config.ini required — DB credentials are stored in the")
|
||||
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(" [ ] Verify the MySQL connection works from the target machine")
|
||||
print(" [ ] Confirm app.log is written next to the .exe at runtime")
|
||||
|
||||
-24
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -308,7 +308,7 @@ def get_all_users():
|
||||
try:
|
||||
conn = get_connection()
|
||||
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()
|
||||
cur.close()
|
||||
return rows
|
||||
@@ -322,7 +322,7 @@ def get_user_by_id(user_id: int):
|
||||
try:
|
||||
conn = get_connection()
|
||||
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()
|
||||
cur.close()
|
||||
return row
|
||||
@@ -331,40 +331,40 @@ def get_user_by_id(user_id: int):
|
||||
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
|
||||
try:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, password, role, full_name) VALUES (%s,%s,%s,%s)",
|
||||
(username, _hash_password(password), role, full_name)
|
||||
"INSERT INTO users (username, password, role, full_name, email) VALUES (%s,%s,%s,%s,%s)",
|
||||
(username, _hash_password(password), role, full_name, email or None)
|
||||
)
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
cur.close()
|
||||
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
|
||||
finally:
|
||||
if conn:
|
||||
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
|
||||
try:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
if password:
|
||||
cur.execute(
|
||||
"UPDATE users SET username=%s, role=%s, full_name=%s, is_active=%s, password=%s WHERE id=%s",
|
||||
(username, role, full_name, is_active, _hash_password(password), user_id)
|
||||
"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, email or None, _hash_password(password), user_id)
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"UPDATE users SET username=%s, role=%s, full_name=%s, is_active=%s WHERE id=%s",
|
||||
(username, role, full_name, is_active, user_id)
|
||||
"UPDATE users SET username=%s, role=%s, full_name=%s, is_active=%s, email=%s WHERE id=%s",
|
||||
(username, role, full_name, is_active, email or None, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
@@ -445,6 +445,42 @@ def get_all_websites():
|
||||
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):
|
||||
conn = None
|
||||
try:
|
||||
@@ -805,20 +841,47 @@ def update_check_note(user_id, website_id, user_note):
|
||||
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
|
||||
try:
|
||||
conn = get_connection()
|
||||
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
|
||||
LEFT JOIN users u ON u.id = al.user_id
|
||||
{where}
|
||||
ORDER BY al.logged_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,)
|
||||
params,
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
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
|
||||
user_filter_params = [user_id] if user_id else []
|
||||
|
||||
# The subquery needs: dow_params, (optional user_id for shift_users join)
|
||||
# Outer query needs: date_params, user_filter_params, date_params
|
||||
# EXISTS(shift) subquery: dow_params + (user_id if filtering by user)
|
||||
# We build params carefully to match the f-string placeholders below.
|
||||
# Fix for Bug #1: MySQL does not allow a derived table (subquery in FROM/JOIN)
|
||||
# to reference outer-query aliases (e.g. u.id). The previous approach used
|
||||
# "AND su.user_id = u.id" inside a derived table when user_id=None, which
|
||||
# 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 = (
|
||||
date_params # {date_expr} in SELECT
|
||||
+ user_filter_params # {user_filter} AND u.id = %s
|
||||
+ 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}
|
||||
)
|
||||
|
||||
@@ -948,29 +1022,27 @@ def get_unchecked_report(target_date=None, user_id=None):
|
||||
w.url,
|
||||
'Not Checked' AS status
|
||||
FROM users u
|
||||
-- Only websites the user was expected to check on this date
|
||||
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
|
||||
JOIN websites w ON w.is_active = 1
|
||||
WHERE u.is_active = 1
|
||||
AND u.role = 'user'
|
||||
{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
|
||||
AND NOT EXISTS (
|
||||
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):
|
||||
"""
|
||||
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
|
||||
"""
|
||||
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 ""
|
||||
|
||||
# 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(
|
||||
f"""
|
||||
WITH agg AS (
|
||||
SELECT
|
||||
DATE(sc.checked_at) AS check_date,
|
||||
sc.user_id,
|
||||
COUNT(DISTINCT sc.website_id) AS checked_count
|
||||
FROM shift_checks sc
|
||||
{where_clause}
|
||||
GROUP BY DATE(sc.checked_at), sc.user_id
|
||||
)
|
||||
SELECT
|
||||
DATE(sc.checked_at) AS check_date,
|
||||
agg.check_date,
|
||||
u.username,
|
||||
COALESCE(u.full_name, u.username) AS full_name,
|
||||
COUNT(DISTINCT sc.website_id) AS checked_count,
|
||||
(SELECT COUNT(*) FROM websites WHERE is_active=1) AS total_sites,
|
||||
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(
|
||||
COUNT(DISTINCT sc.website_id) * 100.0 /
|
||||
NULLIF((SELECT COUNT(*) FROM websites WHERE is_active=1), 0),
|
||||
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 shift_checks sc
|
||||
JOIN users u ON u.id = sc.user_id
|
||||
{where_clause}
|
||||
GROUP BY DATE(sc.checked_at), sc.user_id
|
||||
ORDER BY check_date DESC, u.username
|
||||
) AS pct_complete
|
||||
FROM agg
|
||||
JOIN users u ON u.id = agg.user_id
|
||||
ORDER BY agg.check_date DESC, u.username
|
||||
""",
|
||||
params
|
||||
)
|
||||
@@ -1430,17 +1551,22 @@ def get_unchecked_sites_for_user(user_id: int):
|
||||
# ─── AI Criteria CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
def get_all_criteria():
|
||||
"""Return all AI evaluation criteria ordered by sort_order, then id."""
|
||||
"""Return all AI evaluation criteria ordered by sort_order, then id.
|
||||
|
||||
The creator username is intentionally omitted — the criteria treeview does
|
||||
not display it and the LEFT JOIN was adding a needless per-call cost.
|
||||
If a creator column is ever added to the UI, restore the JOIN here.
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor(dictionary=True)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT c.*, u.username AS creator
|
||||
FROM ai_criteria c
|
||||
LEFT JOIN users u ON u.id = c.created_by
|
||||
ORDER BY c.sort_order, c.id
|
||||
SELECT id, title, description, is_active, sort_order,
|
||||
created_by, created_at, updated_at
|
||||
FROM ai_criteria
|
||||
ORDER BY sort_order, id
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
@@ -1656,3 +1782,493 @@ def get_ai_analysis_detail(analysis_id: int):
|
||||
finally:
|
||||
if conn:
|
||||
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()
|
||||
@@ -10,3 +10,4 @@ pypdf>=3.0.0
|
||||
python-docx>=1.0.0
|
||||
pywin32>=306
|
||||
docx2txt>=0.8
|
||||
keyring
|
||||
|
||||
+110
-22
@@ -2,14 +2,18 @@
|
||||
utils/crypto.py — Fernet symmetric encryption for website credentials.
|
||||
|
||||
Key derivation:
|
||||
- A 32-byte random salt is generated on first use and stored in config.ini
|
||||
under [crypto] / salt.
|
||||
- A 32-byte random salt is generated on first use and stored in the
|
||||
app_settings table (key: 'crypto.salt') instead of config.ini.
|
||||
- The Fernet key is derived from the salt + a fixed application secret
|
||||
using PBKDF2-HMAC-SHA256 (100,000 iterations).
|
||||
- This means credentials are tied to the specific config.ini file on the
|
||||
operator's machine; moving config.ini to another machine retains access.
|
||||
- Because the salt lives in the shared MySQL database, any machine that
|
||||
connects to the same DB can decrypt credentials without needing a local
|
||||
config.ini — making the app fully portable across machines.
|
||||
|
||||
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
|
||||
unchanged so that plaintext legacy credentials are still readable.
|
||||
- Callers should re-encrypt on next write (update_website handles this).
|
||||
@@ -18,7 +22,6 @@ Migration:
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import configparser
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
@@ -27,30 +30,116 @@ from cryptography.hazmat.primitives import hashes
|
||||
logger = logging.getLogger("crypto")
|
||||
|
||||
_APP_SECRET = b"WebsiteChecker-v1-CredentialKey"
|
||||
_CONFIG_FILE = "config.ini"
|
||||
_ITERATIONS = 100_000
|
||||
_SETTING_KEY = "crypto.salt"
|
||||
_fernet: "Fernet | None" = None
|
||||
|
||||
|
||||
# ─── 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:
|
||||
"""Read salt from config.ini [crypto] section; create and persist if absent."""
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(_CONFIG_FILE, encoding="utf-8")
|
||||
"""
|
||||
Read the Fernet salt from app_settings, falling back to config.ini for
|
||||
legacy installs, and generating a fresh salt for brand-new installs.
|
||||
|
||||
if "crypto" in cfg and cfg["crypto"].get("salt"):
|
||||
return base64.b64decode(cfg["crypto"]["salt"])
|
||||
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
|
||||
|
||||
# Generate a fresh 32-byte salt
|
||||
salt = os.urandom(32)
|
||||
if "crypto" not in cfg:
|
||||
cfg["crypto"] = {}
|
||||
cfg["crypto"]["salt"] = base64.b64encode(salt).decode("ascii")
|
||||
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.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}")
|
||||
|
||||
with open(_CONFIG_FILE, "w", encoding="utf-8") as fh:
|
||||
cfg.write(fh)
|
||||
logger.info("Crypto: generated and persisted new credential encryption salt.")
|
||||
# ── Step 2: ensure app_settings table exists ──────────────────────────────
|
||||
table_ok = _ensure_app_settings_table()
|
||||
|
||||
# ── 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)
|
||||
b64_salt = base64.b64encode(salt).decode("ascii")
|
||||
if table_ok:
|
||||
try:
|
||||
from config import set_setting
|
||||
set_setting(_SETTING_KEY, b64_salt)
|
||||
logger.info("Crypto: generated and persisted new salt to app_settings.")
|
||||
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
|
||||
|
||||
|
||||
@@ -74,7 +163,7 @@ def _get_fernet() -> 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
|
||||
_fernet = None
|
||||
|
||||
@@ -107,7 +196,6 @@ def decrypt(ciphertext: str) -> str:
|
||||
if not ciphertext:
|
||||
return ciphertext
|
||||
if not ciphertext.startswith("enc:"):
|
||||
# Legacy plaintext — return unchanged; will be re-encrypted on next save
|
||||
return ciphertext
|
||||
try:
|
||||
token = ciphertext[4:].encode("ascii")
|
||||
@@ -122,4 +210,4 @@ def decrypt(ciphertext: str) -> str:
|
||||
|
||||
def is_encrypted(value: str) -> bool:
|
||||
"""Return True if the value was produced by encrypt()."""
|
||||
return isinstance(value, str) and value.startswith("enc:")
|
||||
return isinstance(value, str) and value.startswith("enc:")
|
||||
+232
-92
@@ -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
|
||||
summary report via SMTP if it hasn't been sent yet.
|
||||
|
||||
Configuration (config.ini [email] section):
|
||||
enabled = true/false
|
||||
smtp_host = smtp.example.com
|
||||
smtp_port = 587
|
||||
smtp_user = sender@example.com
|
||||
smtp_password= secret
|
||||
use_tls = true
|
||||
recipients = admin@example.com, manager@example.com
|
||||
send_time = 18:00 (24-hour HH:MM, local time)
|
||||
Configuration is stored in the app_settings database table (not config.ini):
|
||||
|
||||
Key Description
|
||||
─────────────────────── ────────────────────────────────────────────
|
||||
email.enabled 'true' or 'false'
|
||||
email.smtp_host SMTP server hostname
|
||||
email.smtp_port SMTP port number (string)
|
||||
email.smtp_user Sender email address / SMTP login
|
||||
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 stop() on logout/shutdown.
|
||||
"""
|
||||
|
||||
import configparser
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import smtplib
|
||||
import threading
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formatdate, make_msgid, formataddr
|
||||
|
||||
logger = logging.getLogger("scheduler")
|
||||
|
||||
CONFIG_FILE = "config.ini"
|
||||
_scheduler_thread: "threading.Thread | None" = None
|
||||
_stop_event = threading.Event()
|
||||
|
||||
@@ -38,61 +40,184 @@ _stop_event = threading.Event()
|
||||
# ─── Config helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def load_email_config() -> dict:
|
||||
from utils.config_crypto import decrypt_value
|
||||
cfg = configparser.ConfigParser()
|
||||
if not os.path.exists(CONFIG_FILE):
|
||||
return {}
|
||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
||||
if "email" not in cfg:
|
||||
return {}
|
||||
s = cfg["email"]
|
||||
"""Load email settings from app_settings table."""
|
||||
from config import get_settings_dict
|
||||
from utils.crypto import decrypt
|
||||
s = get_settings_dict("email.")
|
||||
security = s.get("email.security", "")
|
||||
if not security:
|
||||
# Legacy: fall back from boolean use_tls if security key absent
|
||||
use_tls = s.get("email.use_tls", "true").lower() == "true"
|
||||
security = "starttls" if use_tls else "none"
|
||||
return {
|
||||
"enabled": s.getboolean("enabled", fallback=False),
|
||||
"smtp_host": s.get("smtp_host", ""),
|
||||
"smtp_port": s.getint("smtp_port", fallback=587),
|
||||
"smtp_user": s.get("smtp_user", ""),
|
||||
"smtp_password": decrypt_value(s.get("smtp_password", "")),
|
||||
"use_tls": s.getboolean("use_tls", fallback=True),
|
||||
"recipients": [r.strip() for r in s.get("recipients", "").split(",") if r.strip()],
|
||||
"send_time": s.get("send_time", "18:00"),
|
||||
"enabled": s.get("email.enabled", "false").lower() == "true",
|
||||
"smtp_host": s.get("email.smtp_host", ""),
|
||||
"smtp_port": int(s.get("email.smtp_port", "587") or "587"),
|
||||
"smtp_user": s.get("email.smtp_user", ""),
|
||||
"smtp_password": decrypt(s.get("email.smtp_password", "")),
|
||||
"security": security,
|
||||
"use_tls": security == "starttls",
|
||||
"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,
|
||||
smtp_user: str, smtp_password: str, use_tls: bool,
|
||||
smtp_user: str, smtp_password: str, security: str,
|
||||
recipients: str, send_time: str):
|
||||
from utils.config_crypto import encrypt_value
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
||||
cfg["email"] = {
|
||||
"enabled": str(enabled).lower(),
|
||||
"smtp_host": smtp_host,
|
||||
"smtp_port": str(smtp_port),
|
||||
"smtp_user": smtp_user,
|
||||
"smtp_password": encrypt_value(smtp_password),
|
||||
"use_tls": str(use_tls).lower(),
|
||||
"recipients": recipients,
|
||||
"send_time": send_time,
|
||||
"""Persist email settings to app_settings table."""
|
||||
from config import get_setting, set_setting
|
||||
from utils.crypto import encrypt
|
||||
# Preserve last_sent_date — do not overwrite it on a normal save
|
||||
last_sent = get_setting("email.last_sent_date", "")
|
||||
pairs = {
|
||||
"email.enabled": str(enabled).lower(),
|
||||
"email.smtp_host": smtp_host,
|
||||
"email.smtp_port": str(smtp_port),
|
||||
"email.smtp_user": smtp_user,
|
||||
"email.smtp_password": encrypt(smtp_password),
|
||||
"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:
|
||||
cfg.write(fh)
|
||||
logger.info("Email configuration saved (password encrypted).")
|
||||
for k, v in pairs.items():
|
||||
set_setting(k, v)
|
||||
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.
|
||||
Returns (success: bool, message: str).
|
||||
Open and return a ready-to-login SMTP connection.
|
||||
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.
|
||||
"""
|
||||
if security == "ssl":
|
||||
server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=15)
|
||||
server.ehlo()
|
||||
elif security == "starttls":
|
||||
server = smtplib.SMTP(smtp_host, smtp_port, timeout=15)
|
||||
server.ehlo()
|
||||
server.starttls()
|
||||
server.ehlo()
|
||||
else:
|
||||
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:
|
||||
if use_tls:
|
||||
server = smtplib.SMTP(smtp_host, smtp_port, timeout=8)
|
||||
server.starttls()
|
||||
else:
|
||||
server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=8)
|
||||
server.login(smtp_user, smtp_password)
|
||||
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:
|
||||
return False, str(e)
|
||||
|
||||
@@ -111,7 +236,12 @@ def _build_html_report() -> str:
|
||||
rows = stats.get("user_stats", [])
|
||||
|
||||
table_rows = ""
|
||||
skipped_no_shift = 0
|
||||
for r in rows:
|
||||
total = int(r.get("total_sites") or 0)
|
||||
if total == 0:
|
||||
skipped_no_shift += 1
|
||||
continue
|
||||
pct = float(r.get("pct_complete") or 0)
|
||||
color = "#388e3c" if pct >= 100 else "#f57c00" if pct > 0 else "#d32f2f"
|
||||
table_rows += (
|
||||
@@ -119,11 +249,21 @@ def _build_html_report() -> str:
|
||||
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;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"color:{color};font-weight:bold'>{pct:.0f}%</td>"
|
||||
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"""
|
||||
<html><body style="font-family:Segoe UI,Arial,sans-serif;color:#1a1a2e">
|
||||
@@ -142,6 +282,7 @@ def _build_html_report() -> str:
|
||||
</thead>
|
||||
<tbody>{table_rows}</tbody>
|
||||
</table>
|
||||
{no_shift_note}
|
||||
<p style="color:#6b6b80;font-size:12px;margin-top:24px">
|
||||
Sent automatically by Website Checker at {datetime.datetime.now().strftime('%H:%M')}.
|
||||
</p>
|
||||
@@ -151,37 +292,49 @@ def _build_html_report() -> str:
|
||||
|
||||
def _send_report(cfg: dict):
|
||||
"""Build and send the daily report email."""
|
||||
html = _build_html_report()
|
||||
today = datetime.date.today().strftime("%d %b %Y")
|
||||
subject = f"Website Checker — Daily Report {today}"
|
||||
html = _build_html_report()
|
||||
today = datetime.date.today().strftime("%d %b %Y")
|
||||
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["Subject"] = subject
|
||||
msg["From"] = cfg["smtp_user"]
|
||||
msg["To"] = ", ".join(cfg["recipients"])
|
||||
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = formataddr(("Website Checker", smtp_user))
|
||||
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"))
|
||||
|
||||
try:
|
||||
if cfg["use_tls"]:
|
||||
server = smtplib.SMTP(cfg["smtp_host"], cfg["smtp_port"], timeout=10)
|
||||
server.starttls()
|
||||
else:
|
||||
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())
|
||||
security = cfg.get("security", "starttls")
|
||||
server = _make_smtp_server(cfg["smtp_host"], cfg["smtp_port"], security)
|
||||
server.login(smtp_user, cfg["smtp_password"])
|
||||
server.sendmail(smtp_user, cfg["recipients"], msg.as_string())
|
||||
server.quit()
|
||||
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:
|
||||
logger.error(f"Failed to send daily report email: {e}")
|
||||
|
||||
|
||||
def _get_last_sent_date() -> "datetime.date | None":
|
||||
"""Read the last-sent date from config.ini [email] last_sent_date key."""
|
||||
cfg = configparser.ConfigParser()
|
||||
if not os.path.exists(CONFIG_FILE):
|
||||
return None
|
||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
||||
raw = cfg.get("email", "last_sent_date", fallback="")
|
||||
"""Read the last-sent date from app_settings."""
|
||||
from config import get_setting
|
||||
raw = get_setting("email.last_sent_date", "")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
@@ -191,25 +344,18 @@ def _get_last_sent_date() -> "datetime.date | None":
|
||||
|
||||
|
||||
def _set_last_sent_date(d: "datetime.date"):
|
||||
"""Persist the last-sent date to config.ini [email] last_sent_date key."""
|
||||
cfg = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
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)
|
||||
"""Persist the last-sent date to app_settings."""
|
||||
from config import set_setting
|
||||
set_setting("email.last_sent_date", d.isoformat())
|
||||
|
||||
|
||||
# ─── 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()
|
||||
|
||||
while not _stop_event.is_set():
|
||||
_stop_event.wait(60) # sleep 60 seconds between checks
|
||||
_stop_event.wait(60)
|
||||
if _stop_event.is_set():
|
||||
break
|
||||
|
||||
@@ -236,16 +382,10 @@ def _scheduler_loop():
|
||||
def start():
|
||||
"""Start the background scheduler thread. Call once after successful login."""
|
||||
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():
|
||||
logger.info("Email scheduler already running - stopping before restart.")
|
||||
_stop_event.set()
|
||||
_scheduler_thread.join(timeout=5)
|
||||
# Create a fresh Event so there is no residual set-state from a prior stop()
|
||||
_stop_event = threading.Event()
|
||||
_scheduler_thread = threading.Thread(target=_scheduler_loop,
|
||||
name="EmailScheduler", daemon=True)
|
||||
@@ -257,4 +397,4 @@ def stop():
|
||||
"""Signal the scheduler to stop cleanly."""
|
||||
global _stop_event
|
||||
_stop_event.set()
|
||||
logger.info("Email scheduler stopped.")
|
||||
logger.info("Email scheduler stopped.")
|
||||
+326
-28
@@ -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
|
||||
from tkinter import ttk
|
||||
from utils.ui_helpers import COLOURS, FONT_HEADING, show_error
|
||||
from tkinter import ttk, messagebox
|
||||
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):
|
||||
@@ -12,39 +32,317 @@ class AdminLogView(ttk.Frame):
|
||||
super().__init__(parent)
|
||||
self.current_user = current_user
|
||||
self._build_ui()
|
||||
self._load()
|
||||
|
||||
def _build_ui(self):
|
||||
toolbar = ttk.Frame(self)
|
||||
toolbar.pack(fill="x", pady=(0, 10))
|
||||
ttk.Label(toolbar, text="Activity Log", style="Heading.TLabel").pack(side="left")
|
||||
ttk.Button(toolbar, text="↻ Refresh", style="Ghost.TButton",
|
||||
command=self._load).pack(side="right")
|
||||
# Page header
|
||||
hdr = ttk.Frame(self)
|
||||
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",
|
||||
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")
|
||||
self.tree = ttk.Treeview(self, columns=cols, show="headings", selectmode="browse")
|
||||
widths = [140, 100, 130, 100, 70, 320]
|
||||
widths = [140, 100, 140, 100, 70, 320]
|
||||
self._act_tree = ttk.Treeview(
|
||||
parent, columns=cols, show="headings", selectmode="browse")
|
||||
for col, w in zip(cols, widths):
|
||||
self.tree.heading(col, text=col)
|
||||
self.tree.column(col, width=w, anchor="w")
|
||||
self.tree.pack(fill="both", expand=True)
|
||||
self._act_tree.heading(col, text=col)
|
||||
self._act_tree.column(col, width=w, anchor="w")
|
||||
|
||||
vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview)
|
||||
self.tree.configure(yscrollcommand=vsb.set)
|
||||
vsb.place(relx=1, rely=0, relheight=1, anchor="ne")
|
||||
vsb = ttk.Scrollbar(parent, orient="vertical",
|
||||
command=self._act_tree.yview)
|
||||
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
|
||||
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:
|
||||
for entry in get_activity_log():
|
||||
self.tree.insert("", "end", values=(
|
||||
str(entry["logged_at"])[:16],
|
||||
entry.get("username") or "—",
|
||||
entry["action"],
|
||||
entry.get("entity") or "",
|
||||
entry.get("entity_id") or "",
|
||||
entry.get("detail") or "",
|
||||
))
|
||||
# Pass search to the DB query so filtering happens server-side.
|
||||
entries = get_activity_log(limit=500, search=search)
|
||||
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("action") or "",
|
||||
entry.get("entity") or "",
|
||||
entry.get("entity_id") 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:
|
||||
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 (1–365).")
|
||||
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
|
||||
|
||||
@@ -450,12 +450,26 @@ class ShiftDialog(tk.Toplevel):
|
||||
)
|
||||
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(
|
||||
pane,
|
||||
title="Assigned Websites",
|
||||
all_items=[(w["id"], w["name"]) for w in self._all_websites],
|
||||
all_items=site_items,
|
||||
allow_reorder=True,
|
||||
item_colours=site_colours,
|
||||
)
|
||||
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.
|
||||
"""
|
||||
def __init__(self, parent, title: str, all_items: list,
|
||||
allow_reorder=False):
|
||||
allow_reorder=False, item_colours: dict = None):
|
||||
super().__init__(parent)
|
||||
self._all_items = all_items # [(id, label), ...]
|
||||
self._allow_reorder = allow_reorder
|
||||
self._drag_start = None
|
||||
# item_colours: {id: colour_str} — applied per-item after refresh
|
||||
self._item_colours = item_colours or {}
|
||||
self._build(title)
|
||||
|
||||
def _build(self, title: str):
|
||||
@@ -555,7 +571,19 @@ class _DualListPicker(ttk.Frame):
|
||||
|
||||
ttk.Label(self, text=title,
|
||||
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 ────────────────────────────────────────────────────
|
||||
avail_frame = tk.Frame(self, bg=COLOURS["surface"])
|
||||
@@ -632,12 +660,16 @@ class _DualListPicker(ttk.Frame):
|
||||
|
||||
def _refresh_listboxes(self):
|
||||
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)
|
||||
if id_ in self._item_colours:
|
||||
self._avail_lb.itemconfig(idx, fg=self._item_colours[id_])
|
||||
|
||||
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)
|
||||
if id_ in self._item_colours:
|
||||
self._assign_lb.itemconfig(idx, fg=self._item_colours[id_])
|
||||
|
||||
def _add(self):
|
||||
sel = list(self._avail_lb.curselection())
|
||||
@@ -747,4 +779,4 @@ def _valid_time(s: str) -> bool:
|
||||
h, m = int(parts[0]), int(parts[1])
|
||||
return 0 <= h <= 23 and 0 <= m <= 59
|
||||
except ValueError:
|
||||
return False
|
||||
return False
|
||||
+193
-17
@@ -6,7 +6,7 @@ import tkinter as tk
|
||||
from tkinter import ttk
|
||||
import logging
|
||||
from utils.ui_helpers import (
|
||||
COLOURS, FONT, FONT_BOLD, FONT_HEADING,
|
||||
COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL,
|
||||
show_error, show_info, confirm_delete
|
||||
)
|
||||
|
||||
@@ -44,9 +44,9 @@ class AdminUsersView(ttk.Frame):
|
||||
command=self._reset_password).pack(side="right", padx=(0, 8))
|
||||
|
||||
# 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")
|
||||
widths = [40, 140, 180, 80, 60, 160]
|
||||
widths = [40, 130, 160, 180, 80, 60, 140]
|
||||
for col, w in zip(cols, widths):
|
||||
self.tree.heading(col, text=col)
|
||||
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 ""
|
||||
self.tree.insert("", "end", iid=str(u["id"]),
|
||||
values=(u["id"], u["username"],
|
||||
u["full_name"] or "", u["role"],
|
||||
active, created))
|
||||
u["full_name"] or "", u["email"] or "",
|
||||
u["role"], active, created))
|
||||
except Exception as e:
|
||||
show_error(f"Failed to load users:\n{e}")
|
||||
|
||||
@@ -134,6 +134,7 @@ class AdminUsersView(ttk.Frame):
|
||||
user_data["full_name"],
|
||||
user_data["is_active"],
|
||||
password=pwd,
|
||||
email=user_data.get("email"),
|
||||
)
|
||||
log_action(
|
||||
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"by admin '{self.current_user['username']}'."
|
||||
)
|
||||
# Copy to clipboard
|
||||
self.clipboard_clear()
|
||||
self.clipboard_append(pwd)
|
||||
show_info(
|
||||
f"Temporary password for '{username}' has been set and "
|
||||
f"copied to your clipboard:\n\n{pwd}\n\n"
|
||||
"Please share it with the user securely.\n"
|
||||
"The user should change it immediately after logging in."
|
||||
)
|
||||
# Show the temporary password in a purpose-built secure dialog
|
||||
# instead of show_info (which leaves the password visible indefinitely)
|
||||
_PasswordResetDialog(self, username=username, password=pwd)
|
||||
except Exception as e:
|
||||
show_error(f"Password reset failed:\n{e}")
|
||||
|
||||
@@ -208,7 +203,7 @@ class UserDialog(tk.Toplevel):
|
||||
|
||||
def _centre(self):
|
||||
self.update_idletasks()
|
||||
w, h = 420, 420
|
||||
w, h = 440, 490
|
||||
x = (self.winfo_screenwidth() - w) // 2
|
||||
y = (self.winfo_screenheight() - h) // 2
|
||||
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,
|
||||
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:
|
||||
d = self.user_data
|
||||
self.full_name_var.set(d.get("full_name") or "")
|
||||
self.username_var.set(d.get("username") or "")
|
||||
self.role_var.set(d.get("role") or "user")
|
||||
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.pack(fill="x", padx=24, pady=(16, 20))
|
||||
@@ -270,6 +274,7 @@ class UserDialog(tk.Toplevel):
|
||||
password = self.password_var.get()
|
||||
role = self.role_var.get()
|
||||
is_active = int(self.active_var.get())
|
||||
email = self.email_var.get().strip() or None
|
||||
|
||||
if not username:
|
||||
show_error("Username is required.")
|
||||
@@ -277,6 +282,9 @@ class UserDialog(tk.Toplevel):
|
||||
if not self.is_edit and not password:
|
||||
show_error("Password is required for new users.")
|
||||
return
|
||||
if email and "@" not in email:
|
||||
show_error("Please enter a valid email address.")
|
||||
return
|
||||
|
||||
try:
|
||||
if self.is_edit:
|
||||
@@ -284,15 +292,183 @@ class UserDialog(tk.Toplevel):
|
||||
update_user(self.current_user["id"],
|
||||
self.user_data["id"],
|
||||
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.")
|
||||
show_info("User updated successfully.")
|
||||
else:
|
||||
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.")
|
||||
show_info("User created successfully.")
|
||||
self.on_save()
|
||||
self.destroy()
|
||||
except Exception as e:
|
||||
show_error(f"Save failed:\n{e}")
|
||||
|
||||
|
||||
# ─── Secure Password Reset Dialog ─────────────────────────────────────────────
|
||||
|
||||
class _PasswordResetDialog(tk.Toplevel):
|
||||
"""
|
||||
Purpose-built dialog for displaying a freshly-generated temporary password.
|
||||
|
||||
Features:
|
||||
- Password field is masked by default; admin can reveal with 👁 toggle.
|
||||
- 📋 Copy button copies to clipboard with a 2-second "Copied!" flash.
|
||||
- Clipboard is auto-cleared after 30 seconds for security.
|
||||
- Dialog auto-closes after 120 seconds with a live countdown so the
|
||||
password cannot sit on screen indefinitely.
|
||||
"""
|
||||
|
||||
_AUTO_CLOSE_S = 120 # seconds before auto-close
|
||||
_CLIP_CLEAR_S = 30 # seconds before clipboard is wiped
|
||||
|
||||
def __init__(self, parent, username: str, password: str):
|
||||
super().__init__(parent)
|
||||
self._password = password
|
||||
self._username = username
|
||||
self._remaining = self._AUTO_CLOSE_S
|
||||
self._clip_job = None
|
||||
self._tick_job = None
|
||||
self._revealed = False
|
||||
|
||||
self.title("Temporary Password")
|
||||
self.configure(bg=COLOURS["bg"])
|
||||
self.resizable(False, False)
|
||||
self.grab_set()
|
||||
self.protocol("WM_DELETE_WINDOW", self._close)
|
||||
self._build_ui()
|
||||
self._centre()
|
||||
self._tick()
|
||||
|
||||
def _centre(self):
|
||||
self.update_idletasks()
|
||||
w, h = 480, 300
|
||||
x = (self.winfo_screenwidth() - w) // 2
|
||||
y = (self.winfo_screenheight() - h) // 2
|
||||
self.geometry(f"{w}x{h}+{x}+{y}")
|
||||
|
||||
def _build_ui(self):
|
||||
C = COLOURS
|
||||
pad = dict(padx=24, pady=8)
|
||||
|
||||
tk.Label(self, text="🔑 Password Reset",
|
||||
bg=C["bg"], fg=C["text"],
|
||||
font=FONT_BOLD).pack(anchor="w", **pad)
|
||||
|
||||
tk.Label(
|
||||
self,
|
||||
text=f"A temporary password has been generated for '{self._username}'.\n"
|
||||
"Share it securely — the user must change it on first login.",
|
||||
bg=C["bg"], fg=C["text_dim"],
|
||||
font=FONT_SMALL, justify="left", wraplength=430,
|
||||
).pack(anchor="w", padx=24, pady=(0, 8))
|
||||
|
||||
# Password row
|
||||
pw_frame = tk.Frame(self, bg=C["surface2"], padx=8, pady=8)
|
||||
pw_frame.pack(fill="x", padx=24, pady=(0, 8))
|
||||
|
||||
self._pw_var = tk.StringVar(value=self._password)
|
||||
self._pw_entry = tk.Entry(
|
||||
pw_frame, textvariable=self._pw_var,
|
||||
show="•", state="readonly",
|
||||
readonlybackground=C["surface2"], fg=C["text"],
|
||||
relief="flat", font=(FONT_BOLD[0], 14),
|
||||
)
|
||||
self._pw_entry.pack(side="left", fill="x", expand=True)
|
||||
|
||||
tk.Button(
|
||||
pw_frame, text="👁",
|
||||
command=self._toggle_reveal,
|
||||
bg=C["surface2"], fg=C["text_dim"],
|
||||
activebackground=C["surface"], activeforeground=C["text"],
|
||||
relief="flat", font=FONT_SMALL, cursor="hand2", padx=6,
|
||||
).pack(side="left", padx=(6, 0))
|
||||
|
||||
self._copy_btn = tk.Button(
|
||||
pw_frame, text="📋 Copy",
|
||||
command=self._copy,
|
||||
bg=C["accent"], fg=C["white"],
|
||||
activebackground=C["accent_hover"], activeforeground=C["white"],
|
||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||
padx=10, pady=4,
|
||||
)
|
||||
self._copy_btn.pack(side="left", padx=(6, 0))
|
||||
|
||||
# Countdown label
|
||||
self._countdown_lbl = tk.Label(
|
||||
self, text="",
|
||||
bg=C["bg"], fg=C["text_dim"], font=FONT_SMALL,
|
||||
)
|
||||
self._countdown_lbl.pack(pady=(0, 4))
|
||||
|
||||
# Warning
|
||||
tk.Label(
|
||||
self,
|
||||
text="⚠ This dialog will close automatically. "
|
||||
"The clipboard is cleared after 30 seconds.",
|
||||
bg=C["bg"], fg=C["warning"],
|
||||
font=FONT_SMALL, wraplength=430, justify="left",
|
||||
).pack(anchor="w", padx=24, pady=(0, 8))
|
||||
|
||||
# Close button
|
||||
tk.Button(
|
||||
self, text="Close",
|
||||
command=self._close,
|
||||
bg=C["surface2"], fg=C["text"],
|
||||
activebackground=C["danger"], activeforeground=C["white"],
|
||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||
padx=12, pady=6,
|
||||
).pack(pady=(0, 16))
|
||||
|
||||
def _toggle_reveal(self):
|
||||
self._revealed = not self._revealed
|
||||
self._pw_entry.config(show="" if self._revealed else "•")
|
||||
|
||||
def _copy(self):
|
||||
self.clipboard_clear()
|
||||
self.clipboard_append(self._password)
|
||||
self._copy_btn.config(text="✔ Copied!", bg=COLOURS["success"])
|
||||
self.after(2000, lambda: self._copy_btn.config(
|
||||
text="📋 Copy", bg=COLOURS["accent"]))
|
||||
# Schedule clipboard wipe
|
||||
if self._clip_job:
|
||||
try:
|
||||
self.after_cancel(self._clip_job)
|
||||
except Exception:
|
||||
pass
|
||||
self._clip_job = self.after(
|
||||
self._CLIP_CLEAR_S * 1000, self._clear_clipboard)
|
||||
|
||||
def _clear_clipboard(self):
|
||||
try:
|
||||
self.clipboard_clear()
|
||||
self.clipboard_append("")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _tick(self):
|
||||
if not self.winfo_exists():
|
||||
return
|
||||
self._countdown_lbl.config(
|
||||
text=f"Auto-closes in {self._remaining}s")
|
||||
if self._remaining <= 0:
|
||||
self._close()
|
||||
return
|
||||
self._remaining -= 1
|
||||
self._tick_job = self.after(1000, self._tick)
|
||||
|
||||
def _close(self):
|
||||
# Cancel any pending jobs
|
||||
for job in (self._clip_job, self._tick_job):
|
||||
if job:
|
||||
try:
|
||||
self.after_cancel(job)
|
||||
except Exception:
|
||||
pass
|
||||
self._clear_clipboard()
|
||||
try:
|
||||
self.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -34,6 +34,9 @@ class AdminWebsitesView(ttk.Frame):
|
||||
|
||||
ttk.Button(toolbar, text="+ Add Website",
|
||||
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",
|
||||
style="Ghost.TButton",
|
||||
command=self._open_edit).pack(side="right", padx=(4, 0))
|
||||
@@ -88,6 +91,10 @@ class AdminWebsitesView(ttk.Frame):
|
||||
|
||||
# ─── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _open_import(self):
|
||||
"""Open the bulk import dialog."""
|
||||
BulkImportDialog(self, self.current_user, on_complete=self._load_websites)
|
||||
|
||||
def _open_add(self):
|
||||
WebsiteDialog(self, self.current_user, website_data=None,
|
||||
on_save=self._load_websites)
|
||||
@@ -427,6 +434,21 @@ class WebsiteDialog(tk.Toplevel):
|
||||
if not name or not url:
|
||||
show_error("Name and URL are required.")
|
||||
return
|
||||
|
||||
# Normalise and validate URL before writing to the database.
|
||||
# Reject schemes that could be executed in-browser (javascript:, data:, etc.)
|
||||
# and enforce that a host is present so the health-check thread and the
|
||||
# "open in browser" action both receive a usable address.
|
||||
url = _validate_and_normalise_url(url)
|
||||
if url is None:
|
||||
show_error(
|
||||
"The URL entered is not valid.\n\n"
|
||||
"Please enter a full web address, e.g.:\n"
|
||||
" https://www.example.com\n"
|
||||
" http://intranet.local/app"
|
||||
)
|
||||
return
|
||||
|
||||
if visibility == "assigned" and not assigned_user_ids:
|
||||
show_error("Please assign at least one user, or set visibility to All Users.")
|
||||
return
|
||||
@@ -463,3 +485,418 @@ class WebsiteDialog(tk.Toplevel):
|
||||
self.destroy()
|
||||
except Exception as e:
|
||||
show_error(f"Save failed:\n{e}")
|
||||
|
||||
|
||||
# ─── URL validation helper ────────────────────────────────────────────────────
|
||||
|
||||
def _validate_and_normalise_url(raw: str) -> "str | None":
|
||||
"""
|
||||
Validate and normalise a user-supplied URL string.
|
||||
|
||||
Rules:
|
||||
- If no scheme is present, prepend 'https://'.
|
||||
- Only 'http' and 'https' schemes are accepted.
|
||||
- A non-empty netloc (host) must be present.
|
||||
- Returns the normalised URL string on success, None on failure.
|
||||
|
||||
This prevents dangerous schemes (javascript:, data:, file:, etc.) from
|
||||
reaching the database, the health-check thread, or webbrowser.open().
|
||||
"""
|
||||
import urllib.parse
|
||||
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
# Prepend https:// if no scheme is given so urlparse can parse the host
|
||||
if "://" not in raw:
|
||||
raw = "https://" + raw
|
||||
|
||||
try:
|
||||
parts = urllib.parse.urlparse(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if parts.scheme.lower() not in ("http", "https"):
|
||||
return None
|
||||
|
||||
if not parts.netloc:
|
||||
return None
|
||||
|
||||
# Reconstruct a clean URL (strips any leading/trailing whitespace artefacts)
|
||||
return urllib.parse.urlunparse(parts)
|
||||
|
||||
|
||||
# ─── 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}")
|
||||
+440
-32
@@ -167,35 +167,23 @@ class AiSummaryView(ttk.Frame):
|
||||
|
||||
def _load_config(self):
|
||||
try:
|
||||
import configparser
|
||||
from config import CONFIG_FILE
|
||||
from utils.config_crypto import decrypt_value
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
||||
if cfg.has_section(_CFG_SECTION):
|
||||
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:
|
||||
self._model_var.set(model)
|
||||
from config import get_setting
|
||||
from utils.crypto import decrypt
|
||||
raw_key = get_setting("groq.api_key", "")
|
||||
if raw_key:
|
||||
self._api_key_var.set(decrypt(raw_key))
|
||||
model = get_setting("groq.model", GROQ_MODELS[0])
|
||||
if model in GROQ_MODELS:
|
||||
self._model_var.set(model)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load Groq config: {e}")
|
||||
|
||||
def _save_config(self):
|
||||
try:
|
||||
import configparser
|
||||
from config import CONFIG_FILE
|
||||
from utils.config_crypto import encrypt_value
|
||||
cfg = configparser.ConfigParser()
|
||||
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)
|
||||
from config import set_setting
|
||||
from utils.crypto import encrypt
|
||||
set_setting("groq.api_key", encrypt(self._api_key_var.get().strip()))
|
||||
set_setting("groq.model", self._model_var.get())
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not save Groq config: {e}")
|
||||
|
||||
@@ -217,9 +205,17 @@ class AiSummaryView(ttk.Frame):
|
||||
|
||||
self._build_criteria_panel()
|
||||
|
||||
pane = tk.PanedWindow(self, orient="horizontal",
|
||||
# Notebook — Analyze tab (existing layout) + History tab (new)
|
||||
self._nb = ttk.Notebook(self)
|
||||
self._nb.pack(fill="both", expand=True, pady=(0, 6))
|
||||
|
||||
# ── Analyze tab ───────────────────────────────────────────────────────
|
||||
analyze_tab = tk.Frame(self._nb, bg=C["bg"])
|
||||
self._nb.add(analyze_tab, text="✨ Analyze")
|
||||
|
||||
pane = tk.PanedWindow(analyze_tab, orient="horizontal",
|
||||
bg=C["border"], sashwidth=4, sashrelief="flat")
|
||||
pane.pack(fill="both", expand=True, pady=(0, 6))
|
||||
pane.pack(fill="both", expand=True)
|
||||
|
||||
left = tk.Frame(pane, bg=C["bg"])
|
||||
pane.add(left, minsize=260, width=300)
|
||||
@@ -229,6 +225,12 @@ class AiSummaryView(ttk.Frame):
|
||||
pane.add(right, minsize=350)
|
||||
self._build_output_panel(right)
|
||||
|
||||
# ── History tab ───────────────────────────────────────────────────────
|
||||
history_tab = tk.Frame(self._nb, bg=C["bg"])
|
||||
self._nb.add(history_tab, text="🕑 History")
|
||||
self._build_history_panel(history_tab)
|
||||
self._nb.bind("<<NotebookTabChanged>>", self._on_tab_changed)
|
||||
|
||||
status_bar = tk.Frame(self, bg=C["surface2"], pady=4)
|
||||
status_bar.pack(fill="x", side="bottom")
|
||||
tk.Label(status_bar, textvariable=self._status_var,
|
||||
@@ -361,6 +363,20 @@ class AiSummaryView(ttk.Frame):
|
||||
).pack(side="left", padx=(0, 4))
|
||||
self._criteria_tree.bind("<Double-1>",
|
||||
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(
|
||||
self._criteria_card,
|
||||
@@ -430,6 +446,27 @@ class AiSummaryView(ttk.Frame):
|
||||
CriterionDialog(self, self.current_user,
|
||||
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):
|
||||
cid = self._get_selected_criterion_id()
|
||||
if not cid:
|
||||
@@ -858,12 +895,24 @@ class AiSummaryView(ttk.Frame):
|
||||
)
|
||||
full_output = header + result
|
||||
|
||||
self.after(0, lambda t=full_output, v=verdict: self._on_success(t, v))
|
||||
# Build artefacts for history persistence
|
||||
file_names_str = ", ".join(os.path.basename(fp) for fp in file_paths)
|
||||
criteria_snapshot = (
|
||||
"\n".join(
|
||||
f"{i+1}. {c['title']}: {c['description']}"
|
||||
for i, c in enumerate(active_criteria)
|
||||
) if active_criteria else None
|
||||
)
|
||||
|
||||
self.after(0, lambda t=full_output, v=verdict,
|
||||
fn=file_names_str, m=model, cs=criteria_snapshot:
|
||||
self._on_success(t, v, fn, m, cs))
|
||||
|
||||
except Exception as exc:
|
||||
self.after(0, lambda e=exc: self._on_error(e))
|
||||
|
||||
def _on_success(self, text: str, verdict):
|
||||
def _on_success(self, text: str, verdict, file_names: str,
|
||||
model: str, criteria_snapshot: str):
|
||||
self._stop_spinner()
|
||||
self._set_output_text(text)
|
||||
if verdict:
|
||||
@@ -876,6 +925,25 @@ class AiSummaryView(ttk.Frame):
|
||||
f"'{self.current_user.get('username')}' "
|
||||
f"({len(self._files)} file(s)). Verdict: {verdict or 'N/A'}."
|
||||
)
|
||||
# Persist to ai_analysis_log so the history panel can display it
|
||||
try:
|
||||
from models import save_ai_analysis
|
||||
save_ai_analysis(
|
||||
user_id=self.current_user["id"],
|
||||
file_names=file_names,
|
||||
model=model,
|
||||
verdict=verdict,
|
||||
criteria_snapshot=criteria_snapshot,
|
||||
summary_text=text,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[AI SUMMARY] Could not save analysis to history: {e}")
|
||||
# Refresh history panel if it exists (it is built lazily on tab switch)
|
||||
if hasattr(self, "_refresh_history"):
|
||||
try:
|
||||
self._refresh_history()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_error(self, exc: Exception):
|
||||
self._stop_spinner()
|
||||
@@ -904,6 +972,241 @@ class AiSummaryView(ttk.Frame):
|
||||
self._progress.pack_forget()
|
||||
self._run_btn.config(state="normal", text="✨ Analyze with AI")
|
||||
|
||||
# -- History panel --------------------------------------------------------
|
||||
|
||||
def _build_history_panel(self, parent):
|
||||
"""
|
||||
Build the analysis history tab.
|
||||
Admin: sees all users' analyses.
|
||||
User: sees only their own.
|
||||
Treeview columns: Date/Time, User (admin only), Files, Model, Verdict
|
||||
Double-click or View button restores the full result in the output panel.
|
||||
"""
|
||||
C = COLOURS
|
||||
|
||||
# ── Toolbar ───────────────────────────────────────────────────────────
|
||||
toolbar = tk.Frame(parent, bg=C["surface"], pady=8, padx=10)
|
||||
toolbar.pack(fill="x")
|
||||
|
||||
tk.Label(toolbar, text="📜 Analysis History",
|
||||
bg=C["surface"], fg=C["text"],
|
||||
font=FONT_BOLD).pack(side="left")
|
||||
|
||||
tk.Button(
|
||||
toolbar, text="🔍 View Result",
|
||||
command=self._history_view_selected,
|
||||
bg=C["accent"], fg=C["white"],
|
||||
activebackground=C["accent_hover"], activeforeground=C["white"],
|
||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||
padx=8, pady=4,
|
||||
).pack(side="right", padx=(4, 0))
|
||||
|
||||
tk.Button(
|
||||
toolbar, text="↻ Refresh",
|
||||
command=self._refresh_history,
|
||||
bg=C["surface2"], fg=C["text"],
|
||||
activebackground=C["accent"], activeforeground=C["white"],
|
||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||
padx=8, pady=4,
|
||||
).pack(side="right", padx=(4, 0))
|
||||
|
||||
# ── Verdict filter ────────────────────────────────────────────────────
|
||||
filter_frame = tk.Frame(parent, bg=C["surface"], padx=10, pady=4)
|
||||
filter_frame.pack(fill="x")
|
||||
|
||||
tk.Label(filter_frame, text="Filter by verdict:",
|
||||
bg=C["surface"], fg=C["text_dim"],
|
||||
font=FONT_SMALL).pack(side="left")
|
||||
|
||||
self._hist_filter_var = tk.StringVar(value="All")
|
||||
for label in ("All", "PURSUE", "PASS", "UNCLEAR", "— none —"):
|
||||
tk.Radiobutton(
|
||||
filter_frame, text=label,
|
||||
variable=self._hist_filter_var, value=label,
|
||||
command=self._apply_history_filter,
|
||||
bg=C["surface"], fg=C["text"],
|
||||
activebackground=C["surface"],
|
||||
activeforeground=C["accent"],
|
||||
selectcolor=C["surface2"],
|
||||
font=FONT_SMALL, cursor="hand2",
|
||||
).pack(side="left", padx=(8, 0))
|
||||
|
||||
# ── Treeview ──────────────────────────────────────────────────────────
|
||||
tree_frame = tk.Frame(parent, bg=C["bg"])
|
||||
tree_frame.pack(fill="both", expand=True, padx=6, pady=(4, 4))
|
||||
|
||||
if self._is_admin:
|
||||
cols = ("ID", "Date/Time", "User", "Files", "Model", "Verdict")
|
||||
widths = [40, 140, 100, 280, 160, 80]
|
||||
else:
|
||||
cols = ("ID", "Date/Time", "Files", "Model", "Verdict")
|
||||
widths = [40, 140, 360, 160, 80]
|
||||
|
||||
self._hist_tree = ttk.Treeview(
|
||||
tree_frame, columns=cols,
|
||||
show="headings", selectmode="browse",
|
||||
)
|
||||
for col, w in zip(cols, widths):
|
||||
self._hist_tree.heading(col, text=col)
|
||||
self._hist_tree.column(
|
||||
col, width=w,
|
||||
anchor="center" if col in ("ID", "Verdict") else "w",
|
||||
)
|
||||
|
||||
# Colour-code verdict rows
|
||||
self._hist_tree.tag_configure("PURSUE", foreground=C["success"])
|
||||
self._hist_tree.tag_configure("PASS", foreground=C["danger"])
|
||||
self._hist_tree.tag_configure("UNCLEAR", foreground=C["warning"])
|
||||
self._hist_tree.tag_configure("none", foreground=C["text_dim"])
|
||||
|
||||
vsb = ttk.Scrollbar(tree_frame, orient="vertical",
|
||||
command=self._hist_tree.yview)
|
||||
self._hist_tree.configure(yscrollcommand=vsb.set)
|
||||
vsb.pack(side="right", fill="y")
|
||||
self._hist_tree.pack(side="left", fill="both", expand=True)
|
||||
|
||||
self._hist_tree.bind("<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 ------------------------------------------------------------
|
||||
|
||||
def _set_status(self, msg: str):
|
||||
@@ -914,6 +1217,109 @@ class AiSummaryView(ttk.Frame):
|
||||
# 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):
|
||||
"""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.configure(bg=COLOURS["bg"])
|
||||
self.resizable(False, False)
|
||||
self.resizable(False, True) # allow vertical resize for varied DPI/fonts
|
||||
self.grab_set()
|
||||
self._build_ui()
|
||||
self._centre()
|
||||
|
||||
def _centre(self):
|
||||
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
|
||||
y = (self.winfo_screenheight() - h) // 2
|
||||
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.pack(side="right", fill="y")
|
||||
self._desc_txt = tk.Text(
|
||||
desc_frame, height=6, wrap="word",
|
||||
desc_frame, height=5, wrap="word",
|
||||
bg=C["surface2"], fg=C["text"],
|
||||
insertbackground=C["text"],
|
||||
relief="flat", font=FONT,
|
||||
@@ -1265,4 +1673,4 @@ def _human_size(n: int) -> str:
|
||||
if n < 1024:
|
||||
return f"{n:.0f} {unit}"
|
||||
n /= 1024
|
||||
return f"{n:.1f} GB"
|
||||
return f"{n:.1f} GB"
|
||||
@@ -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}")
|
||||
+166
-76
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
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
|
||||
@@ -15,6 +15,13 @@ from utils.ui_helpers import (
|
||||
|
||||
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):
|
||||
def __init__(self, master, current_user: dict):
|
||||
@@ -31,119 +38,159 @@ class EmailSettingsView(tk.Toplevel):
|
||||
|
||||
def _centre(self):
|
||||
self.update_idletasks()
|
||||
w, h = 500, 560
|
||||
w, h = 540, 610
|
||||
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
|
||||
|
||||
# Header
|
||||
hdr = tk.Frame(self, bg=COLOURS["accent"], pady=14)
|
||||
hdr = tk.Frame(self, bg=C["accent"], pady=14)
|
||||
hdr.pack(fill="x")
|
||||
tk.Label(hdr, text="Daily Report Email Settings",
|
||||
font=FONT_BOLD, bg=COLOURS["accent"],
|
||||
fg=COLOURS["white"]).pack()
|
||||
font=FONT_BOLD, bg=C["accent"],
|
||||
fg=C["white"]).pack()
|
||||
tk.Label(hdr,
|
||||
text="Send an HTML completion report to recipients on a daily schedule.",
|
||||
font=FONT_SMALL, bg=COLOURS["accent"],
|
||||
fg=COLOURS["white"]).pack(pady=(2, 0))
|
||||
font=FONT_SMALL, bg=C["accent"],
|
||||
fg=C["white"]).pack(pady=(2, 0))
|
||||
|
||||
# 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.columnconfigure(1, weight=1)
|
||||
|
||||
# Enable toggle
|
||||
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)
|
||||
self._fields = [] # widgets to enable/disable with the checkbox
|
||||
|
||||
def field(label, row, show=None, width=28):
|
||||
tk.Label(form, text=label, bg=COLOURS["bg"],
|
||||
fg=COLOURS["text_dim"], font=FONT_SMALL,
|
||||
anchor="w").grid(row=row, column=0, sticky="w",
|
||||
padx=(0, 12), pady=5)
|
||||
def lbl(text, row):
|
||||
tk.Label(form, text=text, bg=C["bg"], fg=C["text_dim"],
|
||||
font=FONT_SMALL, anchor="w").grid(
|
||||
row=row, column=0, sticky="w", padx=(0, 12), pady=5)
|
||||
|
||||
def entry(row, show=None, width=28):
|
||||
var = tk.StringVar()
|
||||
ent = tk.Entry(form, textvariable=var, width=width,
|
||||
bg=COLOURS["surface2"], fg=COLOURS["text"],
|
||||
insertbackground=COLOURS["text"],
|
||||
bg=C["surface2"], fg=C["text"],
|
||||
insertbackground=C["text"],
|
||||
relief="flat", font=FONT, show=show or "")
|
||||
ent.grid(row=row, column=1, sticky="ew", ipady=6, pady=5)
|
||||
self._fields.append(ent)
|
||||
return var
|
||||
|
||||
self._fields = []
|
||||
self.smtp_host_var = field("SMTP Host", 1)
|
||||
self.smtp_port_var = field("SMTP Port", 2, width=8)
|
||||
self.smtp_user_var = field("SMTP Username", 3)
|
||||
self.smtp_pass_var = field("SMTP Password", 4, show="•")
|
||||
# Row 0 — Enable toggle
|
||||
lbl("Enable daily emails", 0)
|
||||
self.enabled_var = tk.BooleanVar(value=False)
|
||||
tk.Checkbutton(form, variable=self.enabled_var,
|
||||
bg=C["bg"], activebackground=C["bg"],
|
||||
selectcolor=C["surface2"],
|
||||
command=self._toggle_fields).grid(
|
||||
row=0, column=1, sticky="w", pady=6)
|
||||
|
||||
# TLS toggle
|
||||
tk.Label(form, text="Use STARTTLS", bg=COLOURS["bg"],
|
||||
fg=COLOURS["text_dim"], font=FONT_SMALL).grid(
|
||||
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)
|
||||
# Row 1 — SMTP Host
|
||||
lbl("SMTP Host", 1)
|
||||
self.smtp_host_var = entry(1)
|
||||
|
||||
self.recipients_var = field("Recipients (comma-sep)", 6)
|
||||
self.send_time_var = field("Send Time (HH:MM)", 7, width=8)
|
||||
# Row 2 — Security mode (replaces the old Use STARTTLS checkbox)
|
||||
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")
|
||||
|
||||
# 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")
|
||||
|
||||
# Status line
|
||||
# Row 8 — Status line (multi-line for diagnostic output)
|
||||
self._status_var = tk.StringVar()
|
||||
self._status_lbl = tk.Label(form, textvariable=self._status_var,
|
||||
bg=COLOURS["bg"], fg=COLOURS["text_dim"],
|
||||
font=FONT_SMALL, wraplength=380, anchor="w")
|
||||
self._status_lbl.grid(row=8, column=0, columnspan=2, sticky="ew", pady=(6, 0))
|
||||
self._status_lbl = tk.Label(
|
||||
form, textvariable=self._status_var,
|
||||
bg=C["bg"], fg=C["text_dim"],
|
||||
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
|
||||
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")
|
||||
|
||||
self._save_btn = tk.Button(
|
||||
btn_row, text="Save Settings",
|
||||
command=self._save,
|
||||
bg=COLOURS["accent"], fg=COLOURS["white"],
|
||||
activebackground=COLOURS["accent_hover"],
|
||||
activeforeground=COLOURS["white"],
|
||||
bg=C["accent"], fg=C["white"],
|
||||
activebackground=C["accent_hover"],
|
||||
activeforeground=C["white"],
|
||||
relief="flat", font=FONT_BOLD, cursor="hand2",
|
||||
padx=14, pady=8)
|
||||
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(
|
||||
btn_row, text="Test Connection",
|
||||
btn_row, text="🔌 Test Connection",
|
||||
command=self._test,
|
||||
bg=COLOURS["surface2"], fg=COLOURS["text"],
|
||||
activebackground=COLOURS["surface2"],
|
||||
activeforeground=COLOURS["accent"],
|
||||
bg=C["surface2"], fg=C["text"],
|
||||
activebackground=C["surface2"],
|
||||
activeforeground=C["accent"],
|
||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||
padx=10, pady=8)
|
||||
self._test_btn.pack(side="right")
|
||||
|
||||
tk.Button(btn_row, text="Cancel",
|
||||
command=self.destroy,
|
||||
bg=COLOURS["surface"], fg=COLOURS["text_dim"],
|
||||
activebackground=COLOURS["surface2"],
|
||||
bg=C["surface"], fg=C["text_dim"],
|
||||
activebackground=C["surface2"],
|
||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||
padx=10, pady=8).pack(side="left")
|
||||
|
||||
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):
|
||||
"""Enable/disable SMTP fields based on the enabled checkbox."""
|
||||
state = "normal" if self.enabled_var.get() else "disabled"
|
||||
for w in self._fields:
|
||||
try:
|
||||
@@ -161,7 +208,11 @@ class EmailSettingsView(tk.Toplevel):
|
||||
self.smtp_port_var.set(str(cfg.get("smtp_port", 587)))
|
||||
self.smtp_user_var.set(cfg.get("smtp_user", ""))
|
||||
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.send_time_var.set(cfg.get("send_time", "18:00"))
|
||||
self._toggle_fields()
|
||||
@@ -171,13 +222,14 @@ class EmailSettingsView(tk.Toplevel):
|
||||
port_str = self.smtp_port_var.get().strip()
|
||||
user = self.smtp_user_var.get().strip()
|
||||
password = self.smtp_pass_var.get()
|
||||
use_tls = self.tls_var.get()
|
||||
security = self.security_var.get()
|
||||
recipients = self.recipients_var.get().strip()
|
||||
send_time = self.send_time_var.get().strip()
|
||||
enabled = self.enabled_var.get()
|
||||
|
||||
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
|
||||
try:
|
||||
port = int(port_str)
|
||||
@@ -186,7 +238,6 @@ class EmailSettingsView(tk.Toplevel):
|
||||
except ValueError:
|
||||
self._set_status("Port must be a number between 1 and 65535.", "danger")
|
||||
return None
|
||||
# Validate send_time
|
||||
try:
|
||||
h, m = map(int, send_time.split(":"))
|
||||
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")
|
||||
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"):
|
||||
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_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):
|
||||
result = self._get_fields()
|
||||
if result is None:
|
||||
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._test_btn.config(state="disabled")
|
||||
self._save_btn.config(state="disabled")
|
||||
self._set_status("Running diagnostics — please wait...", "dim")
|
||||
self._lock_buttons()
|
||||
|
||||
def _run():
|
||||
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"
|
||||
self.after(0, lambda: self._set_status(msg, level))
|
||||
self.after(0, lambda: self._test_btn.config(state="normal"))
|
||||
self.after(0, lambda: self._save_btn.config(state="normal"))
|
||||
self.after(0, self._unlock_buttons)
|
||||
|
||||
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()
|
||||
|
||||
@@ -226,14 +315,15 @@ class EmailSettingsView(tk.Toplevel):
|
||||
result = self._get_fields()
|
||||
if result is None:
|
||||
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
|
||||
save_email_config(enabled, host, port, user, password, use_tls,
|
||||
save_email_config(enabled, host, port, user, password, security,
|
||||
recipients, send_time)
|
||||
from models import log_action
|
||||
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.")
|
||||
logger.info(f"Email settings saved by {self.current_user['username']}.")
|
||||
self.destroy()
|
||||
self.destroy()
|
||||
+24
-15
@@ -1,13 +1,12 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Writes connection details to config.ini via config.save_config().
|
||||
Does NOT store the password in plaintext beyond what config.ini holds
|
||||
(which is acceptable for a locally-run desktop tool; operators should
|
||||
restrict file-system access to config.ini in production).
|
||||
Credentials are saved to the OS native keychain (Windows Credential Manager,
|
||||
macOS Keychain, or Linux Secret Service) via the keyring library.
|
||||
No config.ini or local file is written — the app is fully portable.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
@@ -42,7 +41,7 @@ class SettingsView(tk.Toplevel):
|
||||
|
||||
self.title("Database Setup" if first_run else "Connection Settings")
|
||||
self.configure(bg=COLOURS["bg"])
|
||||
self.resizable(False, False)
|
||||
self.resizable(False, True) # allow vertical resize for high-DPI
|
||||
self.grab_set()
|
||||
|
||||
if first_run:
|
||||
@@ -58,7 +57,9 @@ class SettingsView(tk.Toplevel):
|
||||
|
||||
def _centre(self):
|
||||
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
|
||||
y = (self.winfo_screenheight() - h) // 2
|
||||
self.geometry(f"{w}x{h}+{x}+{y}")
|
||||
@@ -77,6 +78,12 @@ class SettingsView(tk.Toplevel):
|
||||
font=FONT_SMALL, bg=COLOURS["accent"],
|
||||
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 = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=20)
|
||||
form.pack(fill="both", expand=True)
|
||||
@@ -116,7 +123,8 @@ class SettingsView(tk.Toplevel):
|
||||
|
||||
# ── 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."
|
||||
)
|
||||
tk.Label(form, text=hint, bg=COLOURS["bg"],
|
||||
@@ -124,10 +132,7 @@ class SettingsView(tk.Toplevel):
|
||||
justify="left", wraplength=380).grid(
|
||||
row=6, column=0, columnspan=2, sticky="w", pady=(8, 0))
|
||||
|
||||
# ── Buttons ───────────────────────────────────────────────────────────
|
||||
btn_row = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=16)
|
||||
btn_row.pack(fill="x")
|
||||
|
||||
# ── Buttons (btn_row already packed at top of _build_ui) ─────────────
|
||||
self._save_btn = tk.Button(
|
||||
btn_row, text="Save & Connect",
|
||||
command=self._save,
|
||||
@@ -263,9 +268,13 @@ class SettingsView(tk.Toplevel):
|
||||
)
|
||||
conn.close()
|
||||
|
||||
from config import save_config
|
||||
from config import save_config, reload_db_config
|
||||
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)
|
||||
except Exception as e:
|
||||
@@ -284,4 +293,4 @@ class SettingsView(tk.Toplevel):
|
||||
|
||||
def _finish(self):
|
||||
self.destroy()
|
||||
self.on_save_callback()
|
||||
self.on_save_callback()
|
||||
@@ -581,7 +581,15 @@ class UserDashboardView(ttk.Frame):
|
||||
self._notify_job = self.after(60_000, self._schedule_notifications)
|
||||
|
||||
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
|
||||
import datetime as dt
|
||||
try:
|
||||
@@ -590,6 +598,10 @@ class UserDashboardView(ttk.Frame):
|
||||
return
|
||||
|
||||
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:
|
||||
end_time = site.get("end_time")
|
||||
if not end_time:
|
||||
@@ -606,20 +618,41 @@ class UserDashboardView(ttk.Frame):
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Compute minutes until shift end
|
||||
now_mins = now.hour * 60 + now.minute
|
||||
end_mins = end_t.hour * 60 + end_t.minute
|
||||
diff = end_mins - now_mins
|
||||
|
||||
key = (site["id"], end_t)
|
||||
if 0 < diff <= NOTIFY_MINUTES_BEFORE and key not in self._notified_sites:
|
||||
self._notified_sites.add(key)
|
||||
self._fire_notification(site["name"], diff, end_t)
|
||||
if 0 < diff <= NOTIFY_MINUTES_BEFORE:
|
||||
if end_t not in groups:
|
||||
groups[end_t] = {"diff": diff, "names": []}
|
||||
groups[end_t]["names"].append(site["name"])
|
||||
|
||||
def _fire_notification(self, site_name: str, minutes_left: int, end_time):
|
||||
title = "Shift Reminder"
|
||||
message = (f"'{site_name}' is unchecked — "
|
||||
f"shift ends at {end_time.strftime('%H:%M')} "
|
||||
# Fire one notification per end-time group that has not been notified yet
|
||||
for end_t, info in groups.items():
|
||||
key = (frozenset(info["names"]), end_t)
|
||||
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).")
|
||||
logger.info(f"Notification: {message}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user