Add bid tracker
This commit is contained in:
@@ -1,21 +1,23 @@
|
||||
# CLAUDE.md — Website Checker: Complete Technical Knowledge Base
|
||||
|
||||
This document captures the full architecture, design decisions, database schema, module inventory,
|
||||
feature set, and operational notes for the **Website Checker** desktop application.
|
||||
This document is the authoritative technical reference for the **Website Checker**
|
||||
desktop application. Keep it current whenever code changes are made.
|
||||
|
||||
---
|
||||
|
||||
## 1. Application Overview
|
||||
|
||||
Website Checker is a Python/Tkinter desktop application for shift-based website monitoring.
|
||||
Regular users log in, view websites assigned to their shift, open each site, and mark it checked.
|
||||
Administrators manage users, websites, shifts, credentials, and run reports.
|
||||
Website Checker is a Python/Tkinter desktop application for shift-based website
|
||||
monitoring backed by a remote MySQL database. Regular users log in, view websites
|
||||
assigned to their shift, open each site, and mark it checked. Administrators manage
|
||||
users, websites, shifts, credentials, email reports, and AI-powered document analysis.
|
||||
|
||||
- **UI:** Python `tkinter` + `ttk`
|
||||
- **Database:** Remote MySQL 5.7+ / MariaDB 10.3+ via `mysql-connector-python`
|
||||
- **Entry point:** `app.py` → `class App(tk.Tk)`
|
||||
- **Default window:** 1100×700, minimum 960×620
|
||||
- **Default theme:** Light (toggleable to Dark)
|
||||
- **Platform:** Windows desktop (primary); macOS/Linux supported with limitations
|
||||
|
||||
---
|
||||
|
||||
@@ -28,21 +30,23 @@ website_checker/
|
||||
├── models.py All database access (data layer)
|
||||
├── requirements.txt
|
||||
├── CLAUDE.md This file
|
||||
├── README.md Setup and deployment guide
|
||||
├── USER_MANUAL.md End-user documentation
|
||||
├── utils/
|
||||
│ ├── config_crypto.py Windows DPAPI encryption for config.ini credentials
|
||||
│ ├── crypto.py Fernet credential encryption (website passwords)
|
||||
│ ├── export.py CSV + Excel export
|
||||
│ ├── scheduler.py Daily email report background daemon
|
||||
│ ├── scheduler.py Daily email report background daemon + SMTP helpers
|
||||
│ └── ui_helpers.py ThemeManager, COLOURS proxy, DateEntry, widgets
|
||||
└── views/
|
||||
├── admin_dashboard_view.py Admin home — KPI cards + per-user progress table
|
||||
├── admin_log_view.py Activity log treeview
|
||||
├── admin_shifts_view.py Shift CRUD + PDF export
|
||||
├── admin_users_view.py User CRUD
|
||||
├── admin_shifts_view.py Shift CRUD + PDF export + inactive filter toggle
|
||||
├── admin_users_view.py User CRUD + secure password reset dialog
|
||||
├── admin_websites_view.py Website CRUD + visibility + credentials (collapsible)
|
||||
├── ai_summary_view.py AI document analysis via Groq API (NEW)
|
||||
├── ai_summary_view.py AI document analysis via Groq API
|
||||
├── change_password_view.py Self-service password change (all roles)
|
||||
├── email_settings_view.py SMTP / scheduled report configuration
|
||||
├── email_settings_view.py SMTP / scheduled report config (3-mode security)
|
||||
├── login_view.py Login form with rate-limiting countdown
|
||||
├── reports_view.py Shift Detail / Unchecked / Summary / Chart tabs
|
||||
├── settings_view.py DB connection settings dialog
|
||||
@@ -71,9 +75,11 @@ smtp_host=smtp.example.com
|
||||
smtp_port=587
|
||||
smtp_user=sender@example.com
|
||||
smtp_password=dpapi:<base64-blob> ; encrypted
|
||||
use_tls=true
|
||||
security=starttls ; starttls | ssl | none
|
||||
use_tls=true ; legacy compat field, derived from security
|
||||
recipients=admin@example.com
|
||||
send_time=18:00
|
||||
last_sent_date=2025-01-01 ; ISO date, prevents re-send after restart
|
||||
|
||||
[groq]
|
||||
api_key=dpapi:<base64-blob> ; encrypted
|
||||
@@ -92,6 +98,7 @@ UTF-8 log file in working directory. All INFO/WARNING/ERROR from every module.
|
||||
|
||||
All tables use InnoDB/utf8mb4. Created by `initialize_database()` on first launch.
|
||||
Safe ALTER TABLE migrations run automatically for columns added post-deployment.
|
||||
New tables (ai_criteria, ai_analysis_log) verified with information_schema check and logged.
|
||||
|
||||
### users
|
||||
| Column | Type | Notes |
|
||||
@@ -111,7 +118,7 @@ Safe ALTER TABLE migrations run automatically for columns added post-deployment.
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO | |
|
||||
| name | VARCHAR(200) | |
|
||||
| url | TEXT | |
|
||||
| url | TEXT | Validated by _validate_and_normalise_url (http/https only) |
|
||||
| check_type | ENUM('daily','weekly') | weekly = once per ISO week |
|
||||
| visibility | ENUM('all','assigned') | assigned = only users in website_users table |
|
||||
| note | TEXT | Shown on user dashboard card |
|
||||
@@ -130,9 +137,10 @@ Safe ALTER TABLE migrations run automatically for columns added post-deployment.
|
||||
One row per user per site per day (upserted). `user_note` optional.
|
||||
|
||||
### activity_log
|
||||
`action` codes: LOGIN, LOGOUT, SESSION_TIMEOUT, ACCOUNT_LOCKED, CREATE/UPDATE/DELETE_USER,
|
||||
CHANGE_PASSWORD, CHANGE_PASSWORD_FAIL, RESET_PASSWORD, CREATE/UPDATE/DELETE_WEBSITE,
|
||||
ADD/REMOVE_CREDENTIAL, CREATE/UPDATE/DELETE_SHIFT, CHECK_WEBSITE, UPDATE_NOTE,
|
||||
`action` codes: LOGIN, LOGOUT, SESSION_TIMEOUT, ACCOUNT_LOCKED,
|
||||
CREATE/UPDATE/DELETE_USER, CHANGE_PASSWORD, CHANGE_PASSWORD_FAIL, RESET_PASSWORD,
|
||||
CREATE/UPDATE/DELETE_WEBSITE, ADD/REMOVE_CREDENTIAL,
|
||||
CREATE/UPDATE/DELETE_SHIFT, CHECK_WEBSITE, UPDATE_NOTE,
|
||||
EXPORT_CSV, EXPORT_EXCEL, EXPORT_SHIFT_PDF, UPDATE_EMAIL_SETTINGS,
|
||||
CREATE/UPDATE/DELETE_AI_CRITERION.
|
||||
|
||||
@@ -146,15 +154,16 @@ E.g. "23456" = Mon–Fri. Queried with `LOCATE(DAYOFWEEK(CURDATE()), days_of_wee
|
||||
### website_users (junction): (website_id, user_id) PK — for visibility='assigned'
|
||||
|
||||
### ai_criteria
|
||||
Stores evaluation criteria used by the AI to assess opportunity alignment.
|
||||
Stores evaluation criteria used by the AI to assess solicitation alignment.
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO | |
|
||||
| title | VARCHAR(200) | Short label |
|
||||
| description | TEXT | Full criterion text sent to the AI |
|
||||
| title | VARCHAR(200) | Short label shown in treeview |
|
||||
| description | TEXT | Full criterion text sent to the AI prompt |
|
||||
| is_active | TINYINT(1) | Inactive criteria excluded from AI prompt |
|
||||
| sort_order | INT | Controls display and prompt ordering |
|
||||
| created_by | INT FK users SET NULL | |
|
||||
| created_at / updated_at | DATETIME | |
|
||||
|
||||
### ai_analysis_log
|
||||
Persists every AI analysis result for history and audit purposes.
|
||||
@@ -173,16 +182,24 @@ Persists every AI analysis result for history and audit purposes.
|
||||
|
||||
## 5. Module Details
|
||||
|
||||
### app.py
|
||||
- App(tk.Tk) — root window; manages login state, shell, sidebar, session timeout
|
||||
- IDLE_TIMEOUT_MS = 1,800,000 (30 min); IDLE_WARNING_MS = 60,000 (1 min warning)
|
||||
- _reset_idle_timer throttled: Motion events fire at most once per _IDLE_THROTTLE_S=5.0 seconds
|
||||
- _idle_last_reset: monotonic timestamp used for throttle check
|
||||
- Admin login → scheduler.start(); logout → scheduler.stop()
|
||||
- Theme toggle destroys and rebuilds the entire shell (content panels re-instantiated)
|
||||
|
||||
### config.py
|
||||
- DB_CONFIG loaded from config.ini at import; placeholders if file missing
|
||||
- get_connection() → pool (pool_size=5)
|
||||
- initialize_database() → idempotent DDL + ALTER TABLE migrations
|
||||
- initialize_database() → idempotent DDL + ALTER TABLE migrations + table confirmation logs
|
||||
- load_config() / save_config() → decrypt/encrypt DPAPI fields transparently
|
||||
- migrate_plaintext_config() → one-time migration; encrypts any plain-text creds on startup
|
||||
- config_exists() → True if host, database, user are set
|
||||
- reload_db_config() → re-reads config.ini, resets pool
|
||||
|
||||
### utils/config_crypto.py *(NEW)*
|
||||
### utils/config_crypto.py
|
||||
Windows DPAPI-based encryption for config.ini sensitive values.
|
||||
- encrypt_value(plaintext) → "dpapi:<base64>" string
|
||||
- decrypt_value(stored) → plaintext; plain-text pass-through for legacy values
|
||||
@@ -201,6 +218,14 @@ Password helpers:
|
||||
Rate-limit constants: MAX_FAILED_ATTEMPTS=5, LOCKOUT_MINUTES=15
|
||||
Password strength: PW_MIN_LENGTH=8, requires upper + digit + special char
|
||||
|
||||
Key functions:
|
||||
- get_today_checks(user_id) — shift-aware; respects visibility and check_type
|
||||
- get_unchecked_report(target_date, user_id) — shift-scoped; only sites user was expected to check
|
||||
- get_summary_report(date_from, date_to) — shift-scoped total_sites per user per day
|
||||
- get_all_criteria() / get_active_criteria() — no LEFT JOIN; simple SELECT from ai_criteria
|
||||
- save_ai_analysis() / get_ai_analysis_history() / get_ai_analysis_detail()
|
||||
- delete_user() — guards: self-delete prevention + last-admin prevention
|
||||
|
||||
### utils/crypto.py
|
||||
- PBKDF2-HMAC-SHA256, 100,000 iterations, 32-byte salt from config.ini [crypto]
|
||||
- encrypt(plaintext) → "enc:" + base64(Fernet token)
|
||||
@@ -218,73 +243,97 @@ Password strength: PW_MIN_LENGTH=8, requires upper + digit + special char
|
||||
### utils/scheduler.py
|
||||
- Daemon thread; polls every 60 seconds
|
||||
- Sends HTML email once per day when now >= send_time
|
||||
- smtp_password is DPAPI-encrypted on save (encrypt_value) / decrypted on load (decrypt_value)
|
||||
- last_sent_date in-memory (resets on restart)
|
||||
- start() / stop() called from app.py on login/logout (admin only)
|
||||
- smtp_password DPAPI-encrypted on save / decrypted on load
|
||||
- last_sent_date persisted to config.ini [email] last_sent_date — survives restarts
|
||||
- start() / stop() called from app.py on admin login/logout
|
||||
- start() guards against double-start: joins existing thread before spawning new one
|
||||
- _make_smtp_server(host, port, security) — centralised connection with explicit ehlo() calls
|
||||
(required on Windows for reliable STARTTLS/SSL handshake)
|
||||
- security field: "starttls" (port 587) | "ssl" (port 465) | "none" (port 25)
|
||||
- test_smtp_connection() — 4-step diagnostic: DNS → TCP → TLS → Auth
|
||||
- send_test_email() — sends real HTML test message through full pipeline
|
||||
- Email report excludes users with total_sites=0 (no shift today); adds footnote count
|
||||
|
||||
### views/email_settings_view.py
|
||||
- Security mode: 3 radio buttons (STARTTLS/SSL·TLS/None) replacing old Use STARTTLS checkbox
|
||||
- Selecting a security mode auto-fills the default port
|
||||
- Loads legacy use_tls bool from config.ini and maps to security field on first load
|
||||
- 🔌 Test Connection — step-by-step diagnostic output in status label
|
||||
- 📧 Send Test Email — real email send; all buttons disabled during operation
|
||||
- Save preserves last_sent_date in config.ini
|
||||
|
||||
### views/ai_summary_view.py
|
||||
AI-powered document analysis panel. Accessible from the sidebar for both admin and user roles.
|
||||
AI-powered document analysis panel. Accessible from sidebar for both roles.
|
||||
|
||||
Key internals:
|
||||
- Supported file types: .txt, .md, .csv, .pdf, .docx, .doc, .xlsx, .xls
|
||||
- Groq API key and model selection are **visible only to admins** (user role sees neither)
|
||||
- Regular users use the stored API key transparently
|
||||
- Groq API key and model selection visible only to admins
|
||||
- Regular users use stored API key transparently
|
||||
- Background thread for AI calls (prevents UI freeze)
|
||||
- Extraction prompt focused on procurement/solicitation fields:
|
||||
Solicitation Number/Type, Set-Aside, Description, Work Site, Pre-Proposal Conference,
|
||||
POC, Square Footage, Driving Distance from office (2815 Hartland Rd, Falls Church VA),
|
||||
Last Day for Questions, Due Date
|
||||
- Overall summary covers: Scope of Work, Contract Period, Proposal Submission Requirements,
|
||||
Key Deadlines & Action Items
|
||||
- Groq API key stored DPAPI-encrypted in config.ini [groq] section
|
||||
- Notebook layout: ✨ Analyze tab + 🕑 History tab
|
||||
- Extraction prompt: 12 labeled fields per document + 4-section Overall Summary
|
||||
- Driving distance calculated from 2815 Hartland Rd, Falls Church VA (AI estimate)
|
||||
- max_tokens=4096; temperature=0.2; MAX_CHARS_PER_FILE=14,000
|
||||
- **Evaluation Criteria panel** (collapsible): admin can add/edit/delete criteria;
|
||||
users see read-only list. Active criteria are appended to the AI prompt as an
|
||||
alignment evaluation section. The AI outputs RECOMMENDATION: PURSUE/PASS/UNCLEAR
|
||||
which is parsed and displayed as a colour-coded verdict banner.
|
||||
- **History tab**: every analysis is saved to ai_analysis_log (with verdict + criteria
|
||||
snapshot). History treeview is filterable by verdict. Double-click restores full
|
||||
output in the Analyze tab. Admins see all users; regular users see only their own.
|
||||
- CriterionDialog has a live character counter with 500-char soft limit guidance.
|
||||
- URL open and health-check probe both guarded by _validate_and_normalise_url.
|
||||
|
||||
.doc reading (legacy binary Word format) — 3-tier fallback:
|
||||
Evaluation Criteria panel (collapsible):
|
||||
- Admin: Add/Edit/Delete controls; CriterionDialog with live character counter (soft limit 500)
|
||||
- Users: read-only treeview
|
||||
- Active criteria appended to AI prompt; AI outputs RECOMMENDATION: PURSUE/PASS/UNCLEAR
|
||||
- Verdict parsed by _parse_verdict() and shown as colour-coded banner (green/red/amber)
|
||||
|
||||
History tab:
|
||||
- Every analysis saved to ai_analysis_log with verdict + criteria snapshot
|
||||
- save_ai_analysis() called in _on_success() — never blocks the result display
|
||||
- Filterable by verdict radio buttons (All/PURSUE/PASS/UNCLEAR/none)
|
||||
- Double-click or View Result restores full output in Analyze tab
|
||||
- Admins see all users' history; regular users see only their own
|
||||
- Auto-refreshes on tab switch via <<NotebookTabChanged>>
|
||||
|
||||
.doc reading (legacy binary Word) — 3-tier fallback:
|
||||
1. win32com (Word COM automation — requires MS Word installed)
|
||||
2. docx2txt (pure Python)
|
||||
3. Raw ASCII scrape from binary
|
||||
|
||||
URL safety: _validate_and_normalise_url() rejects non-http/https schemes.
|
||||
|
||||
### views/admin_websites_view.py
|
||||
- Website CRUD dialog now has a **collapsible credentials section** (hidden by default)
|
||||
- "🔑 Show Credentials" / "🔒 Hide Credentials" toggle button
|
||||
- Auto-expands when editing a site that already has saved credentials
|
||||
- "+ Add Credential" auto-expands the section if collapsed
|
||||
- **Bug fixed:** `_on_visibility_change` previously used `before=self.creds_container`
|
||||
which crosses a widget parent boundary causing a TclError; corrected to `before=self._cred_body`
|
||||
- Website CRUD dialog with collapsible credentials section (hidden by default)
|
||||
- _on_visibility_change uses before=self._cred_body (not creds_container — same parent)
|
||||
- _validate_and_normalise_url() called in _save() before DB write
|
||||
- URL validation: prepends https:// if no scheme; rejects javascript:/data:/file: etc.
|
||||
|
||||
### views/admin_users_view.py
|
||||
- 🔑 Reset Password button generates 16-char cryptographically random password
|
||||
- _PasswordResetDialog: masked field, 👁 reveal, 📋 copy (2s flash), 120s auto-close,
|
||||
30s clipboard wipe, countdown timer
|
||||
- Logs RESET_PASSWORD to activity_log
|
||||
|
||||
### views/admin_shifts_view.py
|
||||
- "Show inactive" checkbox toggles visibility of inactive shifts in treeview
|
||||
- _load_shifts filters by _show_inactive BooleanVar; no extra DB call
|
||||
- _export_pdf(): reportlab A4; one section per active shift
|
||||
|
||||
### views/user_dashboard_view.py
|
||||
Key internals:
|
||||
- _health_cache: {website_id: ("ok"|"slow"|"down", ms)} — daemon threads, HEAD request, 6s timeout
|
||||
- Dot colours: green (<=3000ms), amber (>3000ms), red (error)
|
||||
- Mousewheel: Enter/Leave scoped; unbind_all in destroy()
|
||||
- Keyboard shortcuts: self.bind() stored in _shortcut_ids; unbound in destroy()
|
||||
- Notifications: after(60_000) loop; plyer first, fallback to borderless Toplevel toast
|
||||
- **Credentials popup no longer opens automatically on link click**
|
||||
- "🔑 Credentials" button appears on each site card only if the site has saved credentials
|
||||
- CredentialsPopup: 📋 copy button for username (2s flash); 📋 copy button for password
|
||||
(2s flash + clipboard auto-cleared after 15s for security); 👁 toggle to reveal password
|
||||
- _health_cache: {website_id: ("ok"|"slow"|"restricted"|"down", ms)}
|
||||
- ok: 2xx/3xx ≤3000ms; slow: 2xx/3xx >3000ms; restricted: 4xx; down: 5xx/network error
|
||||
- Notifications: single consolidated reminder per shift end-time group (not per site)
|
||||
- Groups unchecked sites by end_time; fires one notification listing all sites
|
||||
- _notified_sites key: (frozenset(names), end_t)
|
||||
- Keyboard shortcuts: stored in _shortcut_ids; unbound in destroy()
|
||||
- Credentials popup: 📋 copy (username 2s flash; password 2s flash + 15s clipboard wipe)
|
||||
|
||||
### views/reports_view.py
|
||||
- Tabs: Shift Detail / Unchecked / Summary / Chart
|
||||
- All date fields use DateEntry (no manual text entry)
|
||||
- Summary report: shift-scoped total_sites per user per day (not global count)
|
||||
- Unchecked report: shift-scoped; only sites user was expected to check that day
|
||||
- Chart: matplotlib Agg + FigureCanvasTkAgg; bars green≥100% / amber≥50% / red<50%
|
||||
|
||||
### views/login_view.py
|
||||
- check_login_allowed(username) called before authenticate()
|
||||
- Locked: form disabled; 1-second countdown; re-enables when timer reaches 0
|
||||
- Not-yet-locked failed attempt: shows "N attempt(s) remaining before lockout"
|
||||
|
||||
### views/admin_shifts_view.py
|
||||
- _export_pdf(): reportlab A4 document; one section per active shift; user+website tables
|
||||
|
||||
### views/reports_view.py
|
||||
- All date fields use DateEntry (no manual text entry)
|
||||
- Chart: matplotlib Agg + FigureCanvasTkAgg; bars green>=100% / amber>=50% / red<50%
|
||||
|
||||
---
|
||||
|
||||
## 6. Authentication & Security
|
||||
@@ -296,10 +345,14 @@ Key internals:
|
||||
| Config credential protection | Windows DPAPI (CryptProtectData) — user-account-scoped |
|
||||
| Encrypted fields | DB user, DB password, SMTP password, Groq API key |
|
||||
| Login rate limiting | 5 attempts → 15-min lockout in DB |
|
||||
| Session timeout | 30-min idle; 1-min warning; any mouse/key event resets |
|
||||
| Session timeout | 30-min idle; 1-min warning; Motion events throttled to 1/5s |
|
||||
| Password policy | 8+ chars, uppercase, digit, special character |
|
||||
| Self-service change | Requires current password; strength meter; same-as-current guard |
|
||||
| Clipboard security | Password copy auto-clears clipboard after 15 seconds |
|
||||
| URL safety | _validate_and_normalise_url rejects non-http/https schemes |
|
||||
| Admin self-delete | Blocked in delete_user() |
|
||||
| Last-admin deletion | Blocked in delete_user() |
|
||||
| Password reset display | _PasswordResetDialog: 120s auto-close, 30s clipboard wipe |
|
||||
|
||||
### DPAPI Migration
|
||||
`migrate_plaintext_config()` is called automatically on every startup (in `_init_db`).
|
||||
@@ -316,13 +369,21 @@ On first run after this feature was added, it encrypts any existing plain-text v
|
||||
|
||||
### visibility
|
||||
- all: every user in shift sees it
|
||||
- assigned: only users in website_users table see it
|
||||
- assigned: only users in website_users table see it (enforced in both shift and legacy paths)
|
||||
|
||||
### get_today_checks resolution
|
||||
1. Check if user has active shifts today (DAYOFWEEK match)
|
||||
2. If yes: union of shift websites filtered by check_type + visibility
|
||||
2. If yes: shift websites filtered by check_type + visibility (website_users respected)
|
||||
3. If no: all active websites filtered by visibility + check_type (legacy fallback)
|
||||
|
||||
### Unchecked report scope
|
||||
Uses shift membership to determine "expected" sites per user per day.
|
||||
CROSS JOIN replaced with shift-aware subquery — no false positives for off-shift users.
|
||||
|
||||
### Summary report total_sites
|
||||
Correlated subquery counts shift-assigned sites per user per check_date day-of-week.
|
||||
A user in a 3-site shift shows 3 as total_sites, not the global count.
|
||||
|
||||
### Soft deletes
|
||||
Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first).
|
||||
|
||||
@@ -336,6 +397,7 @@ Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first
|
||||
| app.py | IDLE_WARNING_MS | 60,000 (1 min) |
|
||||
| app.py | APP_VERSION | "1.0.0" |
|
||||
| app.py | VERSION_CHECK_ENABLED | False |
|
||||
| app.py | App._IDLE_THROTTLE_S | 5.0 (seconds between Motion-event timer resets) |
|
||||
| models.py | MAX_FAILED_ATTEMPTS | 5 |
|
||||
| models.py | LOCKOUT_MINUTES | 15 |
|
||||
| models.py | PW_MIN_LENGTH | 8 |
|
||||
@@ -345,9 +407,9 @@ Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first
|
||||
| ai_summary_view.py | MAX_CHARS_PER_FILE | 14,000 |
|
||||
| ai_summary_view.py | _OFFICE_ADDRESS | "2815 Hartland Road, Falls Church, VA 22043, USA" |
|
||||
| ai_summary_view.py | CriterionDialog._DESC_SOFT_LIMIT | 500 (chars, soft guidance only) |
|
||||
| app.py | App._IDLE_THROTTLE_S | 5.0 (seconds between Motion-event timer resets) |
|
||||
| admin_users_view.py | _PasswordResetDialog._AUTO_CLOSE_S | 120 (seconds before auto-close) |
|
||||
| admin_users_view.py | _PasswordResetDialog._CLIP_CLEAR_S | 30 (seconds before clipboard wipe) |
|
||||
| utils/scheduler.py | (timeout in _make_smtp_server) | 15 seconds |
|
||||
|
||||
---
|
||||
|
||||
@@ -370,9 +432,8 @@ Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first
|
||||
|
||||
6. Weekly site logic uses YEARWEEK(..., 1) (ISO week, Monday start).
|
||||
|
||||
7. The email scheduler `last_sent_date` is now persisted to `config.ini`
|
||||
`[email] last_sent_date` (ISO format). A restart after the configured
|
||||
`send_time` will not re-send the report on the same day.
|
||||
7. The email scheduler last_sent_date is persisted to config.ini [email] last_sent_date
|
||||
(ISO format). A restart after the configured send_time will not re-send that day.
|
||||
|
||||
8. All log messages use ASCII only (hyphens not em-dashes/arrows) to prevent
|
||||
cp1252 UnicodeEncodeError on Windows consoles.
|
||||
@@ -381,8 +442,19 @@ Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first
|
||||
If config.ini is copied to a different machine or user account, credentials
|
||||
cannot be decrypted. Users must re-enter credentials via the Settings dialog.
|
||||
|
||||
10. The AI summary view fetches credentials from the DB at card render time (not lazily).
|
||||
Avoid having hundreds of sites with credentials as this adds DB round-trips per render.
|
||||
10. Theme toggle destroys and rebuilds the entire shell. Any unsaved in-memory
|
||||
state in the active panel is lost. AI summary output is not preserved across
|
||||
theme toggles (it is in ai_analysis_log; retrieve from History tab).
|
||||
|
||||
11. _make_smtp_server calls ehlo() explicitly before and after starttls() — this is
|
||||
required on Windows. Removing these calls breaks SMTP on many servers.
|
||||
|
||||
12. The security field in config.ini supersedes use_tls. On existing deployments
|
||||
without a security field, load_email_config() derives it from use_tls for
|
||||
backward compatibility.
|
||||
|
||||
13. get_all_criteria() does NOT join users table. The creator column is excluded
|
||||
intentionally — the treeview doesn't show it and the join is wasted I/O.
|
||||
|
||||
---
|
||||
|
||||
@@ -403,8 +475,8 @@ pywin32>=306
|
||||
docx2txt>=0.8
|
||||
```
|
||||
|
||||
stdlib used: tkinter, csv, smtplib, urllib.request, configparser, threading, calendar,
|
||||
datetime, base64, re, tempfile
|
||||
stdlib used: tkinter, csv, smtplib, urllib.request, urllib.parse, configparser,
|
||||
threading, calendar, datetime, base64, re, tempfile, socket
|
||||
|
||||
---
|
||||
|
||||
@@ -429,7 +501,28 @@ My Shift · 🤖 AI Summary · Change Password · Sign Out
|
||||
|
||||
---
|
||||
|
||||
## 13. Deployment Notes
|
||||
## 13. SMTP Configuration Reference
|
||||
|
||||
| Security Mode | Protocol | Default Port | Use When |
|
||||
|---|---|---|---|
|
||||
| STARTTLS | Plain → TLS upgrade | 587 | Office 365, Exchange, most corporate |
|
||||
| SSL/TLS | TLS from first byte | 465 | Gmail direct, some providers |
|
||||
| None | Unencrypted | 25 | Internal relay servers only |
|
||||
|
||||
Connection flow in _make_smtp_server:
|
||||
- STARTTLS: SMTP() → ehlo() → starttls() → ehlo()
|
||||
- SSL: SMTP_SSL() → ehlo()
|
||||
- None: SMTP() → ehlo()
|
||||
|
||||
test_smtp_connection() diagnostic steps:
|
||||
1. DNS — getaddrinfo(); fails = hostname wrong or no network
|
||||
2. TCP — create_connection(); fails = firewall blocking the port
|
||||
3. TLS — _make_smtp_server(); fails = wrong security mode or port
|
||||
4. Auth — server.login(); fails = wrong username/password or App Password needed
|
||||
|
||||
---
|
||||
|
||||
## 14. Deployment Notes
|
||||
|
||||
- Python 3.9+ required. 3.12 tested on Windows.
|
||||
- tkinter bundled on Windows/macOS; Linux: apt install python3-tk
|
||||
@@ -439,30 +532,41 @@ My Shift · 🤖 AI Summary · Change Password · Sign Out
|
||||
- pywin32 required for DPAPI encryption (Windows only). Install: pip install pywin32
|
||||
- Microsoft Word recommended for .doc file support in AI Summary (falls back to docx2txt)
|
||||
- Groq API key required for AI Summary feature — free at https://console.groq.com
|
||||
- Gmail users: enable 2FA, then create an App Password at myaccount.google.com/apppasswords
|
||||
- config.ini is machine- and user-account-specific. Do not share or copy between machines.
|
||||
|
||||
---
|
||||
|
||||
## 14. AI Summary Feature — Setup & Prompt Details
|
||||
## 15. AI Summary Feature — Setup & Prompt Details
|
||||
|
||||
### Setup (Admin)
|
||||
### Setup (Admin only)
|
||||
1. Obtain a free Groq API key at https://console.groq.com
|
||||
2. Navigate to **🤖 AI Summary** in the sidebar
|
||||
2. Navigate to 🤖 AI Summary in the sidebar
|
||||
3. Enter the API key and select a model
|
||||
4. Click **💾 Save Settings** — key is stored DPAPI-encrypted in config.ini
|
||||
4. Click 💾 Save Settings — key stored DPAPI-encrypted in config.ini [groq]
|
||||
|
||||
### Supported Models
|
||||
- llama-3.3-70b-versatile (default, recommended)
|
||||
- llama-3.1-8b-instant
|
||||
- llama-3.1-8b-instant (faster, smaller)
|
||||
- gemma2-9b-it
|
||||
- mixtral-8x7b-32768
|
||||
|
||||
### Prompt Strategy
|
||||
The prompt instructs the AI to extract 12 labeled fields per document:
|
||||
Solicitation Number, Type, Set-Aside, Description/Scope, Work Site, Pre-Proposal Conference,
|
||||
POC, Square Footage, **Driving Distance from 2815 Hartland Rd Falls Church VA** (calculated by AI),
|
||||
Extraction: 12 labeled fields per document:
|
||||
Solicitation Number, Type, Set-Aside, Description/Scope, Work Site,
|
||||
Pre-Proposal Conference, POC, Square Footage,
|
||||
Driving Distance from 2815 Hartland Rd Falls Church VA (AI estimate),
|
||||
Last Day for Questions, Due Date, Other requirements.
|
||||
|
||||
Then produces an Overall Summary with 4 sections:
|
||||
Overall Summary: 4 sections:
|
||||
A. Scope of Work · B. Contract Period · C. Proposal Submission Requirements · D. Key Deadlines
|
||||
|
||||
The driving distance/time is an AI estimate using major highways. Actual times may vary with traffic.
|
||||
Alignment evaluation (when criteria active):
|
||||
- Each criterion assessed as MEETS / DOES NOT MEET / PARTIALLY MEETS
|
||||
- RECOMMENDATION: PURSUE | PASS | UNCLEAR on its own line (machine-read by _parse_verdict)
|
||||
- Executive Summary: 2-3 sentences
|
||||
|
||||
### History & Audit
|
||||
- Every analysis is auto-saved to ai_analysis_log (verdict + criteria snapshot at time of run)
|
||||
- update_criterion() logs full description text in activity_log.detail for audit trail
|
||||
- Admins see all users' analyses; users see only their own
|
||||
@@ -1,136 +1,192 @@
|
||||
# Website Checker — Desktop App
|
||||
# Website Checker
|
||||
|
||||
A Tkinter-based desktop application for shift-based website monitoring,
|
||||
backed by a remote MySQL database.
|
||||
A Windows desktop application for shift-based website monitoring.
|
||||
Teams use it to systematically verify that assigned websites are operational
|
||||
each shift. Administrators manage users, websites, shifts, and receive
|
||||
automated daily email reports. An AI-powered document analysis panel helps
|
||||
evaluate government solicitations against configurable business criteria.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
## Requirements
|
||||
|
||||
- **Windows 10/11** (primary platform)
|
||||
- **Python 3.9 or newer** (3.12 recommended; must include Tkinter — standard on Windows)
|
||||
- **MySQL 5.7+ or MariaDB 10.3+** accessible over the network
|
||||
- **pip packages** listed in `requirements.txt`
|
||||
|
||||
Optional but recommended:
|
||||
- **Microsoft Word** — for reading legacy `.doc` files in AI Summary
|
||||
- **Groq API key** — free at https://console.groq.com, required for AI Summary
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Clone or extract the project
|
||||
|
||||
```
|
||||
website_checker/
|
||||
├── app.py ← Entry point & main application shell
|
||||
├── config.py ← DB config, connection pool, schema init
|
||||
├── models.py ← All database access (CRUD + logging)
|
||||
├── app.py
|
||||
├── config.py
|
||||
├── models.py
|
||||
├── requirements.txt
|
||||
├── utils/
|
||||
│ └── ui_helpers.py ← Theme, colour constants, reusable widgets
|
||||
└── views/
|
||||
├── login_view.py ← Login screen
|
||||
├── admin_users_view.py ← Admin: User Management
|
||||
├── admin_websites_view.py ← Admin: Website Link Management
|
||||
├── admin_log_view.py ← Admin: Activity Log
|
||||
└── user_dashboard_view.py ← User: Shift Checklist Dashboard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.9 or newer (must include Tkinter — standard on Windows/macOS)
|
||||
- A remote MySQL 5.7+ / MariaDB 10.3+ server
|
||||
- The database and a user with CREATE / INSERT / UPDATE / DELETE privileges
|
||||
|
||||
---
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Install Python dependencies
|
||||
### 2. Install dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Configure the database connection
|
||||
Key packages: `mysql-connector-python`, `bcrypt`, `cryptography`, `openpyxl`,
|
||||
`matplotlib`, `plyer`, `reportlab`, `groq`, `pypdf`, `python-docx`, `pywin32`, `docx2txt`
|
||||
|
||||
Open `config.py` and update the `DB_CONFIG` dictionary:
|
||||
### 3. Create the MySQL database
|
||||
|
||||
```python
|
||||
DB_CONFIG = {
|
||||
"host": "your-mysql-host", # ← change this
|
||||
"port": 3306,
|
||||
"database": "website_checker", # ← create this DB first
|
||||
"user": "your-db-user", # ← change this
|
||||
"password": "your-db-password", # ← change this
|
||||
}
|
||||
```sql
|
||||
CREATE DATABASE website_checker CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
> **Important:** Create the database on your MySQL server first:
|
||||
> ```sql
|
||||
> CREATE DATABASE website_checker CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
> ```
|
||||
Grant a dedicated user full access to this database.
|
||||
|
||||
### 3. Run the application
|
||||
### 4. Run the application
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
On first launch, the app automatically creates all required tables and seeds a
|
||||
default admin account:
|
||||
On first launch, a **Database Settings** dialog appears. Enter your MySQL host,
|
||||
port, database name, username, and password. Click **Test Connection**, then
|
||||
**Save & Connect**.
|
||||
|
||||
---
|
||||
|
||||
## First Login
|
||||
|
||||
The database is auto-initialised on first successful connection.
|
||||
A default administrator account is created:
|
||||
|
||||
| Field | Value |
|
||||
|----------|------------|
|
||||
| Username | `admin` |
|
||||
| Password | `admin123` |
|
||||
|
||||
**Change the admin password immediately after first login.**
|
||||
**Change this password immediately** via the sidebar → Change Password.
|
||||
|
||||
---
|
||||
|
||||
## Feature Overview
|
||||
|
||||
### Admin Role
|
||||
### For Regular Users
|
||||
|
||||
| Feature | Description |
|
||||
|---|---|
|
||||
| User Management | Create, edit, deactivate, and delete users; assign admin or regular role |
|
||||
| Website Management | Add sites with name, URL, multiple login credentials, and notes |
|
||||
| Activity Log | View a chronological audit trail of all create/edit/delete/login events |
|
||||
| Shift Dashboard | Admins can also use the shift checklist like regular users |
|
||||
| **My Shift checklist** | View all websites assigned to your shift for today |
|
||||
| **Site health indicator** | Colour dot shows if site is reachable (green/amber/red) |
|
||||
| **One-click check-off** | Click the checkbox or press Space to mark a site checked |
|
||||
| **Open in browser** | Click the site name or URL to open it directly |
|
||||
| **Credentials popup** | View stored login credentials with password reveal and copy |
|
||||
| **Shift notes** | Write a per-site note for each shift day |
|
||||
| **Bulk check** | Select All Unchecked → mark all in one action |
|
||||
| **Search / filter** | Search by name, filter by checked/unchecked status |
|
||||
| **Shift reminder** | Desktop notification X minutes before shift ends listing all unchecked sites |
|
||||
| **🤖 AI Summary** | Upload solicitation documents for AI-powered extraction and analysis |
|
||||
| **Change Password** | Self-service password change with strength indicator |
|
||||
|
||||
### For Administrators
|
||||
|
||||
All user features, plus:
|
||||
|
||||
### Regular User Role
|
||||
| Feature | Description |
|
||||
|---|---|
|
||||
| Login | Secure username/password login |
|
||||
| Shift Checklist | See all active websites; click URL to open in browser; check off each site |
|
||||
| Credentials Popup | Clicking a site shows its stored login credentials with a password reveal toggle |
|
||||
| Notes | Write or update a per-site note for each shift day |
|
||||
| Progress Bar | Visual indicator of how many sites have been checked this shift |
|
||||
| **Dashboard** | KPI cards (users online, sites checked today, completion %) + per-user progress table |
|
||||
| **Website Management** | Add/edit/delete websites; set visibility (all users or assigned only); store credentials |
|
||||
| **User Management** | Create/edit/deactivate/delete users; assign roles; reset passwords securely |
|
||||
| **Shift Management** | Create/edit shifts with days-of-week, start/end times, assigned users and websites |
|
||||
| **Reports** | Shift Detail / Unchecked Sites / Summary / Completion Chart — all exportable |
|
||||
| **Activity Log** | Full audit trail of every action in the system |
|
||||
| **Email Reports** | Automated daily HTML report via SMTP; STARTTLS, SSL/TLS, or plain |
|
||||
| **AI Summary settings** | Configure Groq API key, model selection, and evaluation criteria |
|
||||
|
||||
---
|
||||
|
||||
## Activity Logging
|
||||
## SMTP Email Setup
|
||||
|
||||
Every significant action is recorded in the `activity_log` table:
|
||||
Navigate to the sidebar gear icon → **Email Report Settings**.
|
||||
|
||||
| Action | Trigger |
|
||||
| Security Mode | Port | Use For |
|
||||
|---|---|---|
|
||||
| STARTTLS | 587 | Office 365, Exchange, most corporate servers |
|
||||
| SSL / TLS | 465 | Gmail (with App Password), some providers |
|
||||
| None | 25 | Internal relay servers only |
|
||||
|
||||
**Gmail users:** Enable 2-Step Verification, then create an App Password at
|
||||
`myaccount.google.com/apppasswords`. Use the App Password — not your Gmail password.
|
||||
|
||||
Use **🔌 Test Connection** to run a step-by-step diagnostic (DNS → TCP → TLS → Auth).
|
||||
Use **📧 Send Test Email** to verify full end-to-end delivery.
|
||||
|
||||
---
|
||||
|
||||
## AI Summary Setup
|
||||
|
||||
1. Get a free API key at https://console.groq.com
|
||||
2. Open **🤖 AI Summary** → enter the key → **💾 Save Settings**
|
||||
3. Upload PDF, Word, or Excel solicitation documents
|
||||
4. Click **✨ Analyze with AI**
|
||||
|
||||
The AI extracts solicitation fields, calculates driving distance from the office,
|
||||
and — when evaluation criteria are configured — issues a PURSUE / PASS / UNCLEAR
|
||||
recommendation. All analyses are saved to history and can be reviewed at any time.
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
- All sensitive configuration values (DB password, SMTP password, Groq API key)
|
||||
are encrypted using **Windows DPAPI** and tied to the current Windows user account.
|
||||
`config.ini` cannot be decrypted on another machine or account.
|
||||
- Website credentials are encrypted with **Fernet (AES-128-CBC + HMAC)**.
|
||||
- User passwords are hashed with **bcrypt (rounds=12)**.
|
||||
- Accounts are locked for 15 minutes after 5 failed login attempts.
|
||||
- Sessions time out after 30 minutes of inactivity.
|
||||
- The password reset dialog auto-closes after 120 seconds and wipes the clipboard after 30 seconds.
|
||||
|
||||
---
|
||||
|
||||
## File Reference
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `LOGIN` / `LOGOUT` | User authentication events |
|
||||
| `CREATE_USER` | Admin creates a new user |
|
||||
| `UPDATE_USER` | Admin edits a user |
|
||||
| `DELETE_USER` | Admin deletes a user |
|
||||
| `CREATE_WEBSITE` | Admin adds a website |
|
||||
| `UPDATE_WEBSITE` | Admin edits a website |
|
||||
| `DELETE_WEBSITE` | Admin soft-deletes a website |
|
||||
| `CHECK_WEBSITE` | User marks a website as checked |
|
||||
| `UPDATE_NOTE` | User updates their shift note |
|
||||
|
||||
Logs are also written to `app.log` in the application directory.
|
||||
| `app.py` | Entry point — run this |
|
||||
| `config.ini` | Auto-created; stores DB/SMTP/Groq settings (encrypted) |
|
||||
| `app.log` | Application log — check here when troubleshooting |
|
||||
| `CLAUDE.md` | Full technical reference for developers |
|
||||
| `USER_MANUAL.md` | Step-by-step guide for end users |
|
||||
|
||||
---
|
||||
|
||||
## Database Schema (auto-created on first run)
|
||||
## Troubleshooting
|
||||
|
||||
- `users` — application accounts with role-based access
|
||||
- `websites` — monitored sites
|
||||
- `website_credentials` — multiple username/password pairs per site
|
||||
- `shift_checks` — one record per user per site per day
|
||||
- `activity_log` — full audit trail
|
||||
| Symptom | Resolution |
|
||||
|---|---|
|
||||
| "Cannot connect to database" | Verify host/port/credentials in Settings; check MySQL firewall |
|
||||
| "SMTP error: Connection unexpectedly closed" | Wrong security mode — try SSL/TLS on port 465 |
|
||||
| "Authentication failed" | Wrong password; Gmail requires an App Password |
|
||||
| "AI analysis failed: 401" | Invalid or expired Groq API key |
|
||||
| "No readable text found" | PDF may be scanned/image-only; try a text-based PDF |
|
||||
| App won't start (tkinter error) | Reinstall Python with Tkinter option checked |
|
||||
| Config.ini decryption error | config.ini was moved from another machine — re-enter credentials |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
## Default Admin Credentials
|
||||
|
||||
- Websites are **soft-deleted** (flagged inactive) to preserve historical check records.
|
||||
- Passwords are stored as **SHA-256 hashes**. For production, consider upgrading to `bcrypt`.
|
||||
- The connection pool size is set to 5; increase `pool_size` in `config.py` for larger teams.
|
||||
| Username | Password |
|
||||
|---|---|
|
||||
| `admin` | `admin123` |
|
||||
|
||||
Change immediately after first login.
|
||||
+689
@@ -0,0 +1,689 @@
|
||||
# Website Checker — User Manual
|
||||
|
||||
**Version 1.0** | For all users
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Getting Started](#1-getting-started)
|
||||
2. [Logging In](#2-logging-in)
|
||||
3. [My Shift — The Daily Checklist](#3-my-shift--the-daily-checklist)
|
||||
4. [Checking Off a Website](#4-checking-off-a-website)
|
||||
5. [Site Health Indicator](#5-site-health-indicator)
|
||||
6. [Viewing Credentials](#6-viewing-credentials)
|
||||
7. [Shift Reminder Notifications](#7-shift-reminder-notifications)
|
||||
8. [AI Document Summary](#8-ai-document-summary)
|
||||
9. [Evaluation Criteria](#9-evaluation-criteria)
|
||||
10. [Analysis History](#10-analysis-history)
|
||||
11. [Change Password](#11-change-password)
|
||||
12. [Admin — Dashboard](#12-admin--dashboard)
|
||||
13. [Admin — Website Management](#13-admin--website-management)
|
||||
14. [Admin — User Management](#14-admin--user-management)
|
||||
15. [Admin — Shift Management](#15-admin--shift-management)
|
||||
16. [Admin — Reports](#16-admin--reports)
|
||||
17. [Admin — Activity Log](#17-admin--activity-log)
|
||||
18. [Admin — Email Report Settings](#18-admin--email-report-settings)
|
||||
19. [Admin — AI Summary Settings](#19-admin--ai-summary-settings)
|
||||
20. [Session Timeout](#20-session-timeout)
|
||||
21. [Keyboard Shortcuts](#21-keyboard-shortcuts)
|
||||
22. [Troubleshooting](#22-troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## 1. Getting Started
|
||||
|
||||
Website Checker is a desktop application that helps your team systematically
|
||||
verify that assigned websites are operational each shift. Every team member
|
||||
logs in, checks each site, and marks it complete. Administrators can see
|
||||
team-wide progress in real time, run reports, and receive automated daily emails.
|
||||
|
||||
**Who sees what:**
|
||||
|
||||
| Feature | Regular User | Administrator |
|
||||
|---|---|---|
|
||||
| My Shift checklist | ✔ | ✔ |
|
||||
| AI Document Summary | ✔ | ✔ |
|
||||
| Change Password | ✔ | ✔ |
|
||||
| Dashboard & Reports | — | ✔ |
|
||||
| Manage Websites / Users / Shifts | — | ✔ |
|
||||
| Email Settings & Activity Log | — | ✔ |
|
||||
| AI API key & model selection | — | ✔ |
|
||||
| Add/Edit Evaluation Criteria | — | ✔ |
|
||||
|
||||
---
|
||||
|
||||
## 2. Logging In
|
||||
|
||||
1. Launch the application by running `app.py` (or the desktop shortcut).
|
||||
2. Enter your **Username** and **Password**.
|
||||
3. Press **Enter** or click **Sign In**.
|
||||
|
||||
**If your account is locked:** After 5 failed attempts your account is locked
|
||||
for 15 minutes. The login screen shows a countdown timer. Wait for it to reach
|
||||
zero and try again with the correct password.
|
||||
|
||||
**If you forget your password:** Contact your administrator. They can reset it
|
||||
from the User Management panel.
|
||||
|
||||
---
|
||||
|
||||
## 3. My Shift — The Daily Checklist
|
||||
|
||||
After logging in, regular users land on the **My Shift** panel. This shows all
|
||||
websites you are expected to check during your current shift today.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ My Shift [ Search... ] [All ▾] [✓ Select All Unchecked] │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ ● [✓] Portal ↗ Open 🔑 Credentials │
|
||||
│ https://portal.gov Shift: Morning │
|
||||
│ Note: Check dashboard for alerts [Edit] │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ ● [ ] Vendor Hub ↗ Open 🔑 Credentials │
|
||||
│ https://vendor.example.com Shift: Morning │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ Progress: ████████░░ 1 / 2 checked (50%) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Elements
|
||||
|
||||
- **Colour dot** — health status of the site (see §5)
|
||||
- **Checkbox** — tick to mark the site as checked for today
|
||||
- **Site name / URL** — click either to open the site in your browser
|
||||
- **🔑 Credentials** — appears only if credentials are stored; click to view
|
||||
- **Note field** — free-text area to leave a per-site note for the shift
|
||||
- **Progress bar** — shows how many sites you have checked vs total
|
||||
|
||||
### Filtering and search
|
||||
|
||||
- **Search box** — type any part of a site name or URL to filter the list
|
||||
- **Dropdown filter** — choose All, Checked, or Unchecked
|
||||
- **✓ Select All Unchecked** — ticks all currently visible unchecked sites at once
|
||||
|
||||
---
|
||||
|
||||
## 4. Checking Off a Website
|
||||
|
||||
1. Open the website (click the name or URL).
|
||||
2. Verify the site is operational.
|
||||
3. Tick the **checkbox** on the site card, or press **Space** when the card is focused.
|
||||
4. The checkbox turns green and the progress bar advances.
|
||||
|
||||
To **uncheck** a site, tick the checkbox again. You can re-check as many times
|
||||
as needed throughout the shift.
|
||||
|
||||
**Weekly sites** appear only once per week. Once checked, they disappear from
|
||||
your list until the following Monday.
|
||||
|
||||
---
|
||||
|
||||
## 5. Site Health Indicator
|
||||
|
||||
The coloured dot next to each site name is automatically refreshed in the
|
||||
background when you open the checklist.
|
||||
|
||||
| Colour | Meaning |
|
||||
|---|---|
|
||||
| 🟢 Green | Site is reachable and responding quickly (under 3 seconds) |
|
||||
| 🟡 Amber | Site is reachable but slow (over 3 seconds), OR responding with a 4xx access restriction |
|
||||
| 🔴 Red | Site is unreachable — network error or server failure |
|
||||
|
||||
**Amber (restricted)** means the server responded — it is online — but rejected
|
||||
the automated probe request. This is normal for sites that block automated checks.
|
||||
Open the site manually to verify it is actually working.
|
||||
|
||||
Hover over the dot to see the tooltip with exact response time and status.
|
||||
|
||||
---
|
||||
|
||||
## 6. Viewing Credentials
|
||||
|
||||
If your administrator has stored login credentials for a site, a **🔑 Credentials**
|
||||
button appears on that site's card.
|
||||
|
||||
1. Click **🔑 Credentials**.
|
||||
2. A popup appears showing all stored credential sets for that site.
|
||||
3. Click **📋** next to the username to copy it to the clipboard (flashes "Copied!" for 2 seconds).
|
||||
4. Click **📋** next to the password to copy it (also clears the clipboard automatically after 15 seconds).
|
||||
5. Click **👁** to reveal the password on screen.
|
||||
|
||||
The popup closes when you click outside it or press Escape.
|
||||
|
||||
---
|
||||
|
||||
## 7. Shift Reminder Notifications
|
||||
|
||||
If you have unchecked sites when your shift is about to end, the app sends a
|
||||
desktop notification listing all unchecked sites together.
|
||||
|
||||
- The reminder fires **15 minutes before your shift end time** by default.
|
||||
- One notification covers all unchecked sites in the shift — you will not receive
|
||||
a separate popup per site.
|
||||
- Example: *"3 sites unchecked: Portal, Vendor Hub, SAM.gov. Shift ends at 17:00 (15 min remaining)."*
|
||||
|
||||
If the desktop notification system is unavailable, an in-app toast message appears
|
||||
in the bottom-right corner instead.
|
||||
|
||||
---
|
||||
|
||||
## 8. AI Document Summary
|
||||
|
||||
The **🤖 AI Summary** panel lets you upload government solicitation documents
|
||||
and receive an AI-generated extraction of key procurement fields, plus an
|
||||
alignment recommendation based on your company's evaluation criteria.
|
||||
|
||||
### Uploading documents
|
||||
|
||||
1. Click **➕ Add Files** in the left pane.
|
||||
2. Select one or more files. Supported formats:
|
||||
- PDF (`.pdf`)
|
||||
- Word documents (`.docx`, `.doc`)
|
||||
- Excel spreadsheets (`.xlsx`, `.xls`)
|
||||
- Text files (`.txt`, `.md`, `.csv`)
|
||||
3. The file list shows each file name and size.
|
||||
4. To remove a file, right-click it and choose **Remove selected**, or use **✕ Clear All**.
|
||||
|
||||
### Running the analysis
|
||||
|
||||
1. Click **✨ Analyze with AI**.
|
||||
2. The status bar shows progress as each file is read and the AI processes them.
|
||||
3. Results appear in the right pane when complete.
|
||||
|
||||
### What the AI extracts
|
||||
|
||||
For each document, the AI identifies and labels:
|
||||
|
||||
| Field | Example |
|
||||
|---|---|
|
||||
| Solicitation Number | W912DR-25-R-0042 |
|
||||
| Solicitation Type | RFP, RFQ, IFB |
|
||||
| Set-Aside | Small Business, 8(a), N/A |
|
||||
| Description / Scope of Work | Summary of required services |
|
||||
| Work Site / Location | Full address of performance site |
|
||||
| Pre-Proposal Conference | Date, time, address, mandatory or optional |
|
||||
| Point of Contact (POC) | Name, phone, email |
|
||||
| Total Square Footage | If applicable |
|
||||
| Driving Distance & Travel Time | From office to conference/site (AI estimate) |
|
||||
| Last Day to Submit Questions | Date |
|
||||
| Due Date & Time | Submission deadline |
|
||||
| Other Notable Requirements | Bonding, insurance, certifications |
|
||||
|
||||
After the per-document breakdown, the AI produces an **Overall Summary** with:
|
||||
|
||||
- **A. Scope of Work** — what is being requested and key performance requirements
|
||||
- **B. Contract Period** — base period, option years, anticipated start date
|
||||
- **C. Proposal Submission Requirements** — documents, formatting, evaluation criteria
|
||||
- **D. Key Deadlines & Action Items** — all critical dates in chronological order
|
||||
|
||||
### Saving and copying results
|
||||
|
||||
- **📋 Copy** — copies the full output text to the clipboard
|
||||
- **💾 Save as TXT** — saves the output to a text file you choose
|
||||
- **🗑 Clear** — resets the output panel
|
||||
|
||||
---
|
||||
|
||||
## 9. Evaluation Criteria
|
||||
|
||||
When your administrator has configured evaluation criteria, the AI automatically
|
||||
assesses the solicitation against each one and issues a recommendation.
|
||||
|
||||
### The criteria panel
|
||||
|
||||
The **📋 Evaluation Criteria** panel sits below the page header and is collapsible.
|
||||
It shows all currently configured criteria with their title, description, sort order,
|
||||
and active status.
|
||||
|
||||
Criteria marked **✔ Active** are included in every AI analysis you run.
|
||||
Criteria marked **✘ Inactive** are shown for reference but not sent to the AI.
|
||||
|
||||
### The alignment verdict
|
||||
|
||||
When active criteria exist, the analysis output includes a colour-coded banner:
|
||||
|
||||
| Banner | Meaning |
|
||||
|---|---|
|
||||
| ✅ Green — PURSUE | The opportunity strongly aligns with your criteria |
|
||||
| 🚫 Red — PASS | The opportunity fails one or more key criteria |
|
||||
| ⚠️ Amber — UNCLEAR | Insufficient information in the documents to decide |
|
||||
|
||||
Below the banner, the AI lists each criterion and explains whether the solicitation
|
||||
**MEETS**, **DOES NOT MEET**, or **PARTIALLY MEETS** it, citing specific details
|
||||
from the documents. It ends with a 2-3 sentence executive summary.
|
||||
|
||||
### For administrators — managing criteria
|
||||
|
||||
Click **+ Add Criterion** in the criteria panel header to open the Add Criterion dialog.
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| Title * | Short name shown in the treeview (e.g. "Geographic Range") |
|
||||
| Description * | Full criterion text sent to the AI — be specific |
|
||||
| Sort Order | Lower numbers appear first |
|
||||
| Active | Uncheck to exclude from AI analysis without deleting |
|
||||
|
||||
**Tips for writing effective criteria:**
|
||||
|
||||
- Be specific and measurable: *"Work site must be within 50 miles of Falls Church, VA"*
|
||||
is better than *"Site must be nearby."*
|
||||
- One criterion per idea — don't combine multiple requirements in one description.
|
||||
- The description has a **500-character soft limit**. Longer descriptions consume
|
||||
token budget that would otherwise go to the document content.
|
||||
|
||||
To **edit** a criterion, select it in the list and click **✎ Edit**, or double-click the row.
|
||||
To **delete** a criterion, select it and click **✕ Delete**. You will be asked to confirm.
|
||||
|
||||
---
|
||||
|
||||
## 10. Analysis History
|
||||
|
||||
Every analysis you run is automatically saved. Click the **🕑 History** tab to review past results.
|
||||
|
||||
### History tab features
|
||||
|
||||
- **Treeview** — shows Date/Time, Files analyzed, Model used, and Verdict for each analysis.
|
||||
Administrators see all users' analyses; regular users see only their own.
|
||||
- **Verdict filter** — radio buttons let you filter by All / PURSUE / PASS / UNCLEAR / No verdict.
|
||||
- **Detail strip** — selecting a row shows the criteria snapshot and other metadata at the bottom.
|
||||
- **↻ Refresh** — reloads the history from the database.
|
||||
- **🔍 View Result** — loads the full AI output for the selected analysis back into the
|
||||
Analyze tab output panel, including the verdict banner. You can then copy or save it.
|
||||
- **Double-click** on any row does the same as View Result.
|
||||
|
||||
The History tab refreshes automatically whenever you switch to it.
|
||||
|
||||
---
|
||||
|
||||
## 11. Change Password
|
||||
|
||||
All users can change their own password at any time.
|
||||
|
||||
1. Click **Change Password** in the sidebar.
|
||||
2. Enter your **Current Password**.
|
||||
3. Enter and confirm your **New Password**.
|
||||
4. Click **Change Password**.
|
||||
|
||||
Password requirements:
|
||||
- At least 8 characters
|
||||
- At least one uppercase letter
|
||||
- At least one digit (0–9)
|
||||
- At least one special character (`!@#$%^&*` etc.)
|
||||
|
||||
A strength indicator shows how strong the new password is as you type.
|
||||
|
||||
---
|
||||
|
||||
## 12. Admin — Dashboard
|
||||
|
||||
The Dashboard is the administrator's home screen. It refreshes automatically every 60 seconds.
|
||||
|
||||
### KPI cards (top row)
|
||||
|
||||
| Card | Description |
|
||||
|---|---|
|
||||
| Active Users | Number of users with active accounts |
|
||||
| Sites Checked Today | Total site checks recorded today across all users |
|
||||
| Team Completion | Percentage of expected checks completed today |
|
||||
| Unchecked Sites | Number of sites not yet checked today |
|
||||
|
||||
### Per-user progress table
|
||||
|
||||
Shows each active user with their checked count, total expected sites today, and
|
||||
completion percentage. Completion is colour-coded:
|
||||
- 🟢 Green — 100% complete
|
||||
- 🟡 Amber — partially complete
|
||||
- 🔴 Red — not started
|
||||
|
||||
Users with no shifts scheduled today are excluded from the table automatically.
|
||||
|
||||
---
|
||||
|
||||
## 13. Admin — Website Management
|
||||
|
||||
Navigate to **Websites** in the sidebar.
|
||||
|
||||
### Adding a website
|
||||
|
||||
1. Click **+ Add Website**.
|
||||
2. Fill in the required fields:
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| Name * | Display name shown on user dashboards |
|
||||
| URL * | Full web address (https:// will be added if omitted) |
|
||||
| Check Type | Daily (shown every shift day) or Weekly (once per week) |
|
||||
| Visibility | All Users or Assigned Only |
|
||||
| Note | Optional note shown on the user's site card |
|
||||
|
||||
3. If **Assigned Only** is selected, a user assignment panel appears — tick the users who should see this site.
|
||||
4. Optionally expand **🔑 Credentials** to add login credentials for the site.
|
||||
5. Click **Save**.
|
||||
|
||||
### Credentials
|
||||
|
||||
Each website can have multiple credential sets (e.g. Admin login, Read-only login).
|
||||
Each set has a **Label**, **Username**, and **Password**.
|
||||
Passwords are stored encrypted and are only revealed when the user clicks 👁 in the credentials popup.
|
||||
|
||||
### Editing a website
|
||||
|
||||
Select a website in the list and click **✎ Edit**, or double-click the row.
|
||||
All fields can be changed, including credentials.
|
||||
|
||||
### Deactivating / deleting a website
|
||||
|
||||
- **Deactivate** (recommended): Uncheck **Active** in the edit dialog. The site is hidden from
|
||||
users but historical check records are preserved.
|
||||
- **Delete**: Select and click **✕ Delete**. This is permanent.
|
||||
|
||||
---
|
||||
|
||||
## 14. Admin — User Management
|
||||
|
||||
Navigate to **Users** in the sidebar.
|
||||
|
||||
### Adding a user
|
||||
|
||||
1. Click **+ Add User**.
|
||||
2. Fill in Username, Full Name, Role (Admin or User), and Password.
|
||||
3. Click **Save**.
|
||||
|
||||
### Editing a user
|
||||
|
||||
Select a user and click **✎ Edit** or double-click. You can change their name,
|
||||
role, active status, and set a new password.
|
||||
|
||||
### Resetting a password
|
||||
|
||||
1. Select a user and click **🔑 Reset Password**.
|
||||
2. Confirm the prompt.
|
||||
3. A secure dialog appears with a randomly generated 16-character temporary password.
|
||||
4. Click **📋 Copy** to copy it to the clipboard (the clipboard is wiped after 30 seconds).
|
||||
5. Share the temporary password with the user securely (e.g. by phone).
|
||||
6. The dialog auto-closes after 120 seconds.
|
||||
7. The user should change this password immediately after logging in.
|
||||
|
||||
**Notes:**
|
||||
- You cannot reset your own password here — use Change Password in the sidebar.
|
||||
- The last active administrator account cannot be deleted.
|
||||
|
||||
### Deactivating vs deleting
|
||||
|
||||
- **Deactivate** (is_active = off): The user cannot log in but their records are preserved.
|
||||
- **Delete**: Permanent. Use deactivate first if you want to keep audit history.
|
||||
|
||||
---
|
||||
|
||||
## 15. Admin — Shift Management
|
||||
|
||||
Navigate to **Shifts** in the sidebar.
|
||||
|
||||
### What is a shift?
|
||||
|
||||
A shift defines which websites a group of users should check, on which days of the week,
|
||||
and during which time window. A user can belong to multiple shifts.
|
||||
|
||||
### Adding a shift
|
||||
|
||||
1. Click **+ New Shift**.
|
||||
2. Fill in:
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| Shift Name | e.g. "Morning Team", "Weekend Check" |
|
||||
| Days of Week | Tick each day this shift runs |
|
||||
| Start Time | When the shift begins (HH:MM) |
|
||||
| End Time | When the shift ends — used for reminder notifications |
|
||||
| Note | Optional internal note |
|
||||
|
||||
3. On the **Users** tab, tick the users assigned to this shift.
|
||||
4. On the **Websites** tab, tick the websites this shift should check (drag to reorder).
|
||||
5. Click **Save**.
|
||||
|
||||
### Show inactive shifts
|
||||
|
||||
By default, deactivated shifts are hidden. Tick **Show inactive** in the toolbar
|
||||
to display them (shown in grey).
|
||||
|
||||
### Exporting shifts to PDF
|
||||
|
||||
Click **⬇ Export PDF** to generate a printable PDF showing all active shifts,
|
||||
their assigned users, and their website lists.
|
||||
|
||||
---
|
||||
|
||||
## 16. Admin — Reports
|
||||
|
||||
Navigate to **Reports** in the sidebar. There are four tabs.
|
||||
|
||||
### Shift Detail
|
||||
|
||||
Shows every check-in for a specific user and date range.
|
||||
Select a user, set a date range, and click **Generate**.
|
||||
Export to CSV or Excel with the export buttons.
|
||||
|
||||
### Unchecked Sites
|
||||
|
||||
Shows websites that were NOT checked on a given date, scoped to each user's actual shift.
|
||||
If no date is selected, defaults to today.
|
||||
Filter by a specific user or view all users.
|
||||
|
||||
### Summary
|
||||
|
||||
Shows per-user per-day statistics: sites checked, total expected, and completion percentage.
|
||||
Total is based on that user's actual shift assignment for that day — not a global site count.
|
||||
|
||||
### Completion Chart
|
||||
|
||||
A bar chart showing today's completion percentage per user.
|
||||
Bars are colour-coded: green ≥ 100%, amber ≥ 50%, red < 50%.
|
||||
|
||||
---
|
||||
|
||||
## 17. Admin — Activity Log
|
||||
|
||||
Navigate to **Activity Log** in the sidebar.
|
||||
|
||||
The log shows every significant action taken in the system, including:
|
||||
logins, logouts, session timeouts, account lockouts, all create/edit/delete operations,
|
||||
password changes and resets, website checks, exports, email setting changes,
|
||||
and AI criterion changes.
|
||||
|
||||
Each entry shows: timestamp, acting user, action type, affected record, and detail notes.
|
||||
|
||||
Use the search bar to filter by username, action type, or any text in the detail field.
|
||||
Use the date range pickers to narrow the time window.
|
||||
|
||||
---
|
||||
|
||||
## 18. Admin — Email Report Settings
|
||||
|
||||
Open **Settings** (gear icon in sidebar) → **Email Report Settings**, or click the
|
||||
email icon if visible.
|
||||
|
||||
### Configuration fields
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| Enable daily emails | Master on/off switch |
|
||||
| SMTP Host | Your mail server address |
|
||||
| Security | STARTTLS (port 587), SSL/TLS (port 465), or None (port 25) |
|
||||
| SMTP Port | Auto-filled when Security mode is selected; can be changed manually |
|
||||
| SMTP Username | Usually your full email address |
|
||||
| SMTP Password | For Gmail, use an App Password — not your Gmail password |
|
||||
| Recipients | Comma-separated email addresses to receive the report |
|
||||
| Send Time | HH:MM (24-hour) — the time the report is sent each day |
|
||||
|
||||
### Testing the connection
|
||||
|
||||
**🔌 Test Connection** runs a step-by-step diagnostic:
|
||||
|
||||
1. **DNS** — can the server name be resolved?
|
||||
2. **TCP** — can a connection be made to the port?
|
||||
3. **TLS** — does the TLS/security handshake succeed?
|
||||
4. **Auth** — are the username and password accepted?
|
||||
|
||||
The status line reports exactly which step failed and what to check.
|
||||
|
||||
**📧 Send Test Email** sends a real test message to the configured recipients.
|
||||
Use this to confirm full end-to-end delivery after the connection test passes.
|
||||
|
||||
### Common setups
|
||||
|
||||
**Office 365 / Exchange Online:**
|
||||
- Security: STARTTLS
|
||||
- Port: 587
|
||||
- Username: full email address
|
||||
- Password: your Microsoft account password or app password
|
||||
|
||||
**Gmail (Google Workspace):**
|
||||
- Security: SSL / TLS
|
||||
- Port: 465
|
||||
- Username: full Gmail address
|
||||
- Password: App Password (not your Gmail password)
|
||||
→ Enable 2-Step Verification → myaccount.google.com/apppasswords
|
||||
|
||||
**Internal relay:**
|
||||
- Security: None
|
||||
- Port: 25
|
||||
- Username/Password: may not be required
|
||||
|
||||
### What the daily report contains
|
||||
|
||||
The report email lists every active user who had shifts today, showing:
|
||||
- Number of sites checked
|
||||
- Total sites expected for their shift
|
||||
- Completion percentage (colour-coded)
|
||||
|
||||
Users with no shift scheduled today are excluded, with a footnote showing the count.
|
||||
|
||||
---
|
||||
|
||||
## 19. Admin — AI Summary Settings
|
||||
|
||||
### Setting up the API key
|
||||
|
||||
1. Get a free API key at **https://console.groq.com** (no credit card required).
|
||||
2. Open **🤖 AI Summary** in the sidebar.
|
||||
3. Enter the API key in the **Groq API Key** field.
|
||||
4. Select a model (see below).
|
||||
5. Click **💾 Save Settings**.
|
||||
|
||||
The API key is stored encrypted on your machine and is never shared with other users.
|
||||
|
||||
### Available models
|
||||
|
||||
| Model | Best for |
|
||||
|---|---|
|
||||
| llama-3.3-70b-versatile | Best overall quality — recommended default |
|
||||
| llama-3.1-8b-instant | Faster responses, slightly lower quality |
|
||||
| gemma2-9b-it | Good alternative for shorter documents |
|
||||
| mixtral-8x7b-32768 | Large context window for very long documents |
|
||||
|
||||
### Managing evaluation criteria
|
||||
|
||||
See §9 above for full details on adding, editing, and deleting criteria.
|
||||
|
||||
**Key points for admins:**
|
||||
- Criteria are shared — all users' analyses use the same active criteria.
|
||||
- Deactivating a criterion (unchecking Active) removes it from future AI prompts
|
||||
without deleting it — useful for temporarily suspending a criterion.
|
||||
- The full description text is saved in the audit log each time a criterion is updated,
|
||||
so you can always reconstruct what the AI was evaluating against at any historical date.
|
||||
|
||||
---
|
||||
|
||||
## 20. Session Timeout
|
||||
|
||||
The application automatically signs you out after **30 minutes of inactivity**
|
||||
(no mouse movement, key presses, or clicks).
|
||||
|
||||
One minute before timeout, a warning dialog appears. Click **Stay Signed In** to
|
||||
reset the timer. If you don't respond, you are signed out and must log in again.
|
||||
Any unsaved work is lost.
|
||||
|
||||
---
|
||||
|
||||
## 21. Keyboard Shortcuts
|
||||
|
||||
Available on the My Shift dashboard:
|
||||
|
||||
| Shortcut | Action |
|
||||
|---|---|
|
||||
| `Ctrl+F` | Focus the search box |
|
||||
| `Ctrl+A` | Select all unchecked sites |
|
||||
| `Ctrl+Enter` | Mark selected site as checked |
|
||||
| `Space` | Toggle checkbox on focused site card |
|
||||
| `Escape` | Clear search / deselect |
|
||||
|
||||
---
|
||||
|
||||
## 22. Troubleshooting
|
||||
|
||||
### I can't log in
|
||||
|
||||
- Check Caps Lock — passwords are case-sensitive.
|
||||
- After 5 failed attempts, your account is locked for 15 minutes.
|
||||
Wait for the countdown to finish and try again.
|
||||
- Contact your administrator if you need a password reset.
|
||||
|
||||
### My shift shows no sites
|
||||
|
||||
- You may not be assigned to any active shift scheduled for today.
|
||||
- Contact your administrator to verify your shift assignment and the days-of-week setting.
|
||||
|
||||
### The health dot is always red for a site
|
||||
|
||||
- The site may block automated probes (returns 4xx — shown as amber, not red).
|
||||
- A red dot means a network error — the server is not responding at all.
|
||||
- Check your internet connection. If other sites are green, the specific site may be down.
|
||||
|
||||
### The AI analysis fails with "401"
|
||||
|
||||
- Your Groq API key is invalid or expired.
|
||||
- Log in to https://console.groq.com, generate a new key, and update it in AI Summary settings.
|
||||
|
||||
### The AI analysis says "No readable text found"
|
||||
|
||||
- The PDF may be a scanned image rather than a text-based PDF.
|
||||
- Try using a version of the document that is copy-pasteable, or a Word/Excel version.
|
||||
|
||||
### Email test fails at Step 3 (TLS)
|
||||
|
||||
- You are using the wrong security mode for your mail server.
|
||||
- Try switching from STARTTLS to SSL/TLS (port 465) or vice versa.
|
||||
- Contact your IT department to confirm the correct SMTP settings.
|
||||
|
||||
### Email test fails at Step 4 (Auth)
|
||||
|
||||
- For Gmail/Google Workspace: you must use an **App Password**, not your regular password.
|
||||
Go to myaccount.google.com/apppasswords to create one.
|
||||
- For Office 365: your account may require Modern Authentication or an app password.
|
||||
Contact your IT department.
|
||||
|
||||
### I can't see the Evaluation Criteria panel
|
||||
|
||||
- The panel is present for all users but may be collapsed. Click **▼ Expand** to open it.
|
||||
|
||||
### My analysis history is empty
|
||||
|
||||
- History is only saved for analyses run after the feature was deployed.
|
||||
Earlier analyses are not retroactively added.
|
||||
- Check that you are on the 🕑 History tab (not the ✨ Analyze tab).
|
||||
- Click **↻ Refresh** to reload from the database.
|
||||
|
||||
### The app freezes during AI analysis
|
||||
|
||||
- The AI call runs in a background thread — the UI should remain responsive.
|
||||
- If the app appears frozen, wait up to 60 seconds. Long documents take time to process.
|
||||
- If it remains unresponsive, close and reopen the application. The analysis may have failed silently — check `app.log`.
|
||||
|
||||
---
|
||||
|
||||
*For technical issues not covered here, ask your system administrator to check `app.log`
|
||||
in the application directory for detailed error messages.*
|
||||
@@ -159,12 +159,14 @@ class App(tk.Tk):
|
||||
("📑 Reports", "reports", self._show_reports),
|
||||
("📧 Email Reports","email", self._show_email_settings),
|
||||
("🤖 AI Summary", "ai_summary", self._show_ai_summary),
|
||||
("📌 Bid Tracker", "bid_tracker", self._show_bid_tracker),
|
||||
("⚙ Settings", "settings", self._show_settings),
|
||||
]
|
||||
else:
|
||||
nav_items = [
|
||||
("📊 My Shifts", "shifts", self._show_shifts),
|
||||
("🤖 AI Summary", "ai_summary", self._show_ai_summary),
|
||||
("📌 Bid Tracker", "bid_tracker", self._show_bid_tracker),
|
||||
]
|
||||
|
||||
for label, key, cmd in nav_items:
|
||||
@@ -329,6 +331,12 @@ class App(tk.Tk):
|
||||
from views.ai_summary_view import AiSummaryView
|
||||
AiSummaryView(self.content, self.current_user).pack(fill="both", expand=True)
|
||||
|
||||
def _show_bid_tracker(self):
|
||||
self._clear_content()
|
||||
self._set_active_nav("bid_tracker")
|
||||
from views.bid_tracker_view import BidTrackerView
|
||||
BidTrackerView(self.content, self.current_user).pack(fill="both", expand=True)
|
||||
|
||||
def _on_settings_saved(self):
|
||||
"""Called after settings are saved — reload config and reconnect."""
|
||||
from config import reload_db_config
|
||||
@@ -524,6 +532,7 @@ class App(tk.Tk):
|
||||
"reports": self._show_reports,
|
||||
"email": self._show_email_settings,
|
||||
"ai_summary": self._show_ai_summary,
|
||||
"bid_tracker": self._show_bid_tracker,
|
||||
"settings": self._show_settings,
|
||||
}
|
||||
if active in nav_map:
|
||||
|
||||
@@ -216,6 +216,7 @@ def initialize_database():
|
||||
name VARCHAR(200) NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
check_type ENUM('daily','weekly') NOT NULL DEFAULT 'daily',
|
||||
visibility ENUM('all','assigned') NOT NULL DEFAULT 'all',
|
||||
note TEXT,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_by INT,
|
||||
@@ -228,9 +229,9 @@ def initialize_database():
|
||||
CREATE TABLE IF NOT EXISTS website_credentials (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
website_id INT NOT NULL,
|
||||
username VARCHAR(200) NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
label VARCHAR(100),
|
||||
username VARCHAR(200),
|
||||
password TEXT,
|
||||
FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
""",
|
||||
@@ -253,7 +254,7 @@ def initialize_database():
|
||||
entity VARCHAR(100),
|
||||
entity_id INT,
|
||||
detail TEXT,
|
||||
logged_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
""",
|
||||
@@ -261,9 +262,9 @@ def initialize_database():
|
||||
CREATE TABLE IF NOT EXISTS shifts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
days_of_week VARCHAR(20) NOT NULL DEFAULT '1234567',
|
||||
start_time TIME NOT NULL DEFAULT '00:00:00',
|
||||
end_time TIME NOT NULL DEFAULT '23:59:59',
|
||||
days_of_week VARCHAR(7) NOT NULL DEFAULT '23456',
|
||||
start_time TIME NOT NULL DEFAULT '08:00:00',
|
||||
end_time TIME NOT NULL DEFAULT '17:00:00',
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
note TEXT,
|
||||
created_by INT,
|
||||
@@ -335,6 +336,34 @@ def initialize_database():
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS bid_tracker (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(300) NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
source VARCHAR(200) NULL,
|
||||
solicitation_number VARCHAR(100) NULL,
|
||||
status ENUM('open','monitoring','awarded','no_bid','cancelled')
|
||||
NOT NULL DEFAULT 'open',
|
||||
due_date DATE NULL,
|
||||
notes TEXT NULL,
|
||||
added_by INT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS bid_updates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
bid_id INT NOT NULL,
|
||||
user_id INT,
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (bid_id) REFERENCES bid_tracker(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
""",
|
||||
]
|
||||
|
||||
conn = None
|
||||
@@ -365,7 +394,7 @@ def initialize_database():
|
||||
conn.commit()
|
||||
logger.info("Migration: added check_type column to websites table.")
|
||||
|
||||
# Add failed_attempts column to users if it doesn't exist
|
||||
# Add failed_attempts / locked_until columns to users if absent
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
@@ -384,7 +413,7 @@ def initialize_database():
|
||||
conn.commit()
|
||||
logger.info("Migration: added failed_attempts and locked_until columns to users table.")
|
||||
|
||||
# Add visibility column to websites if it doesn't exist
|
||||
# Add visibility column to websites if absent
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
@@ -402,10 +431,8 @@ def initialize_database():
|
||||
conn.commit()
|
||||
logger.info("Migration: added visibility column to websites table.")
|
||||
|
||||
# Confirm creation of new tables added post-initial-deployment
|
||||
# These use CREATE TABLE IF NOT EXISTS so they are safe on first run.
|
||||
# We log confirmation so operators can verify the upgrade applied.
|
||||
for new_table in ("ai_criteria", "ai_analysis_log"):
|
||||
# Confirm presence of tables added post-initial-deployment
|
||||
for new_table in ("ai_criteria", "ai_analysis_log", "bid_tracker", "bid_updates"):
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM information_schema.TABLES
|
||||
@@ -418,7 +445,7 @@ def initialize_database():
|
||||
if table_exists:
|
||||
logger.info(f"Table '{new_table}' confirmed present.")
|
||||
else:
|
||||
logger.warning(f"Table '{new_table}' was not created — check DDL.")
|
||||
logger.warning(f"Table '{new_table}' was not created -- check DDL.")
|
||||
|
||||
# Seed default admin if users table is empty
|
||||
cursor.execute("SELECT COUNT(*) FROM users")
|
||||
@@ -439,3 +466,5 @@ def initialize_database():
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
"""
|
||||
views/bid_tracker_view.py — Bid / Opportunity Follow-Up Tracker
|
||||
|
||||
Any logged-in user can:
|
||||
- View all tracked bids and their latest status
|
||||
- Add a new bid (URL + metadata)
|
||||
- Post updates on any bid visible to everyone
|
||||
- Edit or delete their own bids (admins can edit/delete any bid)
|
||||
|
||||
Layout:
|
||||
Left pane : filterable bid list with status badges
|
||||
Right pane : detail panel — metadata + scrollable update timeline
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
import webbrowser
|
||||
|
||||
from utils.ui_helpers import (
|
||||
COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL,
|
||||
show_error, show_info, confirm_delete,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("bid_tracker_view")
|
||||
|
||||
# Status display config: (label, colour_key)
|
||||
STATUS_META = {
|
||||
"open": ("🟢 Open", "success"),
|
||||
"monitoring": ("🔵 Monitoring", "accent"),
|
||||
"awarded": ("🏆 Awarded", "warning"),
|
||||
"no_bid": ("⛔ No Bid", "danger"),
|
||||
"cancelled": ("🚫 Cancelled", "text_dim"),
|
||||
}
|
||||
|
||||
|
||||
class BidTrackerView(ttk.Frame):
|
||||
def __init__(self, parent, current_user: dict):
|
||||
super().__init__(parent)
|
||||
self.current_user = current_user
|
||||
self._is_admin = current_user.get("role") == "admin"
|
||||
self._selected_bid_id = None
|
||||
self._build_ui()
|
||||
self._load_bids()
|
||||
|
||||
# ── Layout ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
C = COLOURS
|
||||
|
||||
# Page header
|
||||
hdr = ttk.Frame(self)
|
||||
hdr.pack(fill="x", pady=(0, 10))
|
||||
ttk.Label(hdr, text="📌 Bid Tracker",
|
||||
style="Heading.TLabel").pack(side="left")
|
||||
ttk.Label(hdr,
|
||||
text="Track potential opportunities and follow up on updates.",
|
||||
style="Dim.TLabel").pack(side="left", padx=(12, 0))
|
||||
|
||||
# Toolbar
|
||||
toolbar = tk.Frame(self, bg=C["surface"], pady=8, padx=12)
|
||||
toolbar.pack(fill="x", pady=(0, 8))
|
||||
|
||||
tk.Button(
|
||||
toolbar, text="+ Add Bid",
|
||||
command=self._open_add_dialog,
|
||||
bg=C["accent"], fg=C["white"],
|
||||
activebackground=C["accent_hover"], activeforeground=C["white"],
|
||||
relief="flat", font=FONT_BOLD, cursor="hand2",
|
||||
padx=12, pady=5,
|
||||
).pack(side="left")
|
||||
|
||||
tk.Button(
|
||||
toolbar, text="↻ Refresh",
|
||||
command=self._load_bids,
|
||||
bg=C["surface2"], fg=C["text"],
|
||||
activebackground=C["accent"], activeforeground=C["white"],
|
||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||
padx=10, pady=5,
|
||||
).pack(side="left", padx=(6, 0))
|
||||
|
||||
# Status filter
|
||||
tk.Label(toolbar, text="Filter:",
|
||||
bg=C["surface"], fg=C["text_dim"],
|
||||
font=FONT_SMALL).pack(side="left", padx=(16, 4))
|
||||
self._filter_var = tk.StringVar(value="All")
|
||||
filter_cb = ttk.Combobox(
|
||||
toolbar, textvariable=self._filter_var,
|
||||
values=["All"] + [v[0] for v in STATUS_META.values()],
|
||||
state="readonly", width=14,
|
||||
)
|
||||
filter_cb.pack(side="left")
|
||||
filter_cb.bind("<<ComboboxSelected>>", lambda _: self._load_bids())
|
||||
|
||||
# Search
|
||||
tk.Label(toolbar, text="Search:",
|
||||
bg=C["surface"], fg=C["text_dim"],
|
||||
font=FONT_SMALL).pack(side="left", padx=(16, 4))
|
||||
self._search_var = tk.StringVar()
|
||||
self._search_var.trace_add("write", lambda *_: self._load_bids())
|
||||
tk.Entry(
|
||||
toolbar, textvariable=self._search_var,
|
||||
bg=C["surface2"], fg=C["text"],
|
||||
insertbackground=C["text"],
|
||||
relief="flat", font=FONT, width=22,
|
||||
).pack(side="left", ipady=4)
|
||||
|
||||
# Two-pane split
|
||||
pane = tk.PanedWindow(self, orient="horizontal",
|
||||
bg=C["border"], sashwidth=4, sashrelief="flat")
|
||||
pane.pack(fill="both", expand=True)
|
||||
|
||||
left = tk.Frame(pane, bg=C["bg"])
|
||||
pane.add(left, minsize=300, width=360)
|
||||
self._build_bid_list(left)
|
||||
|
||||
right = tk.Frame(pane, bg=C["bg"])
|
||||
pane.add(right, minsize=380)
|
||||
self._build_detail_panel(right)
|
||||
|
||||
def _build_bid_list(self, parent):
|
||||
C = COLOURS
|
||||
|
||||
# Treeview
|
||||
cols = ("Title", "Status", "Due Date", "Updates")
|
||||
self._tree = ttk.Treeview(
|
||||
parent, columns=cols, show="headings",
|
||||
selectmode="browse",
|
||||
)
|
||||
col_widths = {"Title": 160, "Status": 90, "Due Date": 80, "Updates": 60}
|
||||
for col in cols:
|
||||
self._tree.heading(col, text=col)
|
||||
self._tree.column(col, width=col_widths[col],
|
||||
anchor="w" if col == "Title" else "center")
|
||||
|
||||
# Colour tags per status
|
||||
self._tree.tag_configure("open", foreground=C["success"])
|
||||
self._tree.tag_configure("monitoring", foreground=C["accent"])
|
||||
self._tree.tag_configure("awarded", foreground=C["warning"])
|
||||
self._tree.tag_configure("no_bid", foreground=C["danger"])
|
||||
self._tree.tag_configure("cancelled", foreground=C["text_dim"])
|
||||
|
||||
vsb = ttk.Scrollbar(parent, orient="vertical",
|
||||
command=self._tree.yview)
|
||||
self._tree.configure(yscrollcommand=vsb.set)
|
||||
vsb.pack(side="right", fill="y")
|
||||
self._tree.pack(side="left", fill="both", expand=True)
|
||||
|
||||
self._tree.bind("<<TreeviewSelect>>", self._on_bid_select)
|
||||
self._tree.bind("<Double-1>", lambda _: self._open_edit_dialog())
|
||||
|
||||
def _build_detail_panel(self, parent):
|
||||
C = COLOURS
|
||||
|
||||
# Action toolbar (right pane)
|
||||
act = tk.Frame(parent, bg=C["surface"], pady=8, padx=12)
|
||||
act.pack(fill="x")
|
||||
|
||||
self._edit_btn = tk.Button(
|
||||
act, text="✎ Edit Bid",
|
||||
command=self._open_edit_dialog,
|
||||
bg=C["surface2"], fg=C["text"],
|
||||
activebackground=C["accent"], activeforeground=C["white"],
|
||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||
padx=10, pady=4,
|
||||
)
|
||||
self._edit_btn.pack(side="left", padx=(0, 4))
|
||||
|
||||
self._del_btn = tk.Button(
|
||||
act, text="✕ Delete",
|
||||
command=self._delete_bid,
|
||||
bg=C["surface2"], fg=C["danger"],
|
||||
activebackground=C["danger"], activeforeground=C["white"],
|
||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||
padx=10, pady=4,
|
||||
)
|
||||
self._del_btn.pack(side="left")
|
||||
|
||||
self._open_btn = tk.Button(
|
||||
act, text="↗ Open URL",
|
||||
command=self._open_url,
|
||||
bg=C["surface2"], fg=C["accent"],
|
||||
activebackground=C["accent"], activeforeground=C["white"],
|
||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||
padx=10, pady=4,
|
||||
)
|
||||
self._open_btn.pack(side="right")
|
||||
|
||||
# Metadata card
|
||||
self._meta_frame = tk.Frame(parent, bg=C["surface"], padx=14, pady=10)
|
||||
self._meta_frame.pack(fill="x", pady=(0, 4))
|
||||
|
||||
self._meta_title = tk.Label(
|
||||
self._meta_frame, text="Select a bid to view details.",
|
||||
bg=C["surface"], fg=C["text"],
|
||||
font=FONT_BOLD, wraplength=480, justify="left",
|
||||
)
|
||||
self._meta_title.pack(anchor="w")
|
||||
|
||||
self._meta_sub = tk.Label(
|
||||
self._meta_frame, text="",
|
||||
bg=C["surface"], fg=C["text_dim"],
|
||||
font=FONT_SMALL, wraplength=480, justify="left",
|
||||
)
|
||||
self._meta_sub.pack(anchor="w", pady=(2, 0))
|
||||
|
||||
self._meta_notes = tk.Label(
|
||||
self._meta_frame, text="",
|
||||
bg=C["surface"], fg=C["text"],
|
||||
font=FONT_SMALL, wraplength=480, justify="left",
|
||||
)
|
||||
self._meta_notes.pack(anchor="w", pady=(4, 0))
|
||||
|
||||
# ── Updates section ───────────────────────────────────────────────
|
||||
upd_hdr = tk.Frame(parent, bg=C["bg"], pady=4, padx=12)
|
||||
upd_hdr.pack(fill="x")
|
||||
tk.Label(upd_hdr, text="📝 Updates",
|
||||
bg=C["bg"], fg=C["text"],
|
||||
font=FONT_BOLD).pack(side="left")
|
||||
|
||||
# Post update entry
|
||||
compose = tk.Frame(parent, bg=C["surface"], padx=12, pady=8)
|
||||
compose.pack(fill="x")
|
||||
|
||||
self._update_txt = tk.Text(
|
||||
compose, height=3, wrap="word",
|
||||
bg=C["surface2"], fg=C["text"],
|
||||
insertbackground=C["text"],
|
||||
relief="flat", font=FONT,
|
||||
)
|
||||
self._update_txt.pack(fill="x", pady=(0, 4))
|
||||
# Placeholder behaviour
|
||||
self._update_txt.insert("1.0", "Write an update, e.g. 'Amendment 1 issued — due date extended to...'")
|
||||
self._update_txt.config(fg=C["text_dim"])
|
||||
self._update_txt.bind("<FocusIn>", self._on_update_focus_in)
|
||||
self._update_txt.bind("<FocusOut>", self._on_update_focus_out)
|
||||
|
||||
tk.Button(
|
||||
compose, text="📤 Post Update",
|
||||
command=self._post_update,
|
||||
bg=C["accent"], fg=C["white"],
|
||||
activebackground=C["accent_hover"], activeforeground=C["white"],
|
||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||
padx=10, pady=4,
|
||||
).pack(anchor="e")
|
||||
|
||||
# Scrollable update timeline
|
||||
timeline_frame = tk.Frame(parent, bg=C["bg"])
|
||||
timeline_frame.pack(fill="both", expand=True, padx=4, pady=(4, 0))
|
||||
|
||||
vsb = ttk.Scrollbar(timeline_frame, orient="vertical")
|
||||
vsb.pack(side="right", fill="y")
|
||||
|
||||
self._update_canvas = tk.Canvas(
|
||||
timeline_frame, bg=C["bg"],
|
||||
highlightthickness=0,
|
||||
yscrollcommand=vsb.set,
|
||||
)
|
||||
self._update_canvas.pack(side="left", fill="both", expand=True)
|
||||
vsb.config(command=self._update_canvas.yview)
|
||||
|
||||
self._update_inner = tk.Frame(self._update_canvas, bg=C["bg"])
|
||||
self._canvas_window = self._update_canvas.create_window(
|
||||
(0, 0), window=self._update_inner, anchor="nw")
|
||||
|
||||
self._update_inner.bind("<Configure>", self._on_inner_configure)
|
||||
self._update_canvas.bind("<Configure>", self._on_canvas_configure)
|
||||
|
||||
# Mousewheel scrolling
|
||||
self._update_canvas.bind("<Enter>",
|
||||
lambda _: self._update_canvas.bind_all(
|
||||
"<MouseWheel>", self._on_mousewheel))
|
||||
self._update_canvas.bind("<Leave>",
|
||||
lambda _: self._update_canvas.unbind_all("<MouseWheel>"))
|
||||
|
||||
self._set_detail_buttons_state("disabled")
|
||||
|
||||
# ── Canvas / scroll helpers ───────────────────────────────────────────────
|
||||
|
||||
def _on_inner_configure(self, event=None):
|
||||
self._update_canvas.configure(
|
||||
scrollregion=self._update_canvas.bbox("all"))
|
||||
|
||||
def _on_canvas_configure(self, event):
|
||||
self._update_canvas.itemconfig(
|
||||
self._canvas_window, width=event.width)
|
||||
|
||||
def _on_mousewheel(self, event):
|
||||
self._update_canvas.yview_scroll(
|
||||
int(-1 * (event.delta / 120)), "units")
|
||||
|
||||
# ── Placeholder helpers ───────────────────────────────────────────────────
|
||||
|
||||
def _on_update_focus_in(self, event=None):
|
||||
C = COLOURS
|
||||
if self._update_txt.cget("fg") == C["text_dim"]:
|
||||
self._update_txt.delete("1.0", "end")
|
||||
self._update_txt.config(fg=C["text"])
|
||||
|
||||
def _on_update_focus_out(self, event=None):
|
||||
C = COLOURS
|
||||
if not self._update_txt.get("1.0", "end-1c").strip():
|
||||
self._update_txt.delete("1.0", "end")
|
||||
self._update_txt.insert("1.0",
|
||||
"Write an update, e.g. 'Amendment 1 issued — due date extended to...'")
|
||||
self._update_txt.config(fg=C["text_dim"])
|
||||
|
||||
def _get_update_text(self) -> str:
|
||||
C = COLOURS
|
||||
txt = self._update_txt.get("1.0", "end-1c").strip()
|
||||
if self._update_txt.cget("fg") == C["text_dim"]:
|
||||
return ""
|
||||
return txt
|
||||
|
||||
# ── Data loading ─────────────────────────────────────────────────────────
|
||||
|
||||
def _load_bids(self, *_):
|
||||
from models import get_all_bids
|
||||
|
||||
# Map display label back to DB key for filter
|
||||
filter_label = self._filter_var.get()
|
||||
status_key = ""
|
||||
for k, (label, _) in STATUS_META.items():
|
||||
if label == filter_label:
|
||||
status_key = k
|
||||
break
|
||||
|
||||
search = self._search_var.get().lower().strip()
|
||||
|
||||
self._tree.delete(*self._tree.get_children())
|
||||
try:
|
||||
bids = get_all_bids(status_filter=status_key)
|
||||
except Exception as e:
|
||||
show_error(f"Failed to load bids:\n{e}")
|
||||
return
|
||||
|
||||
for bid in bids:
|
||||
if search and search not in (bid.get("title") or "").lower() \
|
||||
and search not in (bid.get("source") or "").lower() \
|
||||
and search not in (bid.get("solicitation_number") or "").lower():
|
||||
continue
|
||||
|
||||
status = bid.get("status", "open")
|
||||
label, _ = STATUS_META.get(status, (status, "text"))
|
||||
due = str(bid.get("due_date") or "—")
|
||||
upd_count = bid.get("update_count", 0)
|
||||
|
||||
self._tree.insert(
|
||||
"", "end", iid=str(bid["id"]), tags=(status,),
|
||||
values=(bid["title"], label, due, upd_count),
|
||||
)
|
||||
|
||||
# Restore selection if still present
|
||||
if self._selected_bid_id and str(self._selected_bid_id) in \
|
||||
self._tree.get_children():
|
||||
self._tree.selection_set(str(self._selected_bid_id))
|
||||
self._tree.see(str(self._selected_bid_id))
|
||||
else:
|
||||
self._selected_bid_id = None
|
||||
self._clear_detail()
|
||||
|
||||
def _on_bid_select(self, event=None):
|
||||
sel = self._tree.selection()
|
||||
if not sel:
|
||||
return
|
||||
self._selected_bid_id = int(sel[0])
|
||||
self._load_detail(self._selected_bid_id)
|
||||
self._set_detail_buttons_state("normal")
|
||||
|
||||
def _load_detail(self, bid_id: int):
|
||||
from models import get_bid, get_bid_updates
|
||||
C = COLOURS
|
||||
|
||||
bid = get_bid(bid_id)
|
||||
if not bid:
|
||||
return
|
||||
|
||||
status = bid.get("status", "open")
|
||||
label, colour_key = STATUS_META.get(status, (status, "text"))
|
||||
due = str(bid.get("due_date") or "Not specified")
|
||||
sol = bid.get("solicitation_number") or "—"
|
||||
src = bid.get("source") or "—"
|
||||
added_by = bid.get("added_by_username") or "—"
|
||||
added_at = str(bid.get("created_at") or "")[:16]
|
||||
|
||||
self._meta_title.config(text=bid["title"])
|
||||
self._meta_sub.config(
|
||||
text=(f"{label} | Due: {due} | Sol #: {sol} | "
|
||||
f"Source: {src} | Added by: {added_by} ({added_at})")
|
||||
)
|
||||
notes = bid.get("notes") or ""
|
||||
self._meta_notes.config(
|
||||
text=f"Notes: {notes}" if notes else "",
|
||||
)
|
||||
|
||||
# Hide edit/delete for non-owners unless admin
|
||||
is_owner = bid.get("added_by") == self.current_user["id"]
|
||||
can_edit = self._is_admin or is_owner
|
||||
state = "normal" if can_edit else "disabled"
|
||||
self._edit_btn.config(state=state)
|
||||
self._del_btn.config(state=state)
|
||||
|
||||
# Render update timeline
|
||||
for widget in self._update_inner.winfo_children():
|
||||
widget.destroy()
|
||||
|
||||
updates = get_bid_updates(bid_id)
|
||||
if not updates:
|
||||
tk.Label(
|
||||
self._update_inner,
|
||||
text="No updates yet. Be the first to post one.",
|
||||
bg=C["bg"], fg=C["text_dim"],
|
||||
font=FONT_SMALL,
|
||||
).pack(anchor="w", padx=8, pady=8)
|
||||
else:
|
||||
for upd in updates:
|
||||
self._render_update_card(upd)
|
||||
|
||||
self._update_canvas.yview_moveto(0)
|
||||
|
||||
def _render_update_card(self, upd: dict):
|
||||
C = COLOURS
|
||||
|
||||
card = tk.Frame(self._update_inner, bg=C["surface"],
|
||||
padx=10, pady=8)
|
||||
card.pack(fill="x", padx=4, pady=(0, 4))
|
||||
|
||||
# Header row
|
||||
hdr = tk.Frame(card, bg=C["surface"])
|
||||
hdr.pack(fill="x")
|
||||
|
||||
poster = upd.get("posted_by_full_name") or upd.get("posted_by_username") or "Unknown"
|
||||
dt_str = str(upd.get("created_at") or "")[:16]
|
||||
tk.Label(
|
||||
hdr, text=f"👤 {poster}",
|
||||
bg=C["surface"], fg=C["text"],
|
||||
font=FONT_BOLD,
|
||||
).pack(side="left")
|
||||
tk.Label(
|
||||
hdr, text=dt_str,
|
||||
bg=C["surface"], fg=C["text_dim"],
|
||||
font=FONT_SMALL,
|
||||
).pack(side="left", padx=(8, 0))
|
||||
|
||||
# Delete button — own update or admin
|
||||
is_own = upd.get("user_id") == self.current_user["id"]
|
||||
if is_own or self._is_admin:
|
||||
tk.Button(
|
||||
hdr, text="✕",
|
||||
command=lambda uid=upd["id"]: self._delete_update(uid),
|
||||
bg=C["surface"], fg=C["text_dim"],
|
||||
activebackground=C["danger"], activeforeground=C["white"],
|
||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||
).pack(side="right")
|
||||
|
||||
# Content
|
||||
tk.Label(
|
||||
card, text=upd.get("content", ""),
|
||||
bg=C["surface"], fg=C["text"],
|
||||
font=FONT, wraplength=460, justify="left", anchor="w",
|
||||
).pack(fill="x", pady=(4, 0))
|
||||
|
||||
def _clear_detail(self):
|
||||
self._meta_title.config(text="Select a bid to view details.")
|
||||
self._meta_sub.config(text="")
|
||||
self._meta_notes.config(text="")
|
||||
for widget in self._update_inner.winfo_children():
|
||||
widget.destroy()
|
||||
self._set_detail_buttons_state("disabled")
|
||||
|
||||
def _set_detail_buttons_state(self, state: str):
|
||||
self._edit_btn.config(state=state)
|
||||
self._del_btn.config(state=state)
|
||||
self._open_btn.config(state=state)
|
||||
|
||||
# ── Actions ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _open_url(self):
|
||||
if not self._selected_bid_id:
|
||||
return
|
||||
from models import get_bid
|
||||
bid = get_bid(self._selected_bid_id)
|
||||
if bid and bid.get("url"):
|
||||
try:
|
||||
webbrowser.open(bid["url"])
|
||||
except Exception as e:
|
||||
show_error(f"Could not open URL:\n{e}")
|
||||
|
||||
def _open_add_dialog(self):
|
||||
BidDialog(self, self.current_user,
|
||||
bid_data=None, on_save=self._load_bids)
|
||||
|
||||
def _open_edit_dialog(self):
|
||||
if not self._selected_bid_id:
|
||||
show_error("Please select a bid to edit.")
|
||||
return
|
||||
from models import get_bid
|
||||
bid = get_bid(self._selected_bid_id)
|
||||
if not bid:
|
||||
show_error("Bid not found.")
|
||||
return
|
||||
# Enforce ownership
|
||||
is_owner = bid.get("added_by") == self.current_user["id"]
|
||||
if not self._is_admin and not is_owner:
|
||||
show_error("You can only edit bids you added.")
|
||||
return
|
||||
BidDialog(self, self.current_user,
|
||||
bid_data=bid, on_save=self._after_edit)
|
||||
|
||||
def _after_edit(self):
|
||||
"""Reload list and refresh detail panel after an edit."""
|
||||
self._load_bids()
|
||||
if self._selected_bid_id:
|
||||
self._load_detail(self._selected_bid_id)
|
||||
|
||||
def _delete_bid(self):
|
||||
if not self._selected_bid_id:
|
||||
return
|
||||
from models import get_bid, delete_bid
|
||||
bid = get_bid(self._selected_bid_id)
|
||||
if not bid:
|
||||
return
|
||||
is_owner = bid.get("added_by") == self.current_user["id"]
|
||||
if not self._is_admin and not is_owner:
|
||||
show_error("You can only delete bids you added.")
|
||||
return
|
||||
if confirm_delete(bid["title"]):
|
||||
try:
|
||||
delete_bid(self.current_user["id"], self._selected_bid_id)
|
||||
self._selected_bid_id = None
|
||||
self._load_bids()
|
||||
self._clear_detail()
|
||||
show_info("Bid deleted.")
|
||||
except Exception as e:
|
||||
show_error(f"Delete failed:\n{e}")
|
||||
|
||||
def _post_update(self):
|
||||
if not self._selected_bid_id:
|
||||
show_error("Please select a bid first.")
|
||||
return
|
||||
content = self._get_update_text()
|
||||
if not content:
|
||||
show_error("Please write an update before posting.")
|
||||
return
|
||||
try:
|
||||
from models import add_bid_update
|
||||
add_bid_update(self.current_user["id"],
|
||||
self._selected_bid_id, content)
|
||||
# Clear the text box and reset placeholder
|
||||
self._update_txt.delete("1.0", "end")
|
||||
self._on_update_focus_out()
|
||||
# Refresh the update timeline
|
||||
self._load_detail(self._selected_bid_id)
|
||||
# Refresh update count in the tree
|
||||
self._load_bids()
|
||||
if self._selected_bid_id and \
|
||||
str(self._selected_bid_id) in self._tree.get_children():
|
||||
self._tree.selection_set(str(self._selected_bid_id))
|
||||
except Exception as e:
|
||||
show_error(f"Failed to post update:\n{e}")
|
||||
|
||||
def _delete_update(self, update_id: int):
|
||||
from tkinter import messagebox
|
||||
if not messagebox.askyesno(
|
||||
"Delete Update",
|
||||
"Are you sure you want to delete this update?"):
|
||||
return
|
||||
try:
|
||||
from models import delete_bid_update
|
||||
delete_bid_update(self.current_user["id"], update_id)
|
||||
self._load_detail(self._selected_bid_id)
|
||||
self._load_bids()
|
||||
if self._selected_bid_id and \
|
||||
str(self._selected_bid_id) in self._tree.get_children():
|
||||
self._tree.selection_set(str(self._selected_bid_id))
|
||||
except Exception as e:
|
||||
show_error(f"Failed to delete update:\n{e}")
|
||||
|
||||
|
||||
# ── Add / Edit Bid Dialog ─────────────────────────────────────────────────────
|
||||
|
||||
class BidDialog(tk.Toplevel):
|
||||
"""Modal dialog for adding or editing a bid/opportunity."""
|
||||
|
||||
def __init__(self, parent, current_user: dict,
|
||||
bid_data, on_save):
|
||||
super().__init__(parent)
|
||||
self.current_user = current_user
|
||||
self.bid_data = bid_data
|
||||
self.on_save = on_save
|
||||
self.is_edit = bid_data is not None
|
||||
|
||||
self.title("Edit Bid" if self.is_edit else "Add Bid")
|
||||
self.configure(bg=COLOURS["bg"])
|
||||
self.resizable(False, False)
|
||||
self.grab_set()
|
||||
self._build_ui()
|
||||
self._centre()
|
||||
|
||||
def _centre(self):
|
||||
self.update_idletasks()
|
||||
w, h = 560, 520
|
||||
x = (self.winfo_screenwidth() - w) // 2
|
||||
y = (self.winfo_screenheight() - h) // 2
|
||||
self.geometry(f"{w}x{h}+{x}+{y}")
|
||||
|
||||
def _build_ui(self):
|
||||
C = COLOURS
|
||||
|
||||
ttk.Label(self,
|
||||
text="Edit Opportunity" if self.is_edit else "Add Opportunity",
|
||||
style="Heading.TLabel").pack(anchor="w", padx=24, pady=(20, 4))
|
||||
|
||||
form = ttk.Frame(self)
|
||||
form.pack(fill="x", padx=24, pady=8)
|
||||
form.columnconfigure(1, weight=1)
|
||||
|
||||
def lbl(text, row):
|
||||
ttk.Label(form, text=text).grid(
|
||||
row=row, column=0, sticky="w", padx=(0, 12), pady=6)
|
||||
|
||||
def entry(row, show=None):
|
||||
var = tk.StringVar()
|
||||
e = tk.Entry(form, textvariable=var,
|
||||
bg=C["surface2"], fg=C["text"],
|
||||
insertbackground=C["text"],
|
||||
relief="flat", font=FONT, show=show or "")
|
||||
e.grid(row=row, column=1, sticky="ew", ipady=5, pady=6)
|
||||
return var
|
||||
|
||||
# Row 0 — Title
|
||||
lbl("Title *", 0)
|
||||
self._title_var = entry(0)
|
||||
|
||||
# Row 1 — URL
|
||||
lbl("URL *", 1)
|
||||
self._url_var = entry(1)
|
||||
|
||||
# Row 2 — Source
|
||||
lbl("Source", 2)
|
||||
self._source_var = entry(2)
|
||||
|
||||
# Row 3 — Solicitation #
|
||||
lbl("Solicitation #", 3)
|
||||
self._sol_var = entry(3)
|
||||
|
||||
# Row 4 — Status
|
||||
lbl("Status", 4)
|
||||
from models import BID_STATUSES
|
||||
self._status_var = tk.StringVar(value="open")
|
||||
status_frame = tk.Frame(form, bg=C["bg"])
|
||||
status_frame.grid(row=4, column=1, sticky="w", pady=6)
|
||||
for s in BID_STATUSES:
|
||||
label, _ = STATUS_META.get(s, (s, "text"))
|
||||
tk.Radiobutton(
|
||||
status_frame, text=label,
|
||||
variable=self._status_var, value=s,
|
||||
bg=C["bg"], fg=C["text"],
|
||||
activebackground=C["bg"], activeforeground=C["accent"],
|
||||
selectcolor=C["surface2"],
|
||||
font=FONT_SMALL, cursor="hand2",
|
||||
).pack(side="left", padx=(0, 8))
|
||||
|
||||
# Row 5 — Due date
|
||||
lbl("Due Date", 5)
|
||||
self._due_var = tk.StringVar()
|
||||
tk.Entry(form, textvariable=self._due_var,
|
||||
bg=C["surface2"], fg=C["text"],
|
||||
insertbackground=C["text"],
|
||||
relief="flat", font=FONT, width=14).grid(
|
||||
row=5, column=1, sticky="w", ipady=5, pady=6)
|
||||
ttk.Label(form, text="YYYY-MM-DD",
|
||||
style="Dim.TLabel").grid(
|
||||
row=5, column=1, sticky="e", pady=6)
|
||||
|
||||
# Row 6 — Notes
|
||||
lbl("Notes", 6)
|
||||
notes_frame = tk.Frame(form, bg=C["surface2"])
|
||||
notes_frame.grid(row=6, column=1, sticky="ew", pady=6)
|
||||
notes_vsb = ttk.Scrollbar(notes_frame, orient="vertical")
|
||||
notes_vsb.pack(side="right", fill="y")
|
||||
self._notes_txt = tk.Text(
|
||||
notes_frame, height=4, wrap="word",
|
||||
bg=C["surface2"], fg=C["text"],
|
||||
insertbackground=C["text"],
|
||||
relief="flat", font=FONT,
|
||||
yscrollcommand=notes_vsb.set,
|
||||
)
|
||||
self._notes_txt.pack(fill="both", expand=True, padx=4, pady=4)
|
||||
notes_vsb.config(command=self._notes_txt.yview)
|
||||
|
||||
# Pre-populate when editing
|
||||
if self.is_edit:
|
||||
d = self.bid_data
|
||||
self._title_var.set(d.get("title") or "")
|
||||
self._url_var.set(d.get("url") or "")
|
||||
self._source_var.set(d.get("source") or "")
|
||||
self._sol_var.set(d.get("solicitation_number") or "")
|
||||
self._status_var.set(d.get("status") or "open")
|
||||
self._due_var.set(str(d.get("due_date") or ""))
|
||||
self._notes_txt.insert("1.0", d.get("notes") or "")
|
||||
|
||||
ttk.Separator(self, orient="horizontal").pack(fill="x", padx=24, pady=10)
|
||||
|
||||
btn_frame = ttk.Frame(self)
|
||||
btn_frame.pack(fill="x", padx=24, pady=(0, 20))
|
||||
ttk.Button(btn_frame, text="Save",
|
||||
command=self._save).pack(side="right", padx=(6, 0))
|
||||
ttk.Button(btn_frame, text="Cancel", style="Ghost.TButton",
|
||||
command=self.destroy).pack(side="right")
|
||||
|
||||
def _save(self):
|
||||
title = self._title_var.get().strip()
|
||||
url = self._url_var.get().strip()
|
||||
source = self._source_var.get().strip()
|
||||
sol = self._sol_var.get().strip()
|
||||
status = self._status_var.get()
|
||||
due_raw = self._due_var.get().strip()
|
||||
notes = self._notes_txt.get("1.0", "end-1c").strip()
|
||||
|
||||
if not title:
|
||||
show_error("Title is required.")
|
||||
return
|
||||
if not url:
|
||||
show_error("URL is required.")
|
||||
return
|
||||
|
||||
# Normalise URL
|
||||
if "://" not in url:
|
||||
url = "https://" + url
|
||||
|
||||
# Validate due date if provided
|
||||
due_date = None
|
||||
if due_raw:
|
||||
import datetime
|
||||
try:
|
||||
due_date = datetime.date.fromisoformat(due_raw)
|
||||
except ValueError:
|
||||
show_error("Due date must be in YYYY-MM-DD format, e.g. 2025-12-31")
|
||||
return
|
||||
|
||||
try:
|
||||
if self.is_edit:
|
||||
from models import update_bid
|
||||
update_bid(
|
||||
self.current_user["id"], self.bid_data["id"],
|
||||
title, url, source, sol, status, due_date, notes,
|
||||
)
|
||||
show_info("Bid updated successfully.")
|
||||
else:
|
||||
from models import create_bid
|
||||
create_bid(
|
||||
self.current_user["id"],
|
||||
title, url, source, sol, status, due_date, notes,
|
||||
)
|
||||
show_info("Bid added successfully.")
|
||||
self.on_save()
|
||||
self.destroy()
|
||||
except Exception as e:
|
||||
show_error(f"Save failed:\n{e}")
|
||||
Reference in New Issue
Block a user