Add bid tracker

This commit is contained in:
2026-04-24 07:15:48 -04:00
parent 262c71e93b
commit 6ec439bafe
7 changed files with 2273 additions and 205 deletions
+192 -88
View File
@@ -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" = MonFri. 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,73 +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/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 the sidebar for both admin and user roles.
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
- **Evaluation Criteria panel** (collapsible): admin can add/edit/delete criteria;
users see read-only list. Active criteria are appended to the AI prompt as an
alignment evaluation section. The AI outputs RECOMMENDATION: PURSUE/PASS/UNCLEAR
which is parsed and displayed as a colour-coded verdict banner.
- **History tab**: every analysis is saved to ai_analysis_log (with verdict + criteria
snapshot). History treeview is filterable by verdict. Double-click restores full
output in the Analyze tab. Admins see all users; regular users see only their own.
- CriterionDialog has a live character counter with 500-char soft limit guidance.
- URL open and health-check probe both guarded by _validate_and_normalise_url.
.doc reading (legacy binary Word format) — 3-tier fallback:
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
@@ -296,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`).
@@ -316,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).
@@ -336,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 |
@@ -345,9 +407,9 @@ Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first
| ai_summary_view.py | MAX_CHARS_PER_FILE | 14,000 |
| ai_summary_view.py | _OFFICE_ADDRESS | "2815 Hartland Road, Falls Church, VA 22043, USA" |
| ai_summary_view.py | CriterionDialog._DESC_SOFT_LIMIT | 500 (chars, soft guidance only) |
| app.py | App._IDLE_THROTTLE_S | 5.0 (seconds between Motion-event timer resets) |
| admin_users_view.py | _PasswordResetDialog._AUTO_CLOSE_S | 120 (seconds before auto-close) |
| admin_users_view.py | _PasswordResetDialog._CLIP_CLEAR_S | 30 (seconds before clipboard wipe) |
| utils/scheduler.py | (timeout in _make_smtp_server) | 15 seconds |
---
@@ -370,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.
@@ -381,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.
---
@@ -403,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
---
@@ -429,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
@@ -439,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
+135 -79
View File
@@ -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
View File
@@ -0,0 +1,689 @@
# Website Checker — User Manual
**Version 1.0** | For all users
---
## Table of Contents
1. [Getting Started](#1-getting-started)
2. [Logging In](#2-logging-in)
3. [My Shift — The Daily Checklist](#3-my-shift--the-daily-checklist)
4. [Checking Off a Website](#4-checking-off-a-website)
5. [Site Health Indicator](#5-site-health-indicator)
6. [Viewing Credentials](#6-viewing-credentials)
7. [Shift Reminder Notifications](#7-shift-reminder-notifications)
8. [AI Document Summary](#8-ai-document-summary)
9. [Evaluation Criteria](#9-evaluation-criteria)
10. [Analysis History](#10-analysis-history)
11. [Change Password](#11-change-password)
12. [Admin — Dashboard](#12-admin--dashboard)
13. [Admin — Website Management](#13-admin--website-management)
14. [Admin — User Management](#14-admin--user-management)
15. [Admin — Shift Management](#15-admin--shift-management)
16. [Admin — Reports](#16-admin--reports)
17. [Admin — Activity Log](#17-admin--activity-log)
18. [Admin — Email Report Settings](#18-admin--email-report-settings)
19. [Admin — AI Summary Settings](#19-admin--ai-summary-settings)
20. [Session Timeout](#20-session-timeout)
21. [Keyboard Shortcuts](#21-keyboard-shortcuts)
22. [Troubleshooting](#22-troubleshooting)
---
## 1. Getting Started
Website Checker is a desktop application that helps your team systematically
verify that assigned websites are operational each shift. Every team member
logs in, checks each site, and marks it complete. Administrators can see
team-wide progress in real time, run reports, and receive automated daily emails.
**Who sees what:**
| Feature | Regular User | Administrator |
|---|---|---|
| My Shift checklist | ✔ | ✔ |
| AI Document Summary | ✔ | ✔ |
| Change Password | ✔ | ✔ |
| Dashboard & Reports | — | ✔ |
| Manage Websites / Users / Shifts | — | ✔ |
| Email Settings & Activity Log | — | ✔ |
| AI API key & model selection | — | ✔ |
| Add/Edit Evaluation Criteria | — | ✔ |
---
## 2. Logging In
1. Launch the application by running `app.py` (or the desktop shortcut).
2. Enter your **Username** and **Password**.
3. Press **Enter** or click **Sign In**.
**If your account is locked:** After 5 failed attempts your account is locked
for 15 minutes. The login screen shows a countdown timer. Wait for it to reach
zero and try again with the correct password.
**If you forget your password:** Contact your administrator. They can reset it
from the User Management panel.
---
## 3. My Shift — The Daily Checklist
After logging in, regular users land on the **My Shift** panel. This shows all
websites you are expected to check during your current shift today.
### Layout
```
┌─────────────────────────────────────────────────────┐
│ My Shift [ Search... ] [All ▾] [✓ Select All Unchecked] │
├──────────────────────────────────────────────────────┤
│ ● [✓] Portal ↗ Open 🔑 Credentials │
│ https://portal.gov Shift: Morning │
│ Note: Check dashboard for alerts [Edit] │
├──────────────────────────────────────────────────────┤
│ ● [ ] Vendor Hub ↗ Open 🔑 Credentials │
│ https://vendor.example.com Shift: Morning │
├──────────────────────────────────────────────────────┤
│ Progress: ████████░░ 1 / 2 checked (50%) │
└─────────────────────────────────────────────────────┘
```
### Elements
- **Colour dot** — health status of the site (see §5)
- **Checkbox** — tick to mark the site as checked for today
- **Site name / URL** — click either to open the site in your browser
- **🔑 Credentials** — appears only if credentials are stored; click to view
- **Note field** — free-text area to leave a per-site note for the shift
- **Progress bar** — shows how many sites you have checked vs total
### Filtering and search
- **Search box** — type any part of a site name or URL to filter the list
- **Dropdown filter** — choose All, Checked, or Unchecked
- **✓ Select All Unchecked** — ticks all currently visible unchecked sites at once
---
## 4. Checking Off a Website
1. Open the website (click the name or URL).
2. Verify the site is operational.
3. Tick the **checkbox** on the site card, or press **Space** when the card is focused.
4. The checkbox turns green and the progress bar advances.
To **uncheck** a site, tick the checkbox again. You can re-check as many times
as needed throughout the shift.
**Weekly sites** appear only once per week. Once checked, they disappear from
your list until the following Monday.
---
## 5. Site Health Indicator
The coloured dot next to each site name is automatically refreshed in the
background when you open the checklist.
| Colour | Meaning |
|---|---|
| 🟢 Green | Site is reachable and responding quickly (under 3 seconds) |
| 🟡 Amber | Site is reachable but slow (over 3 seconds), OR responding with a 4xx access restriction |
| 🔴 Red | Site is unreachable — network error or server failure |
**Amber (restricted)** means the server responded — it is online — but rejected
the automated probe request. This is normal for sites that block automated checks.
Open the site manually to verify it is actually working.
Hover over the dot to see the tooltip with exact response time and status.
---
## 6. Viewing Credentials
If your administrator has stored login credentials for a site, a **🔑 Credentials**
button appears on that site's card.
1. Click **🔑 Credentials**.
2. A popup appears showing all stored credential sets for that site.
3. Click **📋** next to the username to copy it to the clipboard (flashes "Copied!" for 2 seconds).
4. Click **📋** next to the password to copy it (also clears the clipboard automatically after 15 seconds).
5. Click **👁** to reveal the password on screen.
The popup closes when you click outside it or press Escape.
---
## 7. Shift Reminder Notifications
If you have unchecked sites when your shift is about to end, the app sends a
desktop notification listing all unchecked sites together.
- The reminder fires **15 minutes before your shift end time** by default.
- One notification covers all unchecked sites in the shift — you will not receive
a separate popup per site.
- Example: *"3 sites unchecked: Portal, Vendor Hub, SAM.gov. Shift ends at 17:00 (15 min remaining)."*
If the desktop notification system is unavailable, an in-app toast message appears
in the bottom-right corner instead.
---
## 8. AI Document Summary
The **🤖 AI Summary** panel lets you upload government solicitation documents
and receive an AI-generated extraction of key procurement fields, plus an
alignment recommendation based on your company's evaluation criteria.
### Uploading documents
1. Click ** Add Files** in the left pane.
2. Select one or more files. Supported formats:
- PDF (`.pdf`)
- Word documents (`.docx`, `.doc`)
- Excel spreadsheets (`.xlsx`, `.xls`)
- Text files (`.txt`, `.md`, `.csv`)
3. The file list shows each file name and size.
4. To remove a file, right-click it and choose **Remove selected**, or use **✕ Clear All**.
### Running the analysis
1. Click **✨ Analyze with AI**.
2. The status bar shows progress as each file is read and the AI processes them.
3. Results appear in the right pane when complete.
### What the AI extracts
For each document, the AI identifies and labels:
| Field | Example |
|---|---|
| Solicitation Number | W912DR-25-R-0042 |
| Solicitation Type | RFP, RFQ, IFB |
| Set-Aside | Small Business, 8(a), N/A |
| Description / Scope of Work | Summary of required services |
| Work Site / Location | Full address of performance site |
| Pre-Proposal Conference | Date, time, address, mandatory or optional |
| Point of Contact (POC) | Name, phone, email |
| Total Square Footage | If applicable |
| Driving Distance & Travel Time | From office to conference/site (AI estimate) |
| Last Day to Submit Questions | Date |
| Due Date & Time | Submission deadline |
| Other Notable Requirements | Bonding, insurance, certifications |
After the per-document breakdown, the AI produces an **Overall Summary** with:
- **A. Scope of Work** — what is being requested and key performance requirements
- **B. Contract Period** — base period, option years, anticipated start date
- **C. Proposal Submission Requirements** — documents, formatting, evaluation criteria
- **D. Key Deadlines & Action Items** — all critical dates in chronological order
### Saving and copying results
- **📋 Copy** — copies the full output text to the clipboard
- **💾 Save as TXT** — saves the output to a text file you choose
- **🗑 Clear** — resets the output panel
---
## 9. Evaluation Criteria
When your administrator has configured evaluation criteria, the AI automatically
assesses the solicitation against each one and issues a recommendation.
### The criteria panel
The **📋 Evaluation Criteria** panel sits below the page header and is collapsible.
It shows all currently configured criteria with their title, description, sort order,
and active status.
Criteria marked **✔ Active** are included in every AI analysis you run.
Criteria marked **✘ Inactive** are shown for reference but not sent to the AI.
### The alignment verdict
When active criteria exist, the analysis output includes a colour-coded banner:
| Banner | Meaning |
|---|---|
| ✅ Green — PURSUE | The opportunity strongly aligns with your criteria |
| 🚫 Red — PASS | The opportunity fails one or more key criteria |
| ⚠️ Amber — UNCLEAR | Insufficient information in the documents to decide |
Below the banner, the AI lists each criterion and explains whether the solicitation
**MEETS**, **DOES NOT MEET**, or **PARTIALLY MEETS** it, citing specific details
from the documents. It ends with a 2-3 sentence executive summary.
### For administrators — managing criteria
Click ** Add Criterion** in the criteria panel header to open the Add Criterion dialog.
| Field | Description |
|---|---|
| Title * | Short name shown in the treeview (e.g. "Geographic Range") |
| Description * | Full criterion text sent to the AI — be specific |
| Sort Order | Lower numbers appear first |
| Active | Uncheck to exclude from AI analysis without deleting |
**Tips for writing effective criteria:**
- Be specific and measurable: *"Work site must be within 50 miles of Falls Church, VA"*
is better than *"Site must be nearby."*
- One criterion per idea — don't combine multiple requirements in one description.
- The description has a **500-character soft limit**. Longer descriptions consume
token budget that would otherwise go to the document content.
To **edit** a criterion, select it in the list and click **✎ Edit**, or double-click the row.
To **delete** a criterion, select it and click **✕ Delete**. You will be asked to confirm.
---
## 10. Analysis History
Every analysis you run is automatically saved. Click the **🕑 History** tab to review past results.
### History tab features
- **Treeview** — shows Date/Time, Files analyzed, Model used, and Verdict for each analysis.
Administrators see all users' analyses; regular users see only their own.
- **Verdict filter** — radio buttons let you filter by All / PURSUE / PASS / UNCLEAR / No verdict.
- **Detail strip** — selecting a row shows the criteria snapshot and other metadata at the bottom.
- **↻ Refresh** — reloads the history from the database.
- **🔍 View Result** — loads the full AI output for the selected analysis back into the
Analyze tab output panel, including the verdict banner. You can then copy or save it.
- **Double-click** on any row does the same as View Result.
The History tab refreshes automatically whenever you switch to it.
---
## 11. Change Password
All users can change their own password at any time.
1. Click **Change Password** in the sidebar.
2. Enter your **Current Password**.
3. Enter and confirm your **New Password**.
4. Click **Change Password**.
Password requirements:
- At least 8 characters
- At least one uppercase letter
- At least one digit (09)
- At least one special character (`!@#$%^&*` etc.)
A strength indicator shows how strong the new password is as you type.
---
## 12. Admin — Dashboard
The Dashboard is the administrator's home screen. It refreshes automatically every 60 seconds.
### KPI cards (top row)
| Card | Description |
|---|---|
| Active Users | Number of users with active accounts |
| Sites Checked Today | Total site checks recorded today across all users |
| Team Completion | Percentage of expected checks completed today |
| Unchecked Sites | Number of sites not yet checked today |
### Per-user progress table
Shows each active user with their checked count, total expected sites today, and
completion percentage. Completion is colour-coded:
- 🟢 Green — 100% complete
- 🟡 Amber — partially complete
- 🔴 Red — not started
Users with no shifts scheduled today are excluded from the table automatically.
---
## 13. Admin — Website Management
Navigate to **Websites** in the sidebar.
### Adding a website
1. Click ** Add Website**.
2. Fill in the required fields:
| Field | Description |
|---|---|
| Name * | Display name shown on user dashboards |
| URL * | Full web address (https:// will be added if omitted) |
| Check Type | Daily (shown every shift day) or Weekly (once per week) |
| Visibility | All Users or Assigned Only |
| Note | Optional note shown on the user's site card |
3. If **Assigned Only** is selected, a user assignment panel appears — tick the users who should see this site.
4. Optionally expand **🔑 Credentials** to add login credentials for the site.
5. Click **Save**.
### Credentials
Each website can have multiple credential sets (e.g. Admin login, Read-only login).
Each set has a **Label**, **Username**, and **Password**.
Passwords are stored encrypted and are only revealed when the user clicks 👁 in the credentials popup.
### Editing a website
Select a website in the list and click **✎ Edit**, or double-click the row.
All fields can be changed, including credentials.
### Deactivating / deleting a website
- **Deactivate** (recommended): Uncheck **Active** in the edit dialog. The site is hidden from
users but historical check records are preserved.
- **Delete**: Select and click **✕ Delete**. This is permanent.
---
## 14. Admin — User Management
Navigate to **Users** in the sidebar.
### Adding a user
1. Click ** Add User**.
2. Fill in Username, Full Name, Role (Admin or User), and Password.
3. Click **Save**.
### Editing a user
Select a user and click **✎ Edit** or double-click. You can change their name,
role, active status, and set a new password.
### Resetting a password
1. Select a user and click **🔑 Reset Password**.
2. Confirm the prompt.
3. A secure dialog appears with a randomly generated 16-character temporary password.
4. Click **📋 Copy** to copy it to the clipboard (the clipboard is wiped after 30 seconds).
5. Share the temporary password with the user securely (e.g. by phone).
6. The dialog auto-closes after 120 seconds.
7. The user should change this password immediately after logging in.
**Notes:**
- You cannot reset your own password here — use Change Password in the sidebar.
- The last active administrator account cannot be deleted.
### Deactivating vs deleting
- **Deactivate** (is_active = off): The user cannot log in but their records are preserved.
- **Delete**: Permanent. Use deactivate first if you want to keep audit history.
---
## 15. Admin — Shift Management
Navigate to **Shifts** in the sidebar.
### What is a shift?
A shift defines which websites a group of users should check, on which days of the week,
and during which time window. A user can belong to multiple shifts.
### Adding a shift
1. Click ** New Shift**.
2. Fill in:
| Field | Description |
|---|---|
| Shift Name | e.g. "Morning Team", "Weekend Check" |
| Days of Week | Tick each day this shift runs |
| Start Time | When the shift begins (HH:MM) |
| End Time | When the shift ends — used for reminder notifications |
| Note | Optional internal note |
3. On the **Users** tab, tick the users assigned to this shift.
4. On the **Websites** tab, tick the websites this shift should check (drag to reorder).
5. Click **Save**.
### Show inactive shifts
By default, deactivated shifts are hidden. Tick **Show inactive** in the toolbar
to display them (shown in grey).
### Exporting shifts to PDF
Click **⬇ Export PDF** to generate a printable PDF showing all active shifts,
their assigned users, and their website lists.
---
## 16. Admin — Reports
Navigate to **Reports** in the sidebar. There are four tabs.
### Shift Detail
Shows every check-in for a specific user and date range.
Select a user, set a date range, and click **Generate**.
Export to CSV or Excel with the export buttons.
### Unchecked Sites
Shows websites that were NOT checked on a given date, scoped to each user's actual shift.
If no date is selected, defaults to today.
Filter by a specific user or view all users.
### Summary
Shows per-user per-day statistics: sites checked, total expected, and completion percentage.
Total is based on that user's actual shift assignment for that day — not a global site count.
### Completion Chart
A bar chart showing today's completion percentage per user.
Bars are colour-coded: green ≥ 100%, amber ≥ 50%, red < 50%.
---
## 17. Admin — Activity Log
Navigate to **Activity Log** in the sidebar.
The log shows every significant action taken in the system, including:
logins, logouts, session timeouts, account lockouts, all create/edit/delete operations,
password changes and resets, website checks, exports, email setting changes,
and AI criterion changes.
Each entry shows: timestamp, acting user, action type, affected record, and detail notes.
Use the search bar to filter by username, action type, or any text in the detail field.
Use the date range pickers to narrow the time window.
---
## 18. Admin — Email Report Settings
Open **Settings** (gear icon in sidebar) → **Email Report Settings**, or click the
email icon if visible.
### Configuration fields
| Field | Description |
|---|---|
| Enable daily emails | Master on/off switch |
| SMTP Host | Your mail server address |
| Security | STARTTLS (port 587), SSL/TLS (port 465), or None (port 25) |
| SMTP Port | Auto-filled when Security mode is selected; can be changed manually |
| SMTP Username | Usually your full email address |
| SMTP Password | For Gmail, use an App Password — not your Gmail password |
| Recipients | Comma-separated email addresses to receive the report |
| Send Time | HH:MM (24-hour) — the time the report is sent each day |
### Testing the connection
**🔌 Test Connection** runs a step-by-step diagnostic:
1. **DNS** — can the server name be resolved?
2. **TCP** — can a connection be made to the port?
3. **TLS** — does the TLS/security handshake succeed?
4. **Auth** — are the username and password accepted?
The status line reports exactly which step failed and what to check.
**📧 Send Test Email** sends a real test message to the configured recipients.
Use this to confirm full end-to-end delivery after the connection test passes.
### Common setups
**Office 365 / Exchange Online:**
- Security: STARTTLS
- Port: 587
- Username: full email address
- Password: your Microsoft account password or app password
**Gmail (Google Workspace):**
- Security: SSL / TLS
- Port: 465
- Username: full Gmail address
- Password: App Password (not your Gmail password)
→ Enable 2-Step Verification → myaccount.google.com/apppasswords
**Internal relay:**
- Security: None
- Port: 25
- Username/Password: may not be required
### What the daily report contains
The report email lists every active user who had shifts today, showing:
- Number of sites checked
- Total sites expected for their shift
- Completion percentage (colour-coded)
Users with no shift scheduled today are excluded, with a footnote showing the count.
---
## 19. Admin — AI Summary Settings
### Setting up the API key
1. Get a free API key at **https://console.groq.com** (no credit card required).
2. Open **🤖 AI Summary** in the sidebar.
3. Enter the API key in the **Groq API Key** field.
4. Select a model (see below).
5. Click **💾 Save Settings**.
The API key is stored encrypted on your machine and is never shared with other users.
### Available models
| Model | Best for |
|---|---|
| llama-3.3-70b-versatile | Best overall quality — recommended default |
| llama-3.1-8b-instant | Faster responses, slightly lower quality |
| gemma2-9b-it | Good alternative for shorter documents |
| mixtral-8x7b-32768 | Large context window for very long documents |
### Managing evaluation criteria
See §9 above for full details on adding, editing, and deleting criteria.
**Key points for admins:**
- Criteria are shared — all users' analyses use the same active criteria.
- Deactivating a criterion (unchecking Active) removes it from future AI prompts
without deleting it — useful for temporarily suspending a criterion.
- The full description text is saved in the audit log each time a criterion is updated,
so you can always reconstruct what the AI was evaluating against at any historical date.
---
## 20. Session Timeout
The application automatically signs you out after **30 minutes of inactivity**
(no mouse movement, key presses, or clicks).
One minute before timeout, a warning dialog appears. Click **Stay Signed In** to
reset the timer. If you don't respond, you are signed out and must log in again.
Any unsaved work is lost.
---
## 21. Keyboard Shortcuts
Available on the My Shift dashboard:
| Shortcut | Action |
|---|---|
| `Ctrl+F` | Focus the search box |
| `Ctrl+A` | Select all unchecked sites |
| `Ctrl+Enter` | Mark selected site as checked |
| `Space` | Toggle checkbox on focused site card |
| `Escape` | Clear search / deselect |
---
## 22. Troubleshooting
### I can't log in
- Check Caps Lock — passwords are case-sensitive.
- After 5 failed attempts, your account is locked for 15 minutes.
Wait for the countdown to finish and try again.
- Contact your administrator if you need a password reset.
### My shift shows no sites
- You may not be assigned to any active shift scheduled for today.
- Contact your administrator to verify your shift assignment and the days-of-week setting.
### The health dot is always red for a site
- The site may block automated probes (returns 4xx — shown as amber, not red).
- A red dot means a network error — the server is not responding at all.
- Check your internet connection. If other sites are green, the specific site may be down.
### The AI analysis fails with "401"
- Your Groq API key is invalid or expired.
- Log in to https://console.groq.com, generate a new key, and update it in AI Summary settings.
### The AI analysis says "No readable text found"
- The PDF may be a scanned image rather than a text-based PDF.
- Try using a version of the document that is copy-pasteable, or a Word/Excel version.
### Email test fails at Step 3 (TLS)
- You are using the wrong security mode for your mail server.
- Try switching from STARTTLS to SSL/TLS (port 465) or vice versa.
- Contact your IT department to confirm the correct SMTP settings.
### Email test fails at Step 4 (Auth)
- For Gmail/Google Workspace: you must use an **App Password**, not your regular password.
Go to myaccount.google.com/apppasswords to create one.
- For Office 365: your account may require Modern Authentication or an app password.
Contact your IT department.
### I can't see the Evaluation Criteria panel
- The panel is present for all users but may be collapsed. Click **▼ Expand** to open it.
### My analysis history is empty
- History is only saved for analyses run after the feature was deployed.
Earlier analyses are not retroactively added.
- Check that you are on the 🕑 History tab (not the ✨ Analyze tab).
- Click **↻ Refresh** to reload from the database.
### The app freezes during AI analysis
- The AI call runs in a background thread — the UI should remain responsive.
- If the app appears frozen, wait up to 60 seconds. Long documents take time to process.
- If it remains unresponsive, close and reopen the application. The analysis may have failed silently — check `app.log`.
---
*For technical issues not covered here, ask your system administrator to check `app.log`
in the application directory for detailed error messages.*
+9
View File
@@ -159,12 +159,14 @@ class App(tk.Tk):
("📑 Reports", "reports", self._show_reports),
("📧 Email Reports","email", self._show_email_settings),
("🤖 AI Summary", "ai_summary", self._show_ai_summary),
("📌 Bid Tracker", "bid_tracker", self._show_bid_tracker),
("⚙ Settings", "settings", self._show_settings),
]
else:
nav_items = [
("📊 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:
@@ -329,6 +331,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
@@ -524,6 +532,7 @@ class App(tk.Tk):
"reports": self._show_reports,
"email": self._show_email_settings,
"ai_summary": self._show_ai_summary,
"bid_tracker": self._show_bid_tracker,
"settings": self._show_settings,
}
if active in nav_map:
+42 -13
View File
@@ -216,6 +216,7 @@ def initialize_database():
name VARCHAR(200) NOT NULL,
url TEXT NOT NULL,
check_type ENUM('daily','weekly') NOT NULL DEFAULT 'daily',
visibility ENUM('all','assigned') NOT NULL DEFAULT 'all',
note TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_by INT,
@@ -228,9 +229,9 @@ def initialize_database():
CREATE TABLE IF NOT EXISTS website_credentials (
id INT AUTO_INCREMENT PRIMARY KEY,
website_id INT NOT NULL,
username VARCHAR(200) NOT NULL,
password VARCHAR(255) NOT NULL,
label VARCHAR(100),
username VARCHAR(200),
password TEXT,
FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
@@ -253,7 +254,7 @@ def initialize_database():
entity VARCHAR(100),
entity_id INT,
detail TEXT,
logged_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
@@ -261,9 +262,9 @@ def initialize_database():
CREATE TABLE IF NOT EXISTS shifts (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(200) NOT NULL,
days_of_week VARCHAR(20) NOT NULL DEFAULT '1234567',
start_time TIME NOT NULL DEFAULT '00:00:00',
end_time TIME NOT NULL DEFAULT '23:59:59',
days_of_week VARCHAR(7) NOT NULL DEFAULT '23456',
start_time TIME NOT NULL DEFAULT '08:00:00',
end_time TIME NOT NULL DEFAULT '17:00:00',
is_active TINYINT(1) NOT NULL DEFAULT 1,
note TEXT,
created_by INT,
@@ -335,6 +336,34 @@ def initialize_database():
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS bid_tracker (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(300) NOT NULL,
url TEXT NOT NULL,
source VARCHAR(200) NULL,
solicitation_number VARCHAR(100) NULL,
status ENUM('open','monitoring','awarded','no_bid','cancelled')
NOT NULL DEFAULT 'open',
due_date DATE NULL,
notes TEXT NULL,
added_by INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS bid_updates (
id INT AUTO_INCREMENT PRIMARY KEY,
bid_id INT NOT NULL,
user_id INT,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (bid_id) REFERENCES bid_tracker(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
]
conn = None
@@ -365,7 +394,7 @@ def initialize_database():
conn.commit()
logger.info("Migration: added check_type column to websites table.")
# Add failed_attempts column to users if it doesn't exist
# Add failed_attempts / locked_until columns to users if absent
cursor.execute(
"""
SELECT COUNT(*) FROM information_schema.COLUMNS
@@ -384,7 +413,7 @@ def initialize_database():
conn.commit()
logger.info("Migration: added failed_attempts and locked_until columns to users table.")
# Add visibility column to websites if it doesn't exist
# Add visibility column to websites if absent
cursor.execute(
"""
SELECT COUNT(*) FROM information_schema.COLUMNS
@@ -402,10 +431,8 @@ def initialize_database():
conn.commit()
logger.info("Migration: added visibility column to websites table.")
# Confirm creation of new tables added post-initial-deployment
# These use CREATE TABLE IF NOT EXISTS so they are safe on first run.
# We log confirmation so operators can verify the upgrade applied.
for new_table in ("ai_criteria", "ai_analysis_log"):
# Confirm presence of tables added post-initial-deployment
for new_table in ("ai_criteria", "ai_analysis_log", "bid_tracker", "bid_updates"):
cursor.execute(
"""
SELECT COUNT(*) FROM information_schema.TABLES
@@ -418,7 +445,7 @@ def initialize_database():
if table_exists:
logger.info(f"Table '{new_table}' confirmed present.")
else:
logger.warning(f"Table '{new_table}' was not created check DDL.")
logger.warning(f"Table '{new_table}' was not created -- check DDL.")
# Seed default admin if users table is empty
cursor.execute("SELECT COUNT(*) FROM users")
@@ -439,3 +466,5 @@ def initialize_database():
finally:
if conn:
conn.close()
+420
View File
@@ -1693,3 +1693,423 @@ 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()
+761
View File
@@ -0,0 +1,761 @@
"""
views/bid_tracker_view.py Bid / Opportunity Follow-Up Tracker
Any logged-in user can:
- View all tracked bids and their latest status
- Add a new bid (URL + metadata)
- Post updates on any bid visible to everyone
- Edit or delete their own bids (admins can edit/delete any bid)
Layout:
Left pane : filterable bid list with status badges
Right pane : detail panel metadata + scrollable update timeline
"""
import os
import logging
import tkinter as tk
from tkinter import ttk
import webbrowser
from utils.ui_helpers import (
COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL,
show_error, show_info, confirm_delete,
)
logger = logging.getLogger("bid_tracker_view")
# Status display config: (label, colour_key)
STATUS_META = {
"open": ("🟢 Open", "success"),
"monitoring": ("🔵 Monitoring", "accent"),
"awarded": ("🏆 Awarded", "warning"),
"no_bid": ("⛔ No Bid", "danger"),
"cancelled": ("🚫 Cancelled", "text_dim"),
}
class BidTrackerView(ttk.Frame):
def __init__(self, parent, current_user: dict):
super().__init__(parent)
self.current_user = current_user
self._is_admin = current_user.get("role") == "admin"
self._selected_bid_id = None
self._build_ui()
self._load_bids()
# ── Layout ────────────────────────────────────────────────────────────────
def _build_ui(self):
C = COLOURS
# Page header
hdr = ttk.Frame(self)
hdr.pack(fill="x", pady=(0, 10))
ttk.Label(hdr, text="📌 Bid Tracker",
style="Heading.TLabel").pack(side="left")
ttk.Label(hdr,
text="Track potential opportunities and follow up on updates.",
style="Dim.TLabel").pack(side="left", padx=(12, 0))
# Toolbar
toolbar = tk.Frame(self, bg=C["surface"], pady=8, padx=12)
toolbar.pack(fill="x", pady=(0, 8))
tk.Button(
toolbar, text=" Add Bid",
command=self._open_add_dialog,
bg=C["accent"], fg=C["white"],
activebackground=C["accent_hover"], activeforeground=C["white"],
relief="flat", font=FONT_BOLD, cursor="hand2",
padx=12, pady=5,
).pack(side="left")
tk.Button(
toolbar, text="↻ Refresh",
command=self._load_bids,
bg=C["surface2"], fg=C["text"],
activebackground=C["accent"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=5,
).pack(side="left", padx=(6, 0))
# Status filter
tk.Label(toolbar, text="Filter:",
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL).pack(side="left", padx=(16, 4))
self._filter_var = tk.StringVar(value="All")
filter_cb = ttk.Combobox(
toolbar, textvariable=self._filter_var,
values=["All"] + [v[0] for v in STATUS_META.values()],
state="readonly", width=14,
)
filter_cb.pack(side="left")
filter_cb.bind("<<ComboboxSelected>>", lambda _: self._load_bids())
# Search
tk.Label(toolbar, text="Search:",
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL).pack(side="left", padx=(16, 4))
self._search_var = tk.StringVar()
self._search_var.trace_add("write", lambda *_: self._load_bids())
tk.Entry(
toolbar, textvariable=self._search_var,
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT, width=22,
).pack(side="left", ipady=4)
# Two-pane split
pane = tk.PanedWindow(self, orient="horizontal",
bg=C["border"], sashwidth=4, sashrelief="flat")
pane.pack(fill="both", expand=True)
left = tk.Frame(pane, bg=C["bg"])
pane.add(left, minsize=300, width=360)
self._build_bid_list(left)
right = tk.Frame(pane, bg=C["bg"])
pane.add(right, minsize=380)
self._build_detail_panel(right)
def _build_bid_list(self, parent):
C = COLOURS
# Treeview
cols = ("Title", "Status", "Due Date", "Updates")
self._tree = ttk.Treeview(
parent, columns=cols, show="headings",
selectmode="browse",
)
col_widths = {"Title": 160, "Status": 90, "Due Date": 80, "Updates": 60}
for col in cols:
self._tree.heading(col, text=col)
self._tree.column(col, width=col_widths[col],
anchor="w" if col == "Title" else "center")
# Colour tags per status
self._tree.tag_configure("open", foreground=C["success"])
self._tree.tag_configure("monitoring", foreground=C["accent"])
self._tree.tag_configure("awarded", foreground=C["warning"])
self._tree.tag_configure("no_bid", foreground=C["danger"])
self._tree.tag_configure("cancelled", foreground=C["text_dim"])
vsb = ttk.Scrollbar(parent, orient="vertical",
command=self._tree.yview)
self._tree.configure(yscrollcommand=vsb.set)
vsb.pack(side="right", fill="y")
self._tree.pack(side="left", fill="both", expand=True)
self._tree.bind("<<TreeviewSelect>>", self._on_bid_select)
self._tree.bind("<Double-1>", lambda _: self._open_edit_dialog())
def _build_detail_panel(self, parent):
C = COLOURS
# Action toolbar (right pane)
act = tk.Frame(parent, bg=C["surface"], pady=8, padx=12)
act.pack(fill="x")
self._edit_btn = tk.Button(
act, text="✎ Edit Bid",
command=self._open_edit_dialog,
bg=C["surface2"], fg=C["text"],
activebackground=C["accent"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=4,
)
self._edit_btn.pack(side="left", padx=(0, 4))
self._del_btn = tk.Button(
act, text="✕ Delete",
command=self._delete_bid,
bg=C["surface2"], fg=C["danger"],
activebackground=C["danger"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=4,
)
self._del_btn.pack(side="left")
self._open_btn = tk.Button(
act, text="↗ Open URL",
command=self._open_url,
bg=C["surface2"], fg=C["accent"],
activebackground=C["accent"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=4,
)
self._open_btn.pack(side="right")
# Metadata card
self._meta_frame = tk.Frame(parent, bg=C["surface"], padx=14, pady=10)
self._meta_frame.pack(fill="x", pady=(0, 4))
self._meta_title = tk.Label(
self._meta_frame, text="Select a bid to view details.",
bg=C["surface"], fg=C["text"],
font=FONT_BOLD, wraplength=480, justify="left",
)
self._meta_title.pack(anchor="w")
self._meta_sub = tk.Label(
self._meta_frame, text="",
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL, wraplength=480, justify="left",
)
self._meta_sub.pack(anchor="w", pady=(2, 0))
self._meta_notes = tk.Label(
self._meta_frame, text="",
bg=C["surface"], fg=C["text"],
font=FONT_SMALL, wraplength=480, justify="left",
)
self._meta_notes.pack(anchor="w", pady=(4, 0))
# ── Updates section ───────────────────────────────────────────────
upd_hdr = tk.Frame(parent, bg=C["bg"], pady=4, padx=12)
upd_hdr.pack(fill="x")
tk.Label(upd_hdr, text="📝 Updates",
bg=C["bg"], fg=C["text"],
font=FONT_BOLD).pack(side="left")
# Post update entry
compose = tk.Frame(parent, bg=C["surface"], padx=12, pady=8)
compose.pack(fill="x")
self._update_txt = tk.Text(
compose, height=3, wrap="word",
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT,
)
self._update_txt.pack(fill="x", pady=(0, 4))
# Placeholder behaviour
self._update_txt.insert("1.0", "Write an update, e.g. 'Amendment 1 issued — due date extended to...'")
self._update_txt.config(fg=C["text_dim"])
self._update_txt.bind("<FocusIn>", self._on_update_focus_in)
self._update_txt.bind("<FocusOut>", self._on_update_focus_out)
tk.Button(
compose, text="📤 Post Update",
command=self._post_update,
bg=C["accent"], fg=C["white"],
activebackground=C["accent_hover"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=4,
).pack(anchor="e")
# Scrollable update timeline
timeline_frame = tk.Frame(parent, bg=C["bg"])
timeline_frame.pack(fill="both", expand=True, padx=4, pady=(4, 0))
vsb = ttk.Scrollbar(timeline_frame, orient="vertical")
vsb.pack(side="right", fill="y")
self._update_canvas = tk.Canvas(
timeline_frame, bg=C["bg"],
highlightthickness=0,
yscrollcommand=vsb.set,
)
self._update_canvas.pack(side="left", fill="both", expand=True)
vsb.config(command=self._update_canvas.yview)
self._update_inner = tk.Frame(self._update_canvas, bg=C["bg"])
self._canvas_window = self._update_canvas.create_window(
(0, 0), window=self._update_inner, anchor="nw")
self._update_inner.bind("<Configure>", self._on_inner_configure)
self._update_canvas.bind("<Configure>", self._on_canvas_configure)
# Mousewheel scrolling
self._update_canvas.bind("<Enter>",
lambda _: self._update_canvas.bind_all(
"<MouseWheel>", self._on_mousewheel))
self._update_canvas.bind("<Leave>",
lambda _: self._update_canvas.unbind_all("<MouseWheel>"))
self._set_detail_buttons_state("disabled")
# ── Canvas / scroll helpers ───────────────────────────────────────────────
def _on_inner_configure(self, event=None):
self._update_canvas.configure(
scrollregion=self._update_canvas.bbox("all"))
def _on_canvas_configure(self, event):
self._update_canvas.itemconfig(
self._canvas_window, width=event.width)
def _on_mousewheel(self, event):
self._update_canvas.yview_scroll(
int(-1 * (event.delta / 120)), "units")
# ── Placeholder helpers ───────────────────────────────────────────────────
def _on_update_focus_in(self, event=None):
C = COLOURS
if self._update_txt.cget("fg") == C["text_dim"]:
self._update_txt.delete("1.0", "end")
self._update_txt.config(fg=C["text"])
def _on_update_focus_out(self, event=None):
C = COLOURS
if not self._update_txt.get("1.0", "end-1c").strip():
self._update_txt.delete("1.0", "end")
self._update_txt.insert("1.0",
"Write an update, e.g. 'Amendment 1 issued — due date extended to...'")
self._update_txt.config(fg=C["text_dim"])
def _get_update_text(self) -> str:
C = COLOURS
txt = self._update_txt.get("1.0", "end-1c").strip()
if self._update_txt.cget("fg") == C["text_dim"]:
return ""
return txt
# ── Data loading ─────────────────────────────────────────────────────────
def _load_bids(self, *_):
from models import get_all_bids
# Map display label back to DB key for filter
filter_label = self._filter_var.get()
status_key = ""
for k, (label, _) in STATUS_META.items():
if label == filter_label:
status_key = k
break
search = self._search_var.get().lower().strip()
self._tree.delete(*self._tree.get_children())
try:
bids = get_all_bids(status_filter=status_key)
except Exception as e:
show_error(f"Failed to load bids:\n{e}")
return
for bid in bids:
if search and search not in (bid.get("title") or "").lower() \
and search not in (bid.get("source") or "").lower() \
and search not in (bid.get("solicitation_number") or "").lower():
continue
status = bid.get("status", "open")
label, _ = STATUS_META.get(status, (status, "text"))
due = str(bid.get("due_date") or "")
upd_count = bid.get("update_count", 0)
self._tree.insert(
"", "end", iid=str(bid["id"]), tags=(status,),
values=(bid["title"], label, due, upd_count),
)
# Restore selection if still present
if self._selected_bid_id and str(self._selected_bid_id) in \
self._tree.get_children():
self._tree.selection_set(str(self._selected_bid_id))
self._tree.see(str(self._selected_bid_id))
else:
self._selected_bid_id = None
self._clear_detail()
def _on_bid_select(self, event=None):
sel = self._tree.selection()
if not sel:
return
self._selected_bid_id = int(sel[0])
self._load_detail(self._selected_bid_id)
self._set_detail_buttons_state("normal")
def _load_detail(self, bid_id: int):
from models import get_bid, get_bid_updates
C = COLOURS
bid = get_bid(bid_id)
if not bid:
return
status = bid.get("status", "open")
label, colour_key = STATUS_META.get(status, (status, "text"))
due = str(bid.get("due_date") or "Not specified")
sol = bid.get("solicitation_number") or ""
src = bid.get("source") or ""
added_by = bid.get("added_by_username") or ""
added_at = str(bid.get("created_at") or "")[:16]
self._meta_title.config(text=bid["title"])
self._meta_sub.config(
text=(f"{label} | Due: {due} | Sol #: {sol} | "
f"Source: {src} | Added by: {added_by} ({added_at})")
)
notes = bid.get("notes") or ""
self._meta_notes.config(
text=f"Notes: {notes}" if notes else "",
)
# Hide edit/delete for non-owners unless admin
is_owner = bid.get("added_by") == self.current_user["id"]
can_edit = self._is_admin or is_owner
state = "normal" if can_edit else "disabled"
self._edit_btn.config(state=state)
self._del_btn.config(state=state)
# Render update timeline
for widget in self._update_inner.winfo_children():
widget.destroy()
updates = get_bid_updates(bid_id)
if not updates:
tk.Label(
self._update_inner,
text="No updates yet. Be the first to post one.",
bg=C["bg"], fg=C["text_dim"],
font=FONT_SMALL,
).pack(anchor="w", padx=8, pady=8)
else:
for upd in updates:
self._render_update_card(upd)
self._update_canvas.yview_moveto(0)
def _render_update_card(self, upd: dict):
C = COLOURS
card = tk.Frame(self._update_inner, bg=C["surface"],
padx=10, pady=8)
card.pack(fill="x", padx=4, pady=(0, 4))
# Header row
hdr = tk.Frame(card, bg=C["surface"])
hdr.pack(fill="x")
poster = upd.get("posted_by_full_name") or upd.get("posted_by_username") or "Unknown"
dt_str = str(upd.get("created_at") or "")[:16]
tk.Label(
hdr, text=f"👤 {poster}",
bg=C["surface"], fg=C["text"],
font=FONT_BOLD,
).pack(side="left")
tk.Label(
hdr, text=dt_str,
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL,
).pack(side="left", padx=(8, 0))
# Delete button — own update or admin
is_own = upd.get("user_id") == self.current_user["id"]
if is_own or self._is_admin:
tk.Button(
hdr, text="",
command=lambda uid=upd["id"]: self._delete_update(uid),
bg=C["surface"], fg=C["text_dim"],
activebackground=C["danger"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
).pack(side="right")
# Content
tk.Label(
card, text=upd.get("content", ""),
bg=C["surface"], fg=C["text"],
font=FONT, wraplength=460, justify="left", anchor="w",
).pack(fill="x", pady=(4, 0))
def _clear_detail(self):
self._meta_title.config(text="Select a bid to view details.")
self._meta_sub.config(text="")
self._meta_notes.config(text="")
for widget in self._update_inner.winfo_children():
widget.destroy()
self._set_detail_buttons_state("disabled")
def _set_detail_buttons_state(self, state: str):
self._edit_btn.config(state=state)
self._del_btn.config(state=state)
self._open_btn.config(state=state)
# ── Actions ───────────────────────────────────────────────────────────────
def _open_url(self):
if not self._selected_bid_id:
return
from models import get_bid
bid = get_bid(self._selected_bid_id)
if bid and bid.get("url"):
try:
webbrowser.open(bid["url"])
except Exception as e:
show_error(f"Could not open URL:\n{e}")
def _open_add_dialog(self):
BidDialog(self, self.current_user,
bid_data=None, on_save=self._load_bids)
def _open_edit_dialog(self):
if not self._selected_bid_id:
show_error("Please select a bid to edit.")
return
from models import get_bid
bid = get_bid(self._selected_bid_id)
if not bid:
show_error("Bid not found.")
return
# Enforce ownership
is_owner = bid.get("added_by") == self.current_user["id"]
if not self._is_admin and not is_owner:
show_error("You can only edit bids you added.")
return
BidDialog(self, self.current_user,
bid_data=bid, on_save=self._after_edit)
def _after_edit(self):
"""Reload list and refresh detail panel after an edit."""
self._load_bids()
if self._selected_bid_id:
self._load_detail(self._selected_bid_id)
def _delete_bid(self):
if not self._selected_bid_id:
return
from models import get_bid, delete_bid
bid = get_bid(self._selected_bid_id)
if not bid:
return
is_owner = bid.get("added_by") == self.current_user["id"]
if not self._is_admin and not is_owner:
show_error("You can only delete bids you added.")
return
if confirm_delete(bid["title"]):
try:
delete_bid(self.current_user["id"], self._selected_bid_id)
self._selected_bid_id = None
self._load_bids()
self._clear_detail()
show_info("Bid deleted.")
except Exception as e:
show_error(f"Delete failed:\n{e}")
def _post_update(self):
if not self._selected_bid_id:
show_error("Please select a bid first.")
return
content = self._get_update_text()
if not content:
show_error("Please write an update before posting.")
return
try:
from models import add_bid_update
add_bid_update(self.current_user["id"],
self._selected_bid_id, content)
# Clear the text box and reset placeholder
self._update_txt.delete("1.0", "end")
self._on_update_focus_out()
# Refresh the update timeline
self._load_detail(self._selected_bid_id)
# Refresh update count in the tree
self._load_bids()
if self._selected_bid_id and \
str(self._selected_bid_id) in self._tree.get_children():
self._tree.selection_set(str(self._selected_bid_id))
except Exception as e:
show_error(f"Failed to post update:\n{e}")
def _delete_update(self, update_id: int):
from tkinter import messagebox
if not messagebox.askyesno(
"Delete Update",
"Are you sure you want to delete this update?"):
return
try:
from models import delete_bid_update
delete_bid_update(self.current_user["id"], update_id)
self._load_detail(self._selected_bid_id)
self._load_bids()
if self._selected_bid_id and \
str(self._selected_bid_id) in self._tree.get_children():
self._tree.selection_set(str(self._selected_bid_id))
except Exception as e:
show_error(f"Failed to delete update:\n{e}")
# ── Add / Edit Bid Dialog ─────────────────────────────────────────────────────
class BidDialog(tk.Toplevel):
"""Modal dialog for adding or editing a bid/opportunity."""
def __init__(self, parent, current_user: dict,
bid_data, on_save):
super().__init__(parent)
self.current_user = current_user
self.bid_data = bid_data
self.on_save = on_save
self.is_edit = bid_data is not None
self.title("Edit Bid" if self.is_edit else "Add Bid")
self.configure(bg=COLOURS["bg"])
self.resizable(False, False)
self.grab_set()
self._build_ui()
self._centre()
def _centre(self):
self.update_idletasks()
w, h = 560, 520
x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}")
def _build_ui(self):
C = COLOURS
ttk.Label(self,
text="Edit Opportunity" if self.is_edit else "Add Opportunity",
style="Heading.TLabel").pack(anchor="w", padx=24, pady=(20, 4))
form = ttk.Frame(self)
form.pack(fill="x", padx=24, pady=8)
form.columnconfigure(1, weight=1)
def lbl(text, row):
ttk.Label(form, text=text).grid(
row=row, column=0, sticky="w", padx=(0, 12), pady=6)
def entry(row, show=None):
var = tk.StringVar()
e = tk.Entry(form, textvariable=var,
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT, show=show or "")
e.grid(row=row, column=1, sticky="ew", ipady=5, pady=6)
return var
# Row 0 — Title
lbl("Title *", 0)
self._title_var = entry(0)
# Row 1 — URL
lbl("URL *", 1)
self._url_var = entry(1)
# Row 2 — Source
lbl("Source", 2)
self._source_var = entry(2)
# Row 3 — Solicitation #
lbl("Solicitation #", 3)
self._sol_var = entry(3)
# Row 4 — Status
lbl("Status", 4)
from models import BID_STATUSES
self._status_var = tk.StringVar(value="open")
status_frame = tk.Frame(form, bg=C["bg"])
status_frame.grid(row=4, column=1, sticky="w", pady=6)
for s in BID_STATUSES:
label, _ = STATUS_META.get(s, (s, "text"))
tk.Radiobutton(
status_frame, text=label,
variable=self._status_var, value=s,
bg=C["bg"], fg=C["text"],
activebackground=C["bg"], activeforeground=C["accent"],
selectcolor=C["surface2"],
font=FONT_SMALL, cursor="hand2",
).pack(side="left", padx=(0, 8))
# Row 5 — Due date
lbl("Due Date", 5)
self._due_var = tk.StringVar()
tk.Entry(form, textvariable=self._due_var,
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT, width=14).grid(
row=5, column=1, sticky="w", ipady=5, pady=6)
ttk.Label(form, text="YYYY-MM-DD",
style="Dim.TLabel").grid(
row=5, column=1, sticky="e", pady=6)
# Row 6 — Notes
lbl("Notes", 6)
notes_frame = tk.Frame(form, bg=C["surface2"])
notes_frame.grid(row=6, column=1, sticky="ew", pady=6)
notes_vsb = ttk.Scrollbar(notes_frame, orient="vertical")
notes_vsb.pack(side="right", fill="y")
self._notes_txt = tk.Text(
notes_frame, height=4, wrap="word",
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT,
yscrollcommand=notes_vsb.set,
)
self._notes_txt.pack(fill="both", expand=True, padx=4, pady=4)
notes_vsb.config(command=self._notes_txt.yview)
# Pre-populate when editing
if self.is_edit:
d = self.bid_data
self._title_var.set(d.get("title") or "")
self._url_var.set(d.get("url") or "")
self._source_var.set(d.get("source") or "")
self._sol_var.set(d.get("solicitation_number") or "")
self._status_var.set(d.get("status") or "open")
self._due_var.set(str(d.get("due_date") or ""))
self._notes_txt.insert("1.0", d.get("notes") or "")
ttk.Separator(self, orient="horizontal").pack(fill="x", padx=24, pady=10)
btn_frame = ttk.Frame(self)
btn_frame.pack(fill="x", padx=24, pady=(0, 20))
ttk.Button(btn_frame, text="Save",
command=self._save).pack(side="right", padx=(6, 0))
ttk.Button(btn_frame, text="Cancel", style="Ghost.TButton",
command=self.destroy).pack(side="right")
def _save(self):
title = self._title_var.get().strip()
url = self._url_var.get().strip()
source = self._source_var.get().strip()
sol = self._sol_var.get().strip()
status = self._status_var.get()
due_raw = self._due_var.get().strip()
notes = self._notes_txt.get("1.0", "end-1c").strip()
if not title:
show_error("Title is required.")
return
if not url:
show_error("URL is required.")
return
# Normalise URL
if "://" not in url:
url = "https://" + url
# Validate due date if provided
due_date = None
if due_raw:
import datetime
try:
due_date = datetime.date.fromisoformat(due_raw)
except ValueError:
show_error("Due date must be in YYYY-MM-DD format, e.g. 2025-12-31")
return
try:
if self.is_edit:
from models import update_bid
update_bid(
self.current_user["id"], self.bid_data["id"],
title, url, source, sol, status, due_date, notes,
)
show_info("Bid updated successfully.")
else:
from models import create_bid
create_bid(
self.current_user["id"],
title, url, source, sol, status, due_date, notes,
)
show_info("Bid added successfully.")
self.on_save()
self.destroy()
except Exception as e:
show_error(f"Save failed:\n{e}")