diff --git a/CLAUDE.md b/CLAUDE.md index c9b2a20..c982cb9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: ; 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: ; encrypted @@ -92,6 +98,7 @@ UTF-8 log file in working directory. All INFO/WARNING/ERROR from every module. All tables use InnoDB/utf8mb4. Created by `initialize_database()` on first launch. Safe ALTER TABLE migrations run automatically for columns added post-deployment. +New tables (ai_criteria, ai_analysis_log) verified with information_schema check and logged. ### users | Column | Type | Notes | @@ -111,7 +118,7 @@ Safe ALTER TABLE migrations run automatically for columns added post-deployment. |---|---|---| | id | INT PK AUTO | | | name | VARCHAR(200) | | -| url | TEXT | | +| url | TEXT | Validated by _validate_and_normalise_url (http/https only) | | check_type | ENUM('daily','weekly') | weekly = once per ISO week | | visibility | ENUM('all','assigned') | assigned = only users in website_users table | | note | TEXT | Shown on user dashboard card | @@ -130,9 +137,10 @@ Safe ALTER TABLE migrations run automatically for columns added post-deployment. One row per user per site per day (upserted). `user_note` optional. ### activity_log -`action` codes: LOGIN, LOGOUT, SESSION_TIMEOUT, ACCOUNT_LOCKED, CREATE/UPDATE/DELETE_USER, -CHANGE_PASSWORD, CHANGE_PASSWORD_FAIL, RESET_PASSWORD, CREATE/UPDATE/DELETE_WEBSITE, -ADD/REMOVE_CREDENTIAL, CREATE/UPDATE/DELETE_SHIFT, CHECK_WEBSITE, UPDATE_NOTE, +`action` codes: LOGIN, LOGOUT, SESSION_TIMEOUT, ACCOUNT_LOCKED, +CREATE/UPDATE/DELETE_USER, CHANGE_PASSWORD, CHANGE_PASSWORD_FAIL, RESET_PASSWORD, +CREATE/UPDATE/DELETE_WEBSITE, ADD/REMOVE_CREDENTIAL, +CREATE/UPDATE/DELETE_SHIFT, CHECK_WEBSITE, UPDATE_NOTE, EXPORT_CSV, EXPORT_EXCEL, EXPORT_SHIFT_PDF, UPDATE_EMAIL_SETTINGS, CREATE/UPDATE/DELETE_AI_CRITERION. @@ -146,15 +154,16 @@ E.g. "23456" = Mon–Fri. Queried with `LOCATE(DAYOFWEEK(CURDATE()), days_of_wee ### website_users (junction): (website_id, user_id) PK — for visibility='assigned' ### ai_criteria -Stores evaluation criteria used by the AI to assess opportunity alignment. +Stores evaluation criteria used by the AI to assess solicitation alignment. | Column | Type | Notes | |---|---|---| | id | INT PK AUTO | | -| title | VARCHAR(200) | Short label | -| description | TEXT | Full criterion text sent to the AI | +| title | VARCHAR(200) | Short label shown in treeview | +| description | TEXT | Full criterion text sent to the AI prompt | | is_active | TINYINT(1) | Inactive criteria excluded from AI prompt | | sort_order | INT | Controls display and prompt ordering | | created_by | INT FK users SET NULL | | +| created_at / updated_at | DATETIME | | ### ai_analysis_log Persists every AI analysis result for history and audit purposes. @@ -173,16 +182,24 @@ Persists every AI analysis result for history and audit purposes. ## 5. Module Details +### app.py +- App(tk.Tk) — root window; manages login state, shell, sidebar, session timeout +- IDLE_TIMEOUT_MS = 1,800,000 (30 min); IDLE_WARNING_MS = 60,000 (1 min warning) +- _reset_idle_timer throttled: Motion events fire at most once per _IDLE_THROTTLE_S=5.0 seconds +- _idle_last_reset: monotonic timestamp used for throttle check +- Admin login → scheduler.start(); logout → scheduler.stop() +- Theme toggle destroys and rebuilds the entire shell (content panels re-instantiated) + ### config.py - DB_CONFIG loaded from config.ini at import; placeholders if file missing - get_connection() → pool (pool_size=5) -- initialize_database() → idempotent DDL + ALTER TABLE migrations +- initialize_database() → idempotent DDL + ALTER TABLE migrations + table confirmation logs - load_config() / save_config() → decrypt/encrypt DPAPI fields transparently - migrate_plaintext_config() → one-time migration; encrypts any plain-text creds on startup - config_exists() → True if host, database, user are set - reload_db_config() → re-reads config.ini, resets pool -### utils/config_crypto.py *(NEW)* +### utils/config_crypto.py Windows DPAPI-based encryption for config.ini sensitive values. - encrypt_value(plaintext) → "dpapi:" 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 <> + +.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 \ No newline at end of file diff --git a/README.md b/README.md index a9b2fbc..1e82818 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file diff --git a/USER_MANUAL.md b/USER_MANUAL.md new file mode 100644 index 0000000..47523c4 --- /dev/null +++ b/USER_MANUAL.md @@ -0,0 +1,689 @@ +# Website Checker — User Manual + +**Version 1.0** | For all users + +--- + +## Table of Contents + +1. [Getting Started](#1-getting-started) +2. [Logging In](#2-logging-in) +3. [My Shift — The Daily Checklist](#3-my-shift--the-daily-checklist) +4. [Checking Off a Website](#4-checking-off-a-website) +5. [Site Health Indicator](#5-site-health-indicator) +6. [Viewing Credentials](#6-viewing-credentials) +7. [Shift Reminder Notifications](#7-shift-reminder-notifications) +8. [AI Document Summary](#8-ai-document-summary) +9. [Evaluation Criteria](#9-evaluation-criteria) +10. [Analysis History](#10-analysis-history) +11. [Change Password](#11-change-password) +12. [Admin — Dashboard](#12-admin--dashboard) +13. [Admin — Website Management](#13-admin--website-management) +14. [Admin — User Management](#14-admin--user-management) +15. [Admin — Shift Management](#15-admin--shift-management) +16. [Admin — Reports](#16-admin--reports) +17. [Admin — Activity Log](#17-admin--activity-log) +18. [Admin — Email Report Settings](#18-admin--email-report-settings) +19. [Admin — AI Summary Settings](#19-admin--ai-summary-settings) +20. [Session Timeout](#20-session-timeout) +21. [Keyboard Shortcuts](#21-keyboard-shortcuts) +22. [Troubleshooting](#22-troubleshooting) + +--- + +## 1. Getting Started + +Website Checker is a desktop application that helps your team systematically +verify that assigned websites are operational each shift. Every team member +logs in, checks each site, and marks it complete. Administrators can see +team-wide progress in real time, run reports, and receive automated daily emails. + +**Who sees what:** + +| Feature | Regular User | Administrator | +|---|---|---| +| My Shift checklist | ✔ | ✔ | +| AI Document Summary | ✔ | ✔ | +| Change Password | ✔ | ✔ | +| Dashboard & Reports | — | ✔ | +| Manage Websites / Users / Shifts | — | ✔ | +| Email Settings & Activity Log | — | ✔ | +| AI API key & model selection | — | ✔ | +| Add/Edit Evaluation Criteria | — | ✔ | + +--- + +## 2. Logging In + +1. Launch the application by running `app.py` (or the desktop shortcut). +2. Enter your **Username** and **Password**. +3. Press **Enter** or click **Sign In**. + +**If your account is locked:** After 5 failed attempts your account is locked +for 15 minutes. The login screen shows a countdown timer. Wait for it to reach +zero and try again with the correct password. + +**If you forget your password:** Contact your administrator. They can reset it +from the User Management panel. + +--- + +## 3. My Shift — The Daily Checklist + +After logging in, regular users land on the **My Shift** panel. This shows all +websites you are expected to check during your current shift today. + +### Layout + +``` +┌─────────────────────────────────────────────────────┐ +│ My Shift [ Search... ] [All ▾] [✓ Select All Unchecked] │ +├──────────────────────────────────────────────────────┤ +│ ● [✓] Portal ↗ Open 🔑 Credentials │ +│ https://portal.gov Shift: Morning │ +│ Note: Check dashboard for alerts [Edit] │ +├──────────────────────────────────────────────────────┤ +│ ● [ ] Vendor Hub ↗ Open 🔑 Credentials │ +│ https://vendor.example.com Shift: Morning │ +├──────────────────────────────────────────────────────┤ +│ Progress: ████████░░ 1 / 2 checked (50%) │ +└─────────────────────────────────────────────────────┘ +``` + +### Elements + +- **Colour dot** — health status of the site (see §5) +- **Checkbox** — tick to mark the site as checked for today +- **Site name / URL** — click either to open the site in your browser +- **🔑 Credentials** — appears only if credentials are stored; click to view +- **Note field** — free-text area to leave a per-site note for the shift +- **Progress bar** — shows how many sites you have checked vs total + +### Filtering and search + +- **Search box** — type any part of a site name or URL to filter the list +- **Dropdown filter** — choose All, Checked, or Unchecked +- **✓ Select All Unchecked** — ticks all currently visible unchecked sites at once + +--- + +## 4. Checking Off a Website + +1. Open the website (click the name or URL). +2. Verify the site is operational. +3. Tick the **checkbox** on the site card, or press **Space** when the card is focused. +4. The checkbox turns green and the progress bar advances. + +To **uncheck** a site, tick the checkbox again. You can re-check as many times +as needed throughout the shift. + +**Weekly sites** appear only once per week. Once checked, they disappear from +your list until the following Monday. + +--- + +## 5. Site Health Indicator + +The coloured dot next to each site name is automatically refreshed in the +background when you open the checklist. + +| Colour | Meaning | +|---|---| +| 🟢 Green | Site is reachable and responding quickly (under 3 seconds) | +| 🟡 Amber | Site is reachable but slow (over 3 seconds), OR responding with a 4xx access restriction | +| 🔴 Red | Site is unreachable — network error or server failure | + +**Amber (restricted)** means the server responded — it is online — but rejected +the automated probe request. This is normal for sites that block automated checks. +Open the site manually to verify it is actually working. + +Hover over the dot to see the tooltip with exact response time and status. + +--- + +## 6. Viewing Credentials + +If your administrator has stored login credentials for a site, a **🔑 Credentials** +button appears on that site's card. + +1. Click **🔑 Credentials**. +2. A popup appears showing all stored credential sets for that site. +3. Click **📋** next to the username to copy it to the clipboard (flashes "Copied!" for 2 seconds). +4. Click **📋** next to the password to copy it (also clears the clipboard automatically after 15 seconds). +5. Click **👁** to reveal the password on screen. + +The popup closes when you click outside it or press Escape. + +--- + +## 7. Shift Reminder Notifications + +If you have unchecked sites when your shift is about to end, the app sends a +desktop notification listing all unchecked sites together. + +- The reminder fires **15 minutes before your shift end time** by default. +- One notification covers all unchecked sites in the shift — you will not receive + a separate popup per site. +- Example: *"3 sites unchecked: Portal, Vendor Hub, SAM.gov. Shift ends at 17:00 (15 min remaining)."* + +If the desktop notification system is unavailable, an in-app toast message appears +in the bottom-right corner instead. + +--- + +## 8. AI Document Summary + +The **🤖 AI Summary** panel lets you upload government solicitation documents +and receive an AI-generated extraction of key procurement fields, plus an +alignment recommendation based on your company's evaluation criteria. + +### Uploading documents + +1. Click **➕ Add Files** in the left pane. +2. Select one or more files. Supported formats: + - PDF (`.pdf`) + - Word documents (`.docx`, `.doc`) + - Excel spreadsheets (`.xlsx`, `.xls`) + - Text files (`.txt`, `.md`, `.csv`) +3. The file list shows each file name and size. +4. To remove a file, right-click it and choose **Remove selected**, or use **✕ Clear All**. + +### Running the analysis + +1. Click **✨ Analyze with AI**. +2. The status bar shows progress as each file is read and the AI processes them. +3. Results appear in the right pane when complete. + +### What the AI extracts + +For each document, the AI identifies and labels: + +| Field | Example | +|---|---| +| Solicitation Number | W912DR-25-R-0042 | +| Solicitation Type | RFP, RFQ, IFB | +| Set-Aside | Small Business, 8(a), N/A | +| Description / Scope of Work | Summary of required services | +| Work Site / Location | Full address of performance site | +| Pre-Proposal Conference | Date, time, address, mandatory or optional | +| Point of Contact (POC) | Name, phone, email | +| Total Square Footage | If applicable | +| Driving Distance & Travel Time | From office to conference/site (AI estimate) | +| Last Day to Submit Questions | Date | +| Due Date & Time | Submission deadline | +| Other Notable Requirements | Bonding, insurance, certifications | + +After the per-document breakdown, the AI produces an **Overall Summary** with: + +- **A. Scope of Work** — what is being requested and key performance requirements +- **B. Contract Period** — base period, option years, anticipated start date +- **C. Proposal Submission Requirements** — documents, formatting, evaluation criteria +- **D. Key Deadlines & Action Items** — all critical dates in chronological order + +### Saving and copying results + +- **📋 Copy** — copies the full output text to the clipboard +- **💾 Save as TXT** — saves the output to a text file you choose +- **🗑 Clear** — resets the output panel + +--- + +## 9. Evaluation Criteria + +When your administrator has configured evaluation criteria, the AI automatically +assesses the solicitation against each one and issues a recommendation. + +### The criteria panel + +The **📋 Evaluation Criteria** panel sits below the page header and is collapsible. +It shows all currently configured criteria with their title, description, sort order, +and active status. + +Criteria marked **✔ Active** are included in every AI analysis you run. +Criteria marked **✘ Inactive** are shown for reference but not sent to the AI. + +### The alignment verdict + +When active criteria exist, the analysis output includes a colour-coded banner: + +| Banner | Meaning | +|---|---| +| ✅ Green — PURSUE | The opportunity strongly aligns with your criteria | +| 🚫 Red — PASS | The opportunity fails one or more key criteria | +| ⚠️ Amber — UNCLEAR | Insufficient information in the documents to decide | + +Below the banner, the AI lists each criterion and explains whether the solicitation +**MEETS**, **DOES NOT MEET**, or **PARTIALLY MEETS** it, citing specific details +from the documents. It ends with a 2-3 sentence executive summary. + +### For administrators — managing criteria + +Click **+ Add Criterion** in the criteria panel header to open the Add Criterion dialog. + +| Field | Description | +|---|---| +| Title * | Short name shown in the treeview (e.g. "Geographic Range") | +| Description * | Full criterion text sent to the AI — be specific | +| Sort Order | Lower numbers appear first | +| Active | Uncheck to exclude from AI analysis without deleting | + +**Tips for writing effective criteria:** + +- Be specific and measurable: *"Work site must be within 50 miles of Falls Church, VA"* + is better than *"Site must be nearby."* +- One criterion per idea — don't combine multiple requirements in one description. +- The description has a **500-character soft limit**. Longer descriptions consume + token budget that would otherwise go to the document content. + +To **edit** a criterion, select it in the list and click **✎ Edit**, or double-click the row. +To **delete** a criterion, select it and click **✕ Delete**. You will be asked to confirm. + +--- + +## 10. Analysis History + +Every analysis you run is automatically saved. Click the **🕑 History** tab to review past results. + +### History tab features + +- **Treeview** — shows Date/Time, Files analyzed, Model used, and Verdict for each analysis. + Administrators see all users' analyses; regular users see only their own. +- **Verdict filter** — radio buttons let you filter by All / PURSUE / PASS / UNCLEAR / No verdict. +- **Detail strip** — selecting a row shows the criteria snapshot and other metadata at the bottom. +- **↻ Refresh** — reloads the history from the database. +- **🔍 View Result** — loads the full AI output for the selected analysis back into the + Analyze tab output panel, including the verdict banner. You can then copy or save it. +- **Double-click** on any row does the same as View Result. + +The History tab refreshes automatically whenever you switch to it. + +--- + +## 11. Change Password + +All users can change their own password at any time. + +1. Click **Change Password** in the sidebar. +2. Enter your **Current Password**. +3. Enter and confirm your **New Password**. +4. Click **Change Password**. + +Password requirements: +- At least 8 characters +- At least one uppercase letter +- At least one digit (0–9) +- At least one special character (`!@#$%^&*` etc.) + +A strength indicator shows how strong the new password is as you type. + +--- + +## 12. Admin — Dashboard + +The Dashboard is the administrator's home screen. It refreshes automatically every 60 seconds. + +### KPI cards (top row) + +| Card | Description | +|---|---| +| Active Users | Number of users with active accounts | +| Sites Checked Today | Total site checks recorded today across all users | +| Team Completion | Percentage of expected checks completed today | +| Unchecked Sites | Number of sites not yet checked today | + +### Per-user progress table + +Shows each active user with their checked count, total expected sites today, and +completion percentage. Completion is colour-coded: +- 🟢 Green — 100% complete +- 🟡 Amber — partially complete +- 🔴 Red — not started + +Users with no shifts scheduled today are excluded from the table automatically. + +--- + +## 13. Admin — Website Management + +Navigate to **Websites** in the sidebar. + +### Adding a website + +1. Click **+ Add Website**. +2. Fill in the required fields: + +| Field | Description | +|---|---| +| Name * | Display name shown on user dashboards | +| URL * | Full web address (https:// will be added if omitted) | +| Check Type | Daily (shown every shift day) or Weekly (once per week) | +| Visibility | All Users or Assigned Only | +| Note | Optional note shown on the user's site card | + +3. If **Assigned Only** is selected, a user assignment panel appears — tick the users who should see this site. +4. Optionally expand **🔑 Credentials** to add login credentials for the site. +5. Click **Save**. + +### Credentials + +Each website can have multiple credential sets (e.g. Admin login, Read-only login). +Each set has a **Label**, **Username**, and **Password**. +Passwords are stored encrypted and are only revealed when the user clicks 👁 in the credentials popup. + +### Editing a website + +Select a website in the list and click **✎ Edit**, or double-click the row. +All fields can be changed, including credentials. + +### Deactivating / deleting a website + +- **Deactivate** (recommended): Uncheck **Active** in the edit dialog. The site is hidden from + users but historical check records are preserved. +- **Delete**: Select and click **✕ Delete**. This is permanent. + +--- + +## 14. Admin — User Management + +Navigate to **Users** in the sidebar. + +### Adding a user + +1. Click **+ Add User**. +2. Fill in Username, Full Name, Role (Admin or User), and Password. +3. Click **Save**. + +### Editing a user + +Select a user and click **✎ Edit** or double-click. You can change their name, +role, active status, and set a new password. + +### Resetting a password + +1. Select a user and click **🔑 Reset Password**. +2. Confirm the prompt. +3. A secure dialog appears with a randomly generated 16-character temporary password. +4. Click **📋 Copy** to copy it to the clipboard (the clipboard is wiped after 30 seconds). +5. Share the temporary password with the user securely (e.g. by phone). +6. The dialog auto-closes after 120 seconds. +7. The user should change this password immediately after logging in. + +**Notes:** +- You cannot reset your own password here — use Change Password in the sidebar. +- The last active administrator account cannot be deleted. + +### Deactivating vs deleting + +- **Deactivate** (is_active = off): The user cannot log in but their records are preserved. +- **Delete**: Permanent. Use deactivate first if you want to keep audit history. + +--- + +## 15. Admin — Shift Management + +Navigate to **Shifts** in the sidebar. + +### What is a shift? + +A shift defines which websites a group of users should check, on which days of the week, +and during which time window. A user can belong to multiple shifts. + +### Adding a shift + +1. Click **+ New Shift**. +2. Fill in: + +| Field | Description | +|---|---| +| Shift Name | e.g. "Morning Team", "Weekend Check" | +| Days of Week | Tick each day this shift runs | +| Start Time | When the shift begins (HH:MM) | +| End Time | When the shift ends — used for reminder notifications | +| Note | Optional internal note | + +3. On the **Users** tab, tick the users assigned to this shift. +4. On the **Websites** tab, tick the websites this shift should check (drag to reorder). +5. Click **Save**. + +### Show inactive shifts + +By default, deactivated shifts are hidden. Tick **Show inactive** in the toolbar +to display them (shown in grey). + +### Exporting shifts to PDF + +Click **⬇ Export PDF** to generate a printable PDF showing all active shifts, +their assigned users, and their website lists. + +--- + +## 16. Admin — Reports + +Navigate to **Reports** in the sidebar. There are four tabs. + +### Shift Detail + +Shows every check-in for a specific user and date range. +Select a user, set a date range, and click **Generate**. +Export to CSV or Excel with the export buttons. + +### Unchecked Sites + +Shows websites that were NOT checked on a given date, scoped to each user's actual shift. +If no date is selected, defaults to today. +Filter by a specific user or view all users. + +### Summary + +Shows per-user per-day statistics: sites checked, total expected, and completion percentage. +Total is based on that user's actual shift assignment for that day — not a global site count. + +### Completion Chart + +A bar chart showing today's completion percentage per user. +Bars are colour-coded: green ≥ 100%, amber ≥ 50%, red < 50%. + +--- + +## 17. Admin — Activity Log + +Navigate to **Activity Log** in the sidebar. + +The log shows every significant action taken in the system, including: +logins, logouts, session timeouts, account lockouts, all create/edit/delete operations, +password changes and resets, website checks, exports, email setting changes, +and AI criterion changes. + +Each entry shows: timestamp, acting user, action type, affected record, and detail notes. + +Use the search bar to filter by username, action type, or any text in the detail field. +Use the date range pickers to narrow the time window. + +--- + +## 18. Admin — Email Report Settings + +Open **Settings** (gear icon in sidebar) → **Email Report Settings**, or click the +email icon if visible. + +### Configuration fields + +| Field | Description | +|---|---| +| Enable daily emails | Master on/off switch | +| SMTP Host | Your mail server address | +| Security | STARTTLS (port 587), SSL/TLS (port 465), or None (port 25) | +| SMTP Port | Auto-filled when Security mode is selected; can be changed manually | +| SMTP Username | Usually your full email address | +| SMTP Password | For Gmail, use an App Password — not your Gmail password | +| Recipients | Comma-separated email addresses to receive the report | +| Send Time | HH:MM (24-hour) — the time the report is sent each day | + +### Testing the connection + +**🔌 Test Connection** runs a step-by-step diagnostic: + +1. **DNS** — can the server name be resolved? +2. **TCP** — can a connection be made to the port? +3. **TLS** — does the TLS/security handshake succeed? +4. **Auth** — are the username and password accepted? + +The status line reports exactly which step failed and what to check. + +**📧 Send Test Email** sends a real test message to the configured recipients. +Use this to confirm full end-to-end delivery after the connection test passes. + +### Common setups + +**Office 365 / Exchange Online:** +- Security: STARTTLS +- Port: 587 +- Username: full email address +- Password: your Microsoft account password or app password + +**Gmail (Google Workspace):** +- Security: SSL / TLS +- Port: 465 +- Username: full Gmail address +- Password: App Password (not your Gmail password) + → Enable 2-Step Verification → myaccount.google.com/apppasswords + +**Internal relay:** +- Security: None +- Port: 25 +- Username/Password: may not be required + +### What the daily report contains + +The report email lists every active user who had shifts today, showing: +- Number of sites checked +- Total sites expected for their shift +- Completion percentage (colour-coded) + +Users with no shift scheduled today are excluded, with a footnote showing the count. + +--- + +## 19. Admin — AI Summary Settings + +### Setting up the API key + +1. Get a free API key at **https://console.groq.com** (no credit card required). +2. Open **🤖 AI Summary** in the sidebar. +3. Enter the API key in the **Groq API Key** field. +4. Select a model (see below). +5. Click **💾 Save Settings**. + +The API key is stored encrypted on your machine and is never shared with other users. + +### Available models + +| Model | Best for | +|---|---| +| llama-3.3-70b-versatile | Best overall quality — recommended default | +| llama-3.1-8b-instant | Faster responses, slightly lower quality | +| gemma2-9b-it | Good alternative for shorter documents | +| mixtral-8x7b-32768 | Large context window for very long documents | + +### Managing evaluation criteria + +See §9 above for full details on adding, editing, and deleting criteria. + +**Key points for admins:** +- Criteria are shared — all users' analyses use the same active criteria. +- Deactivating a criterion (unchecking Active) removes it from future AI prompts + without deleting it — useful for temporarily suspending a criterion. +- The full description text is saved in the audit log each time a criterion is updated, + so you can always reconstruct what the AI was evaluating against at any historical date. + +--- + +## 20. Session Timeout + +The application automatically signs you out after **30 minutes of inactivity** +(no mouse movement, key presses, or clicks). + +One minute before timeout, a warning dialog appears. Click **Stay Signed In** to +reset the timer. If you don't respond, you are signed out and must log in again. +Any unsaved work is lost. + +--- + +## 21. Keyboard Shortcuts + +Available on the My Shift dashboard: + +| Shortcut | Action | +|---|---| +| `Ctrl+F` | Focus the search box | +| `Ctrl+A` | Select all unchecked sites | +| `Ctrl+Enter` | Mark selected site as checked | +| `Space` | Toggle checkbox on focused site card | +| `Escape` | Clear search / deselect | + +--- + +## 22. Troubleshooting + +### I can't log in + +- Check Caps Lock — passwords are case-sensitive. +- After 5 failed attempts, your account is locked for 15 minutes. + Wait for the countdown to finish and try again. +- Contact your administrator if you need a password reset. + +### My shift shows no sites + +- You may not be assigned to any active shift scheduled for today. +- Contact your administrator to verify your shift assignment and the days-of-week setting. + +### The health dot is always red for a site + +- The site may block automated probes (returns 4xx — shown as amber, not red). +- A red dot means a network error — the server is not responding at all. +- Check your internet connection. If other sites are green, the specific site may be down. + +### The AI analysis fails with "401" + +- Your Groq API key is invalid or expired. +- Log in to https://console.groq.com, generate a new key, and update it in AI Summary settings. + +### The AI analysis says "No readable text found" + +- The PDF may be a scanned image rather than a text-based PDF. +- Try using a version of the document that is copy-pasteable, or a Word/Excel version. + +### Email test fails at Step 3 (TLS) + +- You are using the wrong security mode for your mail server. +- Try switching from STARTTLS to SSL/TLS (port 465) or vice versa. +- Contact your IT department to confirm the correct SMTP settings. + +### Email test fails at Step 4 (Auth) + +- For Gmail/Google Workspace: you must use an **App Password**, not your regular password. + Go to myaccount.google.com/apppasswords to create one. +- For Office 365: your account may require Modern Authentication or an app password. + Contact your IT department. + +### I can't see the Evaluation Criteria panel + +- The panel is present for all users but may be collapsed. Click **▼ Expand** to open it. + +### My analysis history is empty + +- History is only saved for analyses run after the feature was deployed. + Earlier analyses are not retroactively added. +- Check that you are on the 🕑 History tab (not the ✨ Analyze tab). +- Click **↻ Refresh** to reload from the database. + +### The app freezes during AI analysis + +- The AI call runs in a background thread — the UI should remain responsive. +- If the app appears frozen, wait up to 60 seconds. Long documents take time to process. +- If it remains unresponsive, close and reopen the application. The analysis may have failed silently — check `app.log`. + +--- + +*For technical issues not covered here, ask your system administrator to check `app.log` +in the application directory for detailed error messages.* \ No newline at end of file diff --git a/app.py b/app.py index 19c3773..1c46cdc 100644 --- a/app.py +++ b/app.py @@ -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), + ("📊 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 @@ -523,8 +531,9 @@ class App(tk.Tk): "shifts": self._show_shifts, "reports": self._show_reports, "email": self._show_email_settings, - "ai_summary": self._show_ai_summary, - "settings": self._show_settings, + "ai_summary": self._show_ai_summary, + "bid_tracker": self._show_bid_tracker, + "settings": self._show_settings, } if active in nav_map: nav_map[active]() diff --git a/config.py b/config.py index b388684..8d77280 100644 --- a/config.py +++ b/config.py @@ -212,15 +212,16 @@ def initialize_database(): """, """ CREATE TABLE IF NOT EXISTS websites ( - id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(200) NOT NULL, - url TEXT NOT NULL, - check_type ENUM('daily','weekly') NOT NULL DEFAULT 'daily', - note TEXT, - is_active TINYINT(1) NOT NULL DEFAULT 1, - created_by INT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + id INT AUTO_INCREMENT PRIMARY KEY, + 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, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, @@ -228,19 +229,19 @@ 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; """, """ CREATE TABLE IF NOT EXISTS shift_checks ( - id INT AUTO_INCREMENT PRIMARY KEY, - website_id INT NOT NULL, - user_id INT NOT NULL, - checked_at DATETIME DEFAULT CURRENT_TIMESTAMP, - user_note TEXT, + id INT AUTO_INCREMENT PRIMARY KEY, + website_id INT NOT NULL, + user_id INT NOT NULL, + checked_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_note TEXT, FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -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, @@ -274,18 +275,18 @@ def initialize_database(): """, """ CREATE TABLE IF NOT EXISTS shift_users ( - shift_id INT NOT NULL, - user_id INT NOT NULL, + shift_id INT NOT NULL, + user_id INT NOT NULL, PRIMARY KEY (shift_id, user_id), - FOREIGN KEY (shift_id) REFERENCES shifts(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + FOREIGN KEY (shift_id) REFERENCES shifts(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS shift_websites ( - shift_id INT NOT NULL, - website_id INT NOT NULL, - sort_order INT NOT NULL DEFAULT 0, + shift_id INT NOT NULL, + website_id INT NOT NULL, + sort_order INT NOT NULL DEFAULT 0, PRIMARY KEY (shift_id, website_id), FOREIGN KEY (shift_id) REFERENCES shifts(id) ON DELETE CASCADE, FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE @@ -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() + + diff --git a/models.py b/models.py index c69162c..0552e6d 100644 --- a/models.py +++ b/models.py @@ -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() diff --git a/views/bid_tracker_view.py b/views/bid_tracker_view.py new file mode 100644 index 0000000..2a7a6ef --- /dev/null +++ b/views/bid_tracker_view.py @@ -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("<>", 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("<>", self._on_bid_select) + self._tree.bind("", 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("", self._on_update_focus_in) + self._update_txt.bind("", 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("", self._on_inner_configure) + self._update_canvas.bind("", self._on_canvas_configure) + + # Mousewheel scrolling + self._update_canvas.bind("", + lambda _: self._update_canvas.bind_all( + "", self._on_mousewheel)) + self._update_canvas.bind("", + lambda _: self._update_canvas.unbind_all("")) + + 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}")