04/30 Web Checker web app ver. 1.0

This commit is contained in:
2026-04-30 17:15:56 -04:00
parent feb8e5051c
commit cd63d5a020
39 changed files with 8031 additions and 1 deletions
+33
View File
@@ -0,0 +1,33 @@
# ============================================================
# Website Checker — Environment Variables
# Copy this file to .env and fill in your values.
# NEVER commit .env to version control.
# ============================================================
# Flask secret key — generate with: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=change_me_to_a_random_secret_key
# MySQL connection
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=webchecker
DB_USER=webchecker_user
DB_PASSWORD=change_me_strong_db_password
# Fernet encryption key for website credentials
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
CRYPTO_SECRET=change_me_to_a_fernet_key
# Groq AI (optional — used by AI Summary feature)
GROQ_API_KEY=
# Email / SMTP (optional — configured via Admin > Settings UI)
# These are the defaults; values can also be saved in the app_settings DB table.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
# Environment: development | production
FLASK_ENV=production
+254
View File
@@ -0,0 +1,254 @@
# CLAUDE.md — AI Developer Context
This file gives Claude (or any AI assistant) the context needed to continue development on this project without re-reading the entire codebase from scratch.
---
## What This Project Is
**Website Checker Web** is a Flask web application that is a direct port of a Tkinter desktop application. Both apps share the **same MySQL database** — this is the single most important constraint. Any schema change, query, or encryption logic must remain compatible with what the desktop app writes and reads.
The app helps a small team at LT Services Inc. (Falls Church, VA) track government procurement websites across scheduled shifts, manage bid/opportunity follow-up, and run AI-powered solicitation document analysis.
---
## Architecture at a Glance
```
Browser → Nginx (reverse proxy) → Gunicorn (4 workers) → Flask app
MySQL (shared with desktop)
```
- **Entry point:** `wsgi.py``app.py::create_app()`
- **No ORM** — all DB access is raw SQL via `mysql-connector-python` in `models.py`
- **No frontend framework** — vanilla JS, no build step, no npm
- **Blueprints:** one file per feature area in `routes/`
- **Templates:** Jinja2, all extend `base.html`
- **Static assets:** single `style.css` + `app.js` — no preprocessor
---
## Critical Constraints
### 1. Shared Database
Never rename columns, drop tables, or change column types without verifying the desktop app still works. Key schema facts:
- `activity_log` timestamp column is **`logged_at`** (not `created_at`)
- `app_log` timestamp column is **`logged_at`**
- All other tables generally use `created_at`
- The desktop app writes `bid_tracker`, `bid_updates`, `ai_analysis_log`, `ai_criteria`, `app_settings`, `users`, `websites`, `website_credentials`, `shifts`, `shift_users`, `shift_websites`, `shift_checks`, `login_attempts`
### 2. Credential Encryption
`utils/crypto.py` must stay byte-for-byte compatible with the desktop's `utils/crypto.py`:
- `_APP_SECRET = b"WebsiteChecker-v1-CredentialKey"` — never change
- `_ITERATIONS = 100_000` — never change
- Salt stored as **base64** in `app_settings` under key `"crypto.salt"`
- Ciphertext has **`enc:`** prefix; values without this prefix are legacy plaintext and returned as-is
- Calling `reset_fernet()` forces key reload if the salt changes
### 3. No Inline JS with Dynamic Jinja Values
All button `onclick` handlers that need dynamic data (site ID, site name, note text) **must use `data-*` attributes** on the HTML element and read them in a delegated event listener. Direct `onclick="fn({{ value }})"` breaks when the value contains quotes, apostrophes, or backslashes.
Example of the correct pattern:
```html
<button class="js-check" data-id="{{ site.id }}" data-name="{{ site.name }}">Check</button>
```
```js
document.getElementById('list').addEventListener('click', function(e) {
const btn = e.target.closest('.js-check');
if (btn) openCheckModal(btn.dataset.id, btn.dataset.name);
});
```
### 4. Jinja Macros and `{% extends %}`
Jinja macros defined in the same file as `{% extends "base.html" %}` cannot be called with `{{ macro_name(...) }}` before the macro definition is reached. **Do not use macros in child templates.** Inline the HTML directly or use JS to build dynamic content.
### 5. CSS Specificity — Local `<style>` vs `style.css`
The global `static/css/style.css` is loaded in `base.html` and has equal or higher specificity than local `<style>` blocks in child templates. Always fix layout issues in `style.css` — do not rely on local `<style>` overrides.
---
## Key Files Reference
| File | Purpose | Notes |
|------|---------|-------|
| `app.py` | Flask factory | Registers all 11 blueprints; defines `hhmm` template filter for MySQL TIME columns |
| `config.py` | DB config, DDL, settings | Calls `load_dotenv()` at top of file — **must be before `DB_CONFIG` dict** |
| `models.py` | All DB queries | ~1,430 lines; no ORM; every function opens/closes its own connection |
| `utils/crypto.py` | Fernet encryption | Must match desktop exactly |
| `utils/decorators.py` | `@login_required`, `@admin_required` | Simple session checks |
| `static/css/style.css` | Full design system | Light theme, DM Sans + DM Mono, CSS variables in `:root` |
| `static/js/app.js` | Global JS utilities | `openModal()`, `closeModal()`, `copyToClipboard()`, session timeout warning |
---
## Session & Auth
- Session key: `session["user"]` — dict with `id`, `username`, `full_name`, `role`, `email`
- Role values: `"admin"` or `"user"`
- Session lifetime: 30 minutes (`app.permanent_session_lifetime`)
- Login rate limit: 5 attempts → 15-minute lockout (enforced in `models.check_login_allowed`)
- `@login_required` — redirects to `/login` if no session
- `@admin_required` — redirects to login or 403 if not admin
---
## Settings System
`app_settings` table stores key-value pairs for runtime configuration.
| `key_name` | `.env` fallback | Used by |
|---|---|---|
| `groq.api_key` | `GROQ_API_KEY` | AI Summary route |
| `groq.model` | `GROQ_MODEL` | AI Summary route |
| `email.smtp_host` | `SMTP_HOST` | Email reports |
| `email.smtp_port` | `SMTP_PORT` | Email reports |
| `email.smtp_user` | `SMTP_USER` | Email reports |
| `email.smtp_password` | `SMTP_PASSWORD` | Email reports |
| `email.smtp_from` | `SMTP_FROM` | Email reports |
| `crypto.salt` | *(generated)* | Fernet key derivation |
`get_setting(key, default)` reads DB first, then `.env` fallback, then `default`.
`set_setting(key, value)` upserts into `app_settings`.
On startup, `initialize_database()` seeds blank DB rows from `.env` using `ON DUPLICATE KEY UPDATE value = IF(value='', VALUES(value), value)`.
---
## AI Summary Feature
Route: `/ai-summary/` (`routes/ai_summary.py`)
- **Stage 1 prompt** (`_EXTRACTION_PROMPT`) — extracts 12 fields per document including driving distance from `2815 Hartland Road, Falls Church, VA 22043` to the work site
- **Stage 2 prompt** (`_CRITERIA_PROMPT_SUFFIX`) — appended only when active criteria exist; produces a machine-readable `RECOMMENDATION: PURSUE|PASS|UNCLEAR` line
- **File extraction** — `pypdf` for PDF, `python-docx` (imported as `docx`) for DOCX/DOC, `openpyxl` for XLSX, plain decode for TXT/CSV/MD
- **API call** — direct `requests.post()` to `https://api.groq.com/openai/v1/chat/completions` — the `groq` Python SDK is **not** installed
- **Model** — `claude-sonnet-4-20250514` should NOT be used here; use `llama-3.3-70b-versatile` (Groq)
- **Max tokens** — 4,096; **temperature** — 0.2
---
## Bid Tracker Feature
Route: `/bids/` (`routes/bid_tracker.py`)
Split-pane UI — left: filterable bid list, right: detail panel with update timeline.
Key AJAX endpoints (all return JSON, no page reload):
- `GET /bids/list/json?status=` — paginated bid list
- `GET /bids/<id>/json` — bid detail + updates + `can_edit` flag
- `POST /bids/<id>/updates/json` — post new update
- `POST /bids/updates/<id>/delete/json` — delete update
Ownership rule: users can only edit/delete their own bids. Admins can edit/delete any bid. This is enforced in both the route (`can_edit = is_admin or bid.added_by == user.id`) and the rendered detail panel.
---
## User Dashboard
Route: `/dashboard/` (`routes/user_dashboard.py`)
- Sites are grouped by `check_type` (`daily` / `weekly` / other) and rendered as collapsible groups
- Each site card has two rows: `.sc-row1` (main flex row) and `.sc-row2` (note row, only if `user_note` is set)
- `.sc-actions` uses `margin-left:auto` to push buttons to the right edge of `.sc-row1`
- `.site-card` is `display:block`**not flex** — so `.sc-row1` and `.sc-row2` stack vertically
- Health dots probe site reachability using Google's favicon service (cross-origin safe)
- The `hhmm` Jinja filter (`app.py`) handles MySQL `TIME` columns returned as `datetime.timedelta`
---
## Common Patterns
### Adding a new admin page
1. Create `routes/admin_mypage.py` with a Blueprint at `/admin/mypage`
2. Register it in `app.py::create_app()`
3. Add a nav link in `templates/base.html` inside the admin nav section
4. Create `templates/admin/mypage.html` extending `base.html`
5. Add model functions to `models.py`
6. Add any new CSS classes to `static/css/style.css`
### Adding a new modal
```html
<div class="modal-overlay" id="modal-mymodal">
<div class="modal"> <!-- or modal-dialog for admin pages -->
<div class="modal-header">
<span class="modal-title">Title</span>
<button class="modal-close" onclick="closeModal('modal-mymodal')"></button>
</div>
<div class="modal-body"></div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-mymodal')">Cancel</button>
<button class="btn btn-primary">Save</button>
</div>
</div>
</div>
```
Open with `openModal('modal-mymodal')` from `app.js`.
### Serialising DB rows to JSON
MySQL connector returns `datetime`, `date`, `timedelta` objects which are not JSON-serialisable. Always convert:
```python
def ser(row):
return {k: v.isoformat() if hasattr(v, 'isoformat') else v for k, v in row.items()}
```
---
## Known Gotchas
| Gotcha | Detail |
|--------|--------|
| `activity_log.logged_at` | Column is `logged_at` in the production DB, not `created_at`. Queried as `al.logged_at AS created_at` |
| `TIME` columns → `timedelta` | MySQL returns `TIME` as `datetime.timedelta`. Use the `\|hhmm` filter in templates |
| `GROQ_API_KEY` in `.env` | Read directly via `os.environ.get("GROQ_API_KEY")` in the analyze route — not via `get_setting()` alone — because `get_setting()` depends on the DB row being populated |
| `div.modal` vs `.modal-overlay` | All modal backdrops must use `class="modal-overlay"`. Inner dialog boxes use `class="modal"` or `class="modal-dialog"`. Never use `div.modal` as a backdrop |
| Jinja macros in child templates | Cannot call `{{ macro_name() }}` before the `{% macro %}` definition is parsed. Inline the HTML instead |
| `INSERT IGNORE` for settings seed | Use `ON DUPLICATE KEY UPDATE value = IF(value='', VALUES(value), value)``INSERT IGNORE` silently skips rows that already exist, even with empty values |
| `bcrypt` not in original requirements | Added as `bcrypt==4.1.3`. Required by `models.py` for password hashing |
---
## Development Workflow
```bash
# Activate venv
source /home/webchecker/venv/bin/activate
# After changing Python files — reload Gunicorn (zero-downtime)
sudo systemctl reload webchecker
# After changing templates or static files — no restart needed (served live)
# View live logs
sudo journalctl -u webchecker -f
# View Nginx errors
sudo tail -f /var/log/nginx/webchecker_error.log
# Run a quick DB query
mysql -u webchecker_user -p webchecker -e "SELECT key_name, value FROM app_settings;"
```
---
## File Placement on Server
```
/opt/webchecker/ ← project root (or /home/webchecker/app/)
/opt/webchecker/.env ← secrets (chmod 600, owned by webchecker)
/opt/webchecker/venv/ ← Python virtual environment
/var/log/webchecker/ ← Gunicorn access.log + error.log
/run/webchecker/ ← Gunicorn UNIX socket (webchecker.sock)
/etc/systemd/system/webchecker.service
/etc/nginx/sites-available/webchecker
```
## Change Philosophy
1. **Surgical, additive patches** — smallest possible change to achieve the goal
2. **Preserve all routes, function names, variable names** unless explicitly directed otherwise
3. **Never remove existing functionality** unless explicitly directed
4. **Log all create/update/delete actions** via `log_action()`
5. **Migration existence checks** — all migrations safe to re-run
6. **Full file contents for 13 file changes**; deployment map for larger changesets
7. **Explicit deploy instructions** — migration steps separated from code steps
8. **Root cause analysis** on errors — never apply temporary workarounds
+478
View File
@@ -0,0 +1,478 @@
# Website Checker — Production Deployment Guide
### Ubuntu Server · Nginx · Gunicorn · MySQL · systemd
---
## Table of Contents
1. [Prerequisites](#1-prerequisites)
2. [Server Initial Setup](#2-server-initial-setup)
3. [MySQL Database](#3-mysql-database)
4. [Application Deployment](#4-application-deployment)
5. [Gunicorn systemd Service](#5-gunicorn-systemd-service)
6. [Nginx Configuration](#6-nginx-configuration)
7. [SSL/TLS with Certbot](#7-ssltls-with-certbot)
8. [Firewall Rules](#8-firewall-rules)
9. [Post-Deployment Verification](#9-post-deployment-verification)
10. [Ongoing Maintenance](#10-ongoing-maintenance)
11. [Troubleshooting](#11-troubleshooting)
---
## 1. Prerequisites
| Item | Requirement |
|------|-------------|
| OS | Ubuntu 22.04 LTS or 24.04 LTS |
| RAM | ≥ 1 GB (2 GB recommended) |
| Disk | ≥ 10 GB |
| Access | Root or sudo user |
| Domain | A DNS A-record pointing to your server's IP |
| Python | 3.10+ (pre-installed on Ubuntu 22.04+) |
---
## 2. Server Initial Setup
### 2.1 Update the system
```bash
sudo apt update && sudo apt upgrade -y
```
### 2.2 Install system dependencies
```bash
sudo apt install -y \
python3 python3-pip python3-venv \
mysql-server \
nginx \
certbot python3-certbot-nginx \
git curl ufw
```
### 2.3 Create a dedicated application user
```bash
sudo useradd --system --shell /bin/bash --create-home webchecker
```
---
## 3. MySQL Database
### 3.1 Secure the MySQL installation
```bash
sudo mysql_secure_installation
```
Follow the prompts: set a root password, remove anonymous users, disallow remote root login, remove test database.
### 3.2 Create the database and application user
```bash
sudo mysql -u root -p
```
Inside the MySQL shell:
```sql
CREATE DATABASE webchecker CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'webchecker_user'@'127.0.0.1' IDENTIFIED BY 'YourStrongPasswordHere';
GRANT ALL PRIVILEGES ON webchecker.* TO 'webchecker_user'@'127.0.0.1';
FLUSH PRIVILEGES;
EXIT;
```
> **Security note:** Use `127.0.0.1` (not `localhost`) so the connector uses TCP rather than the UNIX socket, which matches the `DB_HOST=127.0.0.1` setting in `.env`.
---
## 4. Application Deployment
### 4.1 Transfer the application files
Option A — copy from your workstation:
```bash
scp -r ./webchecker_web/ youruser@your-server-ip:/tmp/webchecker_web
```
Option B — clone from a private Git repository:
```bash
sudo -u webchecker git clone https://github.com/your-org/webchecker.git /opt/webchecker
```
Then move files into place (Option A):
```bash
sudo mv /tmp/webchecker_web /opt/webchecker
sudo chown -R webchecker:webchecker /opt/webchecker
```
### 4.2 Create the Python virtual environment
```bash
sudo -u webchecker python3 -m venv /opt/webchecker/venv
```
### 4.3 Install Python dependencies
```bash
sudo -u webchecker /opt/webchecker/venv/bin/pip install --upgrade pip
sudo -u webchecker /opt/webchecker/venv/bin/pip install -r /opt/webchecker/requirements.txt
```
### 4.4 Configure environment variables
```bash
sudo cp /opt/webchecker/.env.example /opt/webchecker/.env
sudo nano /opt/webchecker/.env
```
Fill in every value — especially:
```dotenv
SECRET_KEY=<output of: python3 -c "import secrets; print(secrets.token_hex(32))">
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=webchecker
DB_USER=webchecker_user
DB_PASSWORD=YourStrongPasswordHere
CRYPTO_SECRET=<output of: python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())">
FLASK_ENV=production
```
Secure the file so only the application user can read it:
```bash
sudo chown webchecker:webchecker /opt/webchecker/.env
sudo chmod 600 /opt/webchecker/.env
```
### 4.5 Initialise the database schema
The application auto-creates all tables on first startup via `initialize_database()`. Run it once manually to verify:
```bash
sudo -u webchecker bash -c '
cd /opt/webchecker
source venv/bin/activate
python - <<EOF
from app import create_app
app = create_app()
with app.app_context():
from config import initialize_database
initialize_database()
print("Database initialised successfully.")
EOF
'
```
### 4.6 Create the Gunicorn socket directory
```bash
sudo mkdir -p /run/webchecker
sudo chown webchecker:webchecker /run/webchecker
```
---
## 5. Gunicorn systemd Service
### 5.1 Create the service unit file
```bash
sudo nano /etc/systemd/system/webchecker.service
```
Paste the following:
```ini
[Unit]
Description=Website Checker — Gunicorn Application Server
After=network.target mysql.service
Requires=mysql.service
[Service]
Type=notify
User=webchecker
Group=webchecker
WorkingDirectory=/opt/webchecker
EnvironmentFile=/opt/webchecker/.env
ExecStart=/opt/webchecker/venv/bin/gunicorn \
--bind unix:/run/webchecker/webchecker.sock \
--workers 4 \
--worker-class sync \
--timeout 120 \
--access-logfile /var/log/webchecker/access.log \
--error-logfile /var/log/webchecker/error.log \
--log-level info \
wsgi:application
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=mixed
TimeoutStopSec=10
PrivateTmp=true
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
### 5.2 Create the log directory
```bash
sudo mkdir -p /var/log/webchecker
sudo chown webchecker:webchecker /var/log/webchecker
```
### 5.3 Enable and start the service
```bash
sudo systemctl daemon-reload
sudo systemctl enable webchecker
sudo systemctl start webchecker
sudo systemctl status webchecker
```
You should see `Active: active (running)`. The socket file `/run/webchecker/webchecker.sock` will be created automatically.
---
## 6. Nginx Configuration
### 6.1 Create the Nginx site configuration
```bash
sudo nano /etc/nginx/sites-available/webchecker
```
Paste (replace `your-domain.com` with your actual domain):
```nginx
upstream webchecker_app {
server unix:/run/webchecker/webchecker.sock fail_timeout=0;
}
server {
listen 80;
server_name your-domain.com www.your-domain.com;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Client upload size (raise if users upload large files for AI analysis)
client_max_body_size 20M;
# Static files served directly by Nginx (fast path)
location /static/ {
alias /opt/webchecker/static/;
expires 30d;
access_log off;
add_header Cache-Control "public, immutable";
}
# Proxy everything else to Gunicorn
location / {
proxy_pass http://webchecker_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering on;
proxy_buffer_size 16k;
proxy_buffers 4 32k;
}
# Logging
access_log /var/log/nginx/webchecker_access.log;
error_log /var/log/nginx/webchecker_error.log;
}
```
### 6.2 Enable the site
```bash
sudo ln -s /etc/nginx/sites-available/webchecker /etc/nginx/sites-enabled/
sudo nginx -t # test config — must report "syntax is ok"
sudo systemctl reload nginx
```
---
## 7. SSL/TLS with Certbot
```bash
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
```
Certbot will automatically:
- Obtain a Let's Encrypt certificate
- Modify the Nginx config to add HTTPS (port 443)
- Insert a redirect from HTTP → HTTPS
- Schedule automatic renewal
**Verify auto-renewal works:**
```bash
sudo certbot renew --dry-run
```
After Certbot finishes, add an HSTS header inside the `server` block that Certbot created for port 443:
```nginx
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
```
Then reload Nginx:
```bash
sudo nginx -t && sudo systemctl reload nginx
```
---
## 8. Firewall Rules
```bash
# Allow SSH (adjust port if you changed it)
sudo ufw allow 22/tcp
# Allow HTTP and HTTPS
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Enable the firewall
sudo ufw enable
# Verify
sudo ufw status verbose
```
> MySQL port 3306 is intentionally **not** opened — the application connects via `127.0.0.1` (localhost), so no external exposure is required.
---
## 9. Post-Deployment Verification
### 9.1 Check the Gunicorn service
```bash
sudo systemctl status webchecker
sudo journalctl -u webchecker -n 50 --no-pager
```
### 9.2 Check Nginx
```bash
sudo systemctl status nginx
sudo tail -f /var/log/nginx/webchecker_error.log
```
### 9.3 Smoke-test the application
```bash
curl -I https://your-domain.com/auth/login
# Expect: HTTP/2 200
```
Open a browser and navigate to `https://your-domain.com`. You should see the login page.
### 9.4 First login
Log in with the default admin credentials set during `initialize_database()`. **Change the password immediately** via Admin → Change Password.
---
## 10. Ongoing Maintenance
### Deploy a new version
```bash
# 1. Copy/pull new files to /opt/webchecker
# 2. Install any new dependencies
sudo -u webchecker /opt/webchecker/venv/bin/pip install -r /opt/webchecker/requirements.txt
# 3. Reload Gunicorn (zero-downtime — workers are replaced one by one)
sudo systemctl reload webchecker
# 4. If Nginx config changed:
sudo nginx -t && sudo systemctl reload nginx
```
### View application logs
```bash
# Gunicorn access log
sudo tail -f /var/log/webchecker/access.log
# Gunicorn error log
sudo tail -f /var/log/webchecker/error.log
# systemd journal (includes startup errors)
sudo journalctl -u webchecker -f
```
### Backup the database
```bash
mysqldump -u webchecker_user -p webchecker | gzip > ~/webchecker_$(date +%Y%m%d).sql.gz
```
Automate with a daily cron:
```bash
sudo crontab -e
# Add:
0 2 * * * mysqldump -u webchecker_user -pYourPassword webchecker | gzip > /var/backups/webchecker_$(date +\%Y\%m\%d).sql.gz
```
### Rotate logs
Create `/etc/logrotate.d/webchecker`:
```
/var/log/webchecker/*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
sharedscripts
postrotate
systemctl reload webchecker > /dev/null 2>&1 || true
endscript
}
```
---
## 11. Troubleshooting
| Symptom | Likely Cause | Fix |
|---------|--------------|-----|
| `502 Bad Gateway` | Gunicorn not running or socket missing | `sudo systemctl restart webchecker`; check `journalctl -u webchecker` |
| `connect() to unix:/run/webchecker/webchecker.sock failed (13: Permission denied)` | Nginx user can't read socket | Add `nginx` to `webchecker` group: `sudo usermod -aG webchecker www-data`, then `sudo systemctl restart nginx` |
| `OperationalError: Access denied for user` | Wrong DB credentials in `.env` | Verify `DB_USER`/`DB_PASSWORD` match what was set in MySQL |
| `ImportError` or `ModuleNotFoundError` | Dependency not installed in venv | Run `pip install -r requirements.txt` with the venv active |
| `cryptography.fernet.InvalidToken` | `CRYPTO_SECRET` changed after credentials were stored | Restore the original key from backup; never rotate without re-encrypting stored data |
| Sessions expiring immediately | `SECRET_KEY` changed between restarts | Keep `SECRET_KEY` stable in `.env`; never regenerate it on a live system |
| `413 Request Entity Too Large` | File upload exceeds `client_max_body_size` | Increase `client_max_body_size` in the Nginx config |
| Static files returning 404 | Wrong `alias` path in Nginx | Confirm `/opt/webchecker/static/` exists and the `alias` directive ends with `/` |
---
*Generated for Website Checker Web — Flask/MySQL/Nginx/Gunicorn/Ubuntu deployment.*
+201 -1
View File
@@ -1,2 +1,202 @@
# WebChecker--Web-app- # Website Checker — Web Application
A full-stack web application for monitoring government procurement websites across team shifts. Converted from a Tkinter desktop application, sharing the same MySQL database.
---
## Overview
Website Checker helps teams track which government websites need to be checked during each shift, manage bid/procurement opportunities, and run AI-powered document analysis on solicitation files. Administrators manage users, websites, shifts, and view reports; regular users work through their daily or weekly checklists.
---
## Technology Stack
| Layer | Technology |
|---|---|
| Language | Python 3.10+ |
| Web Framework | Flask 3.0 |
| Database | MySQL 8.x (shared with desktop app) |
| Application Server | Gunicorn |
| Reverse Proxy | Nginx |
| Process Manager | systemd |
| Credential Encryption | Fernet (PBKDF2-HMAC-SHA256, 100k iterations) |
| Password Hashing | bcrypt (12 rounds) |
| AI Analysis | Groq REST API (llama-3.3-70b-versatile) |
| Frontend | Vanilla JS + DM Sans/DM Mono (Google Fonts) |
| OS | Ubuntu 22.04 / 24.04 LTS |
---
## Features
### All Users
- **Shift Checklist** — Sites grouped by Daily / Weekly frequency, collapsible groups, live progress bar, bulk-check, search filter
- **Site Credentials** — View stored credentials (password masked, copy-to-clipboard)
- **Per-site Notes** — Add or update notes per check, displayed in a dedicated row under the site card
- **AI Document Analysis** — Upload PDF / DOCX / XLSX / TXT solicitation files; AI extracts solicitation number, scope of work, due dates, driving distance from office, and evaluates against criteria
- **Bid Tracker** — Split-pane view of all tracked opportunities; post updates, filter by status, search by title / source / solicitation number
### Admins Only
- **Dashboard** — KPI cards (users, active today, total sites) and per-user completion progress
- **User Management** — Create, edit, activate/deactivate users; role assignment (admin / user)
- **Website Management** — CRUD for monitored sites with credentials, check type (daily/weekly), and user assignment
- **Shift Management** — Define shifts with days-of-week, time windows, assigned users and sites
- **Reports** — Shift detail, unchecked sites, and summary reports with date/user/site filters; CSV export
- **Logs** — Activity log (user actions) and application log (system events) with search and purge
- **Settings** — SMTP email configuration and Groq AI API key / model selection
- **AI Criteria Management** — Create, edit, reorder, and deactivate evaluation criteria used by AI analysis
---
## Project Structure
```
webchecker_web/
├── app.py # Flask application factory, blueprint registration
├── config.py # DB connection pool, schema DDL, get/set settings
├── models.py # All data-access functions (no ORM)
├── wsgi.py # Gunicorn entry point
├── requirements.txt
├── .env.example # Environment variable template
├── DEPLOY.md # Full production deployment guide
├── routes/
│ ├── auth.py # Login, logout, change password
│ ├── admin_dashboard.py # /admin/
│ ├── admin_users.py # /admin/users/
│ ├── admin_websites.py # /admin/websites/
│ ├── admin_shifts.py # /admin/shifts/
│ ├── admin_logs.py # /admin/logs/
│ ├── admin_reports.py # /admin/reports/
│ ├── admin_settings.py # /admin/settings/
│ ├── user_dashboard.py # /dashboard/
│ ├── ai_summary.py # /ai-summary/
│ └── bid_tracker.py # /bids/
├── templates/
│ ├── base.html # Sidebar layout, flash messages, nav
│ ├── login.html
│ ├── change_password.html
│ ├── ai_summary.html
│ ├── bid_tracker.html
│ ├── admin/
│ │ ├── dashboard.html
│ │ ├── users.html
│ │ ├── websites.html
│ │ ├── shifts.html
│ │ ├── logs.html
│ │ ├── reports.html
│ │ └── settings.html
│ └── user/
│ └── dashboard.html
├── static/
│ ├── css/style.css # Full light-theme design system
│ └── js/app.js # Modal helpers, tabs, session timeout warning
└── utils/
├── crypto.py # Fernet encryption (DB-compatible with desktop app)
└── decorators.py # @login_required, @admin_required
```
---
## URL Routes
| Prefix | Description | Access |
|---|---|---|
| `/` | Root redirect (role-based) | Any |
| `/login` | Login page | Public |
| `/logout` | Session clear + redirect | Authenticated |
| `/change-password` | Change own password | Authenticated |
| `/admin/` | Admin dashboard | Admin |
| `/admin/users/` | User CRUD | Admin |
| `/admin/websites/` | Website CRUD + credentials | Admin |
| `/admin/shifts/` | Shift CRUD + assignments | Admin |
| `/admin/logs/` | Activity & app logs | Admin |
| `/admin/reports/` | Shift reports + CSV export | Admin |
| `/admin/settings/` | Email + Groq AI settings | Admin |
| `/dashboard/` | User shift checklist | User |
| `/ai-summary/` | AI document analysis | Any |
| `/bids/` | Bid / opportunity tracker | Any |
---
## Environment Variables
Copy `.env.example` to `.env` and fill in all values:
```dotenv
SECRET_KEY= # Flask session secret — generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=webchecker
DB_USER=webchecker_user
DB_PASSWORD=
CRYPTO_SECRET= # Fernet key — generate with: python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
GROQ_API_KEY= # Optional — used by AI Summary feature
FLASK_ENV=production
```
> **Important:** `CRYPTO_SECRET` must match the key used by the desktop app if you are sharing the database. The salt for encryption is stored in the `app_settings` table under `crypto.salt`.
---
## Database Compatibility
This web application shares the MySQL database with the desktop application. Key compatibility notes:
- **Encryption** — `utils/crypto.py` uses the exact same key derivation as the desktop (`APP_SECRET = b"WebsiteChecker-v1-CredentialKey"`, 100,000 PBKDF2 iterations, base64 salt, `enc:` prefix). Credentials are interchangeable between apps.
- **`activity_log`** — The live database column is `logged_at` (desktop schema). Queries use `al.logged_at AS created_at` for template compatibility.
- **`app_settings`** — On startup, `initialize_database()` seeds any settings keys that are blank in the DB from the corresponding `.env` variables (e.g. `GROQ_API_KEY → groq.api_key`), using `ON DUPLICATE KEY UPDATE … IF(value = '', …)` so admin-saved values are never overwritten.
- **`login_attempts.ip_address`** — The only additive column the web app creates. Added via `ALTER TABLE … ADD COLUMN IF NOT EXISTS` on startup.
---
## Development
### Run locally
```bash
# 1. Clone and enter the project
cd webchecker_web
# 2. Create and activate a virtual environment
python3 -m venv venv
source venv/bin/activate
# 3. Install dependencies
pip install -r requirements.txt
# 4. Configure environment
cp .env.example .env
# Edit .env with your local MySQL credentials
# 5. Run the development server
python app.py
# Open http://localhost:5000
```
### Restart the production service
```bash
sudo systemctl restart webchecker
sudo journalctl -u webchecker -f
```
---
## Security Notes
- Passwords are hashed with bcrypt (12 rounds); legacy SHA-256 hashes from the desktop app are automatically rehashed on next login.
- Sessions expire after **30 minutes** of inactivity. A client-side warning fires 5 minutes before expiry.
- Login is rate-limited: 5 failed attempts locks the account for 15 minutes.
- Credentials stored in `website_credentials` are Fernet-encrypted at rest. Plaintext legacy values (without the `enc:` prefix) are returned as-is for backward compatibility.
- `SECRET_KEY` must remain stable across restarts — changing it invalidates all active sessions.
---
## Deployment
See [`DEPLOY.md`](DEPLOY.md) for the full step-by-step guide covering MySQL setup, Gunicorn systemd service, Nginx reverse proxy, Certbot SSL, firewall rules, log rotation, and database backup.
+107
View File
@@ -0,0 +1,107 @@
"""
app.py — Flask application factory.
Web conversion of the desktop Website Checker application.
Stack: Python 3.11+, Flask, MySQL (mysql-connector-python), Gunicorn, Nginx.
"""
import logging
import os
from flask import Flask
from config import initialize_database, db_log_handler, _stream_handler
# ─── Logging ──────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
handlers=[_stream_handler, db_log_handler],
)
logger = logging.getLogger("app")
def create_app():
app = Flask(__name__)
# ── Secret key for session management ─────────────────────────────────────
app.secret_key = os.environ.get("SECRET_KEY", os.urandom(32))
# ── Session timeout (30 minutes) ──────────────────────────────────────────
from datetime import timedelta
app.permanent_session_lifetime = timedelta(minutes=30)
# ── Database initialisation ────────────────────────────────────────────────
try:
initialize_database()
db_log_handler.install()
logger.info("Database initialised successfully.")
except Exception as e:
logger.error(f"Database initialisation failed: {e}")
# ── Register Blueprints ────────────────────────────────────────────────────
from routes.auth import auth_bp
from routes.admin_dashboard import admin_dashboard_bp
from routes.admin_users import admin_users_bp
from routes.admin_websites import admin_websites_bp
from routes.admin_shifts import admin_shifts_bp
from routes.admin_logs import admin_logs_bp
from routes.admin_reports import admin_reports_bp
from routes.admin_settings import admin_settings_bp
from routes.user_dashboard import user_dashboard_bp
from routes.ai_summary import ai_summary_bp
from routes.bid_tracker import bid_tracker_bp
app.register_blueprint(auth_bp)
app.register_blueprint(admin_dashboard_bp)
app.register_blueprint(admin_users_bp)
app.register_blueprint(admin_websites_bp)
app.register_blueprint(admin_shifts_bp)
app.register_blueprint(admin_logs_bp)
app.register_blueprint(admin_reports_bp)
app.register_blueprint(admin_settings_bp)
app.register_blueprint(user_dashboard_bp)
app.register_blueprint(ai_summary_bp)
app.register_blueprint(bid_tracker_bp)
# ── Template context processors ────────────────────────────────────────────
from flask import session, redirect, url_for, g
import functools
# ── Custom template filter: timedelta/time → "HH:MM" ──────────────────────
import datetime as _dt
@app.template_filter('hhmm')
def hhmm_filter(value):
"""Render a MySQL TIME value (timedelta, time, or str) as HH:MM."""
if value is None:
return ''
if isinstance(value, _dt.timedelta):
total = int(value.total_seconds())
h, rem = divmod(abs(total), 3600)
m = rem // 60
return f"{h:02d}:{m:02d}"
if isinstance(value, _dt.time):
return value.strftime('%H:%M')
return str(value)[:5] # already a string like "08:00:00"
@app.context_processor
def inject_user():
return {"current_user": session.get("user")}
# ── Root redirect ──────────────────────────────────────────────────────────
@app.route("/")
def index():
if "user" in session:
user = session["user"]
if user["role"] == "admin":
return redirect(url_for("admin_dashboard.dashboard"))
return redirect(url_for("user_dashboard.my_shifts"))
return redirect(url_for("auth.login"))
return app
# ─── Dev entry point ──────────────────────────────────────────────────────────
if __name__ == "__main__":
application = create_app()
application.run(debug=False, host="0.0.0.0", port=5000)
# Gunicorn entry point
application = create_app()
+449
View File
@@ -0,0 +1,449 @@
"""
config.py — Application configuration and DB connection manager.
Web version: credentials loaded from environment variables / .env file.
No OS keyring dependency — suitable for server deployment.
"""
import os
import mysql.connector
from mysql.connector import pooling
import logging
import queue
import threading
import sys
# ─── Load .env before anything reads os.environ ───────────────────────────────
# Must happen at the very top of this module — DB_CONFIG is built at import
# time, so dotenv must populate os.environ before those lines execute.
try:
from dotenv import load_dotenv
load_dotenv() # looks for .env in cwd, then parent directories
except ImportError:
pass # python-dotenv not installed — rely on real env vars
# ─── Logging Setup ────────────────────────────────────────────────────────────
_formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s - %(message)s")
_stream_handler = logging.StreamHandler(stream=sys.stdout)
_stream_handler.setFormatter(_formatter)
APP_TITLE = "Website Checker"
APP_VERSION = "1.0.0"
# ─── Database Log Handler (async, queued) ─────────────────────────────────────
class _DBLogHandler(logging.Handler):
"""Async handler that writes log records to the app_log DB table."""
def __init__(self):
super().__init__()
self._queue = queue.Queue()
self._ready = False
self._thread = threading.Thread(target=self._worker, name="DBLogWorker", daemon=True)
self._thread.start()
def emit(self, record: logging.LogRecord):
if record.name in ("config", "mysql.connector"):
return
try:
self._queue.put_nowait({
"level": record.levelname,
"logger_name": record.name[:100],
"message": self.format(record),
})
except Exception:
pass
def install(self):
"""Signal worker that the DB pool is ready."""
self._ready = True
def _worker(self):
while True:
try:
record = self._queue.get(timeout=2)
except queue.Empty:
continue
if not self._ready:
self._queue.put(record)
threading.Event().wait(1)
continue
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO app_log (level, logger_name, message) VALUES (%s,%s,%s)",
(record["level"], record["logger_name"], record["message"]),
)
conn.commit()
cur.close()
conn.close()
except Exception:
pass
db_log_handler = _DBLogHandler()
db_log_handler.setFormatter(_formatter)
logger = logging.getLogger("config")
# ─── DB Configuration (environment variables) ─────────────────────────────────
DB_CONFIG = {
"host": os.environ.get("DB_HOST", "localhost"),
"port": int(os.environ.get("DB_PORT", 3306)),
"database": os.environ.get("DB_NAME", "website_checker"),
"user": os.environ.get("DB_USER", "wc_user"),
"password": os.environ.get("DB_PASSWORD", ""),
"connection_timeout": 10,
"charset": "utf8mb4",
}
# ─── Connection Pool ──────────────────────────────────────────────────────────
_pool = None
_pool_lock = threading.Lock()
def get_connection_pool():
global _pool
if _pool is None:
with _pool_lock:
if _pool is None:
try:
_pool = pooling.MySQLConnectionPool(
pool_name="app_pool",
pool_size=10,
**DB_CONFIG,
)
logger.info("Database connection pool initialised.")
except mysql.connector.Error as e:
logger.error(f"Failed to create connection pool: {e}")
raise
return _pool
def get_connection():
"""Return a connection from the pool."""
return get_connection_pool().get_connection()
# ─── Schema DDL ───────────────────────────────────────────────────────────────
def initialize_database():
"""Create all required tables if they do not exist."""
ddl_statements = [
"""
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
role ENUM('admin','user') NOT NULL DEFAULT 'user',
full_name VARCHAR(200),
email VARCHAR(255) NULL DEFAULT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
failed_attempts TINYINT UNSIGNED NOT NULL DEFAULT 0,
locked_until DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS websites (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(200) NOT NULL,
url TEXT NOT NULL,
check_type ENUM('daily','weekly') NOT NULL DEFAULT 'daily',
visibility ENUM('all','assigned') NOT NULL DEFAULT 'all',
note TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_by INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS website_credentials (
id INT AUTO_INCREMENT PRIMARY KEY,
website_id INT NOT NULL,
label VARCHAR(100),
username VARCHAR(200),
password TEXT,
FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS shift_checks (
id INT AUTO_INCREMENT PRIMARY KEY,
website_id INT NOT NULL,
user_id INT NOT NULL,
checked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
user_note TEXT,
FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS activity_log (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
action VARCHAR(100) NOT NULL,
entity VARCHAR(100),
entity_id INT,
detail TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS shifts (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(200) NOT NULL,
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,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS shift_users (
shift_id INT NOT NULL,
user_id INT NOT NULL,
PRIMARY KEY (shift_id, user_id),
FOREIGN KEY (shift_id) REFERENCES shifts(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS shift_websites (
shift_id INT NOT NULL,
website_id INT NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
PRIMARY KEY (shift_id, website_id),
FOREIGN KEY (shift_id) REFERENCES shifts(id) ON DELETE CASCADE,
FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS login_attempts (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL,
attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
ip_address VARCHAR(45),
INDEX idx_username_time (username, attempted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS website_users (
website_id INT NOT NULL,
user_id INT NOT NULL,
PRIMARY KEY (website_id, user_id),
FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS ai_analysis_log (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
file_names TEXT NOT NULL,
model VARCHAR(100) NOT NULL,
verdict ENUM('PURSUE','PASS','UNCLEAR') NULL,
criteria_snapshot TEXT NULL,
summary_text MEDIUMTEXT NOT NULL,
analyzed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS ai_criteria (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
description TEXT NOT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_by INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS app_log (
id INT AUTO_INCREMENT PRIMARY KEY,
level VARCHAR(10) NOT NULL,
logger_name VARCHAR(100) NOT NULL,
message TEXT NOT NULL,
logged_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
INDEX idx_app_log_level (level),
INDEX idx_app_log_time (logged_at)
) 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;
""",
"""
CREATE TABLE IF NOT EXISTS app_settings (
key_name VARCHAR(100) NOT NULL PRIMARY KEY,
value TEXT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
]
conn = None
try:
conn = get_connection()
cursor = conn.cursor()
for stmt in ddl_statements:
cursor.execute(stmt)
conn.commit()
logger.info("Database schema initialised successfully.")
# ── ip_address column on login_attempts (web-only addition) ───────
# The desktop does not capture IP addresses; this adds the column
# safely if deploying the web app alongside an existing desktop DB.
cursor.execute(
"""
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'login_attempts'
AND COLUMN_NAME = 'ip_address'
"""
)
(has_ip,) = cursor.fetchone()
if not has_ip:
cursor.execute(
"ALTER TABLE login_attempts "
"ADD COLUMN ip_address VARCHAR(45) NULL AFTER attempted_at"
)
conn.commit()
logger.info("Migration: added ip_address column to login_attempts table.")
# ── Seed app_settings from environment variables (first-run bootstrap) ─
# Uses INSERT IGNORE so values already saved via the Admin UI are never
# overwritten — .env only fills in keys that are completely absent.
env_seeds = [
("groq.api_key", os.environ.get("GROQ_API_KEY", "")),
("groq.model", os.environ.get("GROQ_MODEL", "")),
("email.smtp_host", os.environ.get("SMTP_HOST", "")),
("email.smtp_port", os.environ.get("SMTP_PORT", "")),
("email.smtp_user", os.environ.get("SMTP_USER", "")),
("email.smtp_password",os.environ.get("SMTP_PASSWORD", "")),
("email.smtp_from", os.environ.get("SMTP_FROM", "")),
]
for k, v in env_seeds:
if v:
cursor.execute(
"""INSERT INTO app_settings (key_name, value) VALUES (%s, %s)
ON DUPLICATE KEY UPDATE
value = IF(value IS NULL OR value = '', VALUES(value), value)""",
(k, v),
)
conn.commit()
logger.info("app_settings seeded from environment variables (env→DB, blank-only overwrite).")
cursor.close()
except mysql.connector.Error as e:
logger.error(f"Database initialisation error: {e}")
raise
finally:
if conn:
conn.close()
# ─── app_settings helpers ─────────────────────────────────────────────────────
# Map app_settings keys → environment variable names so .env values are
# used as fallbacks when the DB row is absent or empty.
_ENV_FALLBACKS = {
"groq.api_key": "GROQ_API_KEY",
"groq.model": "GROQ_MODEL",
"email.smtp_host": "SMTP_HOST",
"email.smtp_port": "SMTP_PORT",
"email.smtp_user": "SMTP_USER",
"email.smtp_password": "SMTP_PASSWORD",
"email.smtp_from": "SMTP_FROM",
}
def get_setting(key: str, default: str = "") -> str:
"""Read a value from app_settings, falling back to env var then default."""
try:
conn = get_connection()
cur = conn.cursor()
cur.execute("SELECT value FROM app_settings WHERE key_name=%s", (key,))
row = cur.fetchone()
cur.close()
conn.close()
if row and row[0]:
return row[0]
except Exception as e:
logger.warning(f"get_setting({key!r}) failed: {e}")
# Fall back to environment variable if mapped
env_var = _ENV_FALLBACKS.get(key)
if env_var:
env_val = os.environ.get(env_var, "")
if env_val:
return env_val
return default
def set_setting(key: str, value: str) -> None:
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO app_settings (key_name, value) VALUES (%s,%s) "
"ON DUPLICATE KEY UPDATE value=%s, updated_at=CURRENT_TIMESTAMP",
(key, value, value),
)
conn.commit()
cur.close()
conn.close()
except Exception as e:
logger.error(f"set_setting({key!r}) failed: {e}")
def get_settings_dict(prefix: str) -> dict:
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"SELECT key_name, value FROM app_settings WHERE key_name LIKE %s",
(prefix + "%",),
)
rows = cur.fetchall()
cur.close()
conn.close()
return {r[0]: (r[1] or "") for r in rows}
except Exception as e:
logger.warning(f"get_settings_dict({prefix!r}) failed: {e}")
return {}
+1466
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
Flask==3.0.3
Flask-Session==0.8.0
mysql-connector-python==8.4.0
cryptography==42.0.8
requests==2.32.3
gunicorn==22.0.0
python-dotenv==1.0.1
Werkzeug==3.0.3
bcrypt==4.1.3
pypdf==4.3.1
python-docx==1.1.2
openpyxl==3.1.5
+1
View File
@@ -0,0 +1 @@
# routes package
+22
View File
@@ -0,0 +1,22 @@
"""
routes/admin_dashboard.py Admin dashboard: today's completion stats.
"""
import logging
from flask import Blueprint, render_template
from models import get_admin_dashboard_stats
from utils.decorators import admin_required
logger = logging.getLogger("routes.admin_dashboard")
admin_dashboard_bp = Blueprint("admin_dashboard", __name__, url_prefix="/admin")
@admin_dashboard_bp.route("/dashboard")
@admin_required
def dashboard():
try:
stats = get_admin_dashboard_stats()
except Exception as e:
logger.error(f"Dashboard stats error: {e}")
stats = {"user_stats": [], "total_sites": 0, "total_users": 0, "active_today": 0}
return render_template("admin/dashboard.html", stats=stats)
+49
View File
@@ -0,0 +1,49 @@
"""
routes/admin_logs.py Activity log and app log routes.
"""
import logging
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from models import get_activity_log, get_app_log, purge_app_log, log_action
from utils.decorators import admin_required
logger = logging.getLogger("routes.admin_logs")
admin_logs_bp = Blueprint("admin_logs", __name__, url_prefix="/admin/logs")
@admin_logs_bp.route("/")
@admin_required
def logs():
tab = request.args.get("tab", "activity")
search = request.args.get("search", "").strip()
limit = int(request.args.get("limit", 200))
activity_rows = []
app_rows = []
level_filter = request.args.get("level", "")
if tab == "activity":
activity_rows = get_activity_log(limit=limit, search=search)
else:
app_rows = get_app_log(limit=limit, level_filter=level_filter, search=search)
return render_template("admin/logs.html",
tab=tab,
activity_rows=activity_rows,
app_rows=app_rows,
search=search,
level_filter=level_filter,
limit=limit)
@admin_logs_bp.route("/purge", methods=["POST"])
@admin_required
def purge():
days = int(request.form.get("days", 30))
admin = session["user"]
deleted = purge_app_log(older_than_days=days)
log_action(admin["id"], "PURGE_APP_LOG", "app_log", None,
f"Purged {deleted} app log records older than {days} days.")
flash(f"Purged {deleted} app log records older than {days} days.", "success")
logger.info(f"App log purged: {deleted} records by admin_id={admin['id']}.")
return redirect(url_for("admin_logs.logs", tab="app"))
+102
View File
@@ -0,0 +1,102 @@
"""
routes/admin_reports.py Shift detail, unchecked, and summary reports.
"""
import logging
import io
import csv
from datetime import date, timedelta
from flask import Blueprint, render_template, request, send_file, Response
from models import (
get_shift_report, get_unchecked_report, get_summary_report,
get_report_filter_options,
)
from utils.decorators import admin_required
logger = logging.getLogger("routes.admin_reports")
admin_reports_bp = Blueprint("admin_reports", __name__, url_prefix="/admin/reports")
def _today():
return date.today()
@admin_reports_bp.route("/")
@admin_required
def reports():
tab = request.args.get("tab", "detail")
date_from = request.args.get("date_from") or str(_today() - timedelta(days=30))
date_to = request.args.get("date_to") or str(_today())
user_id = request.args.get("user_id")
website_id = request.args.get("website_id")
target_date = request.args.get("target_date") or str(_today())
users, websites = get_report_filter_options()
rows = []
try:
if tab == "detail":
rows = get_shift_report(
date_from=date_from, date_to=date_to,
user_id=int(user_id) if user_id else None,
website_id=int(website_id) if website_id else None,
)
elif tab == "unchecked":
rows = get_unchecked_report(
target_date=target_date,
user_id=int(user_id) if user_id else None,
)
elif tab == "summary":
rows = get_summary_report(date_from=date_from, date_to=date_to)
except Exception as e:
logger.error(f"Report query error: {e}")
return render_template("admin/reports.html",
tab=tab, rows=rows,
users=users, websites=websites,
date_from=date_from, date_to=date_to,
target_date=target_date,
user_id=user_id, website_id=website_id)
@admin_reports_bp.route("/export/csv")
@admin_required
def export_csv():
tab = request.args.get("tab", "detail")
date_from = request.args.get("date_from") or str(_today() - timedelta(days=30))
date_to = request.args.get("date_to") or str(_today())
user_id = request.args.get("user_id")
website_id = request.args.get("website_id")
target_date = request.args.get("target_date") or str(_today())
try:
if tab == "detail":
rows = get_shift_report(
date_from=date_from, date_to=date_to,
user_id=int(user_id) if user_id else None,
website_id=int(website_id) if website_id else None,
)
elif tab == "unchecked":
rows = get_unchecked_report(
target_date=target_date,
user_id=int(user_id) if user_id else None,
)
else:
rows = get_summary_report(date_from=date_from, date_to=date_to)
except Exception as e:
logger.error(f"CSV export error: {e}")
rows = []
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=rows[0].keys() if rows else [],
extrasaction="ignore")
writer.writeheader()
for row in rows:
writer.writerow({k: str(v) if v is not None else "" for k, v in row.items()})
output.seek(0)
return Response(
output.getvalue(),
mimetype="text/csv",
headers={"Content-Disposition": f"attachment; filename=report_{tab}_{date_to}.csv"},
)
+56
View File
@@ -0,0 +1,56 @@
"""
routes/admin_settings.py Application settings (email, Groq API, etc.)
"""
import logging
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from config import get_settings_dict, set_setting
from models import log_action
from utils.decorators import admin_required
logger = logging.getLogger("routes.admin_settings")
admin_settings_bp = Blueprint("admin_settings", __name__, url_prefix="/admin/settings")
@admin_settings_bp.route("/")
@admin_required
def settings():
email_settings = get_settings_dict("email.")
groq_settings = get_settings_dict("groq.")
return render_template("admin/settings.html",
email=email_settings, groq=groq_settings)
@admin_settings_bp.route("/email", methods=["POST"])
@admin_required
def save_email():
admin = session["user"]
fields = [
"email.enabled", "email.smtp_host", "email.smtp_port",
"email.smtp_user", "email.smtp_password", "email.security",
"email.recipients", "email.send_time",
]
for field in fields:
key = field
form_key = field.replace(".", "_")
value = request.form.get(form_key, "")
set_setting(key, value)
log_action(admin["id"], "UPDATE_EMAIL_SETTINGS", "app_settings", None,
"Email settings updated via web UI.")
flash("Email settings saved successfully.", "success")
logger.info(f"Email settings updated by admin_id={admin['id']}.")
return redirect(url_for("admin_settings.settings"))
@admin_settings_bp.route("/groq", methods=["POST"])
@admin_required
def save_groq():
admin = session["user"]
set_setting("groq.api_key", request.form.get("groq_api_key", ""))
set_setting("groq.model", request.form.get("groq_model", "llama-3.3-70b-versatile"))
log_action(admin["id"], "UPDATE_GROQ_SETTINGS", "app_settings", None,
"Groq API settings updated via web UI.")
flash("Groq settings saved successfully.", "success")
logger.info(f"Groq settings updated by admin_id={admin['id']}.")
return redirect(url_for("admin_settings.settings"))
+131
View File
@@ -0,0 +1,131 @@
"""
routes/admin_shifts.py Shift management CRUD routes.
"""
import logging
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
from models import (
get_all_shifts, get_shift_by_id, get_shift_assigned_users,
get_shift_assigned_websites, create_shift, update_shift, delete_shift,
get_all_users, get_all_websites,
)
from utils.decorators import admin_required
logger = logging.getLogger("routes.admin_shifts")
admin_shifts_bp = Blueprint("admin_shifts", __name__, url_prefix="/admin/shifts")
# MySQL DAYOFWEEK: 1=Sun 2=Mon … 7=Sat
DAY_MAP = [
("Mon", "2"), ("Tue", "3"), ("Wed", "4"),
("Thu", "5"), ("Fri", "6"), ("Sat", "7"), ("Sun", "1"),
]
@admin_shifts_bp.route("/")
@admin_required
def shifts_list():
shifts = get_all_shifts()
all_users = get_all_users()
all_sites = get_all_websites()
return render_template("admin/shifts.html",
shifts=shifts, all_users=all_users,
all_sites=all_sites, day_map=DAY_MAP)
@admin_shifts_bp.route("/<int:shift_id>/detail")
@admin_required
def detail(shift_id):
shift = get_shift_by_id(shift_id)
users = get_shift_assigned_users(shift_id)
websites = get_shift_assigned_websites(shift_id)
if not shift:
return jsonify({"error": "Not found"}), 404
# Convert time objects to HH:MM strings for form inputs
def _fmt(t):
if t is None:
return ""
if hasattr(t, "seconds"): # timedelta from MySQL
h, rem = divmod(int(t.total_seconds()), 3600)
return f"{h:02d}:{rem // 60:02d}"
return str(t)[:5]
return jsonify({
"id": shift["id"],
"name": shift["name"],
"days_of_week": shift["days_of_week"],
"start_time": _fmt(shift["start_time"]),
"end_time": _fmt(shift["end_time"]),
"note": shift["note"] or "",
"is_active": shift["is_active"],
"user_ids": [u["id"] for u in users],
"website_ids": [w["id"] for w in websites],
})
@admin_shifts_bp.route("/create", methods=["POST"])
@admin_required
def create():
admin_id = session["user"]["id"]
name = request.form.get("name", "").strip()
days_of_week = "".join(request.form.getlist("days_of_week[]"))
start_time = request.form.get("start_time", "08:00")
end_time = request.form.get("end_time", "17:00")
note = request.form.get("note", "").strip()
user_ids = [int(x) for x in request.form.getlist("user_ids[]") if x]
website_ids = [int(x) for x in request.form.getlist("website_ids[]") if x]
if not name:
flash("Shift name is required.", "danger")
return redirect(url_for("admin_shifts.shifts_list"))
try:
create_shift(admin_id, name, days_of_week, start_time, end_time,
note, user_ids, website_ids)
flash(f"Shift '{name}' created successfully.", "success")
logger.info(f"Shift '{name}' created by admin_id={admin_id}.")
except Exception as e:
logger.error(f"create_shift error: {e}")
flash(f"Error creating shift: {e}", "danger")
return redirect(url_for("admin_shifts.shifts_list"))
@admin_shifts_bp.route("/<int:shift_id>/edit", methods=["POST"])
@admin_required
def edit(shift_id):
admin_id = session["user"]["id"]
name = request.form.get("name", "").strip()
days_of_week = "".join(request.form.getlist("days_of_week[]"))
start_time = request.form.get("start_time", "08:00")
end_time = request.form.get("end_time", "17:00")
note = request.form.get("note", "").strip()
is_active = int(request.form.get("is_active", 1))
user_ids = [int(x) for x in request.form.getlist("user_ids[]") if x]
website_ids = [int(x) for x in request.form.getlist("website_ids[]") if x]
try:
update_shift(admin_id, shift_id, name, days_of_week, start_time, end_time,
note, is_active, user_ids, website_ids)
flash(f"Shift '{name}' updated successfully.", "success")
logger.info(f"Shift id={shift_id} updated by admin_id={admin_id}.")
except Exception as e:
logger.error(f"update_shift error: {e}")
flash(f"Error updating shift: {e}", "danger")
return redirect(url_for("admin_shifts.shifts_list"))
@admin_shifts_bp.route("/<int:shift_id>/delete", methods=["POST"])
@admin_required
def delete(shift_id):
admin_id = session["user"]["id"]
try:
delete_shift(admin_id, shift_id)
flash("Shift deactivated successfully.", "success")
logger.info(f"Shift id={shift_id} soft-deleted by admin_id={admin_id}.")
except Exception as e:
logger.error(f"delete_shift error: {e}")
flash(f"Error deleting shift: {e}", "danger")
return redirect(url_for("admin_shifts.shifts_list"))
+82
View File
@@ -0,0 +1,82 @@
"""
routes/admin_users.py User management CRUD routes.
"""
import logging
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from models import get_all_users, create_user, update_user, delete_user
from utils.decorators import admin_required
logger = logging.getLogger("routes.admin_users")
admin_users_bp = Blueprint("admin_users", __name__, url_prefix="/admin/users")
@admin_users_bp.route("/")
@admin_required
def users_list():
users = get_all_users()
return render_template("admin/users.html", users=users)
@admin_users_bp.route("/create", methods=["POST"])
@admin_required
def create():
admin_id = session["user"]["id"]
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
role = request.form.get("role", "user")
full_name = request.form.get("full_name", "").strip()
email = request.form.get("email", "").strip() or None
if not username or not password:
flash("Username and password are required.", "danger")
return redirect(url_for("admin_users.users_list"))
try:
create_user(admin_id, username, password, role, full_name, email)
flash(f"User '{username}' created successfully.", "success")
logger.info(f"User '{username}' created by admin_id={admin_id}.")
except Exception as e:
logger.error(f"create_user error: {e}")
flash(f"Error creating user: {e}", "danger")
return redirect(url_for("admin_users.users_list"))
@admin_users_bp.route("/<int:user_id>/edit", methods=["POST"])
@admin_required
def edit(user_id):
admin_id = session["user"]["id"]
username = request.form.get("username", "").strip()
role = request.form.get("role", "user")
full_name = request.form.get("full_name", "").strip()
email = request.form.get("email", "").strip() or None
is_active = int(request.form.get("is_active", 1))
password = request.form.get("password", "").strip() or None
try:
update_user(admin_id, user_id, username, role, full_name, is_active, password, email)
flash(f"User '{username}' updated successfully.", "success")
logger.info(f"User id={user_id} updated by admin_id={admin_id}.")
except Exception as e:
logger.error(f"update_user error: {e}")
flash(f"Error updating user: {e}", "danger")
return redirect(url_for("admin_users.users_list"))
@admin_users_bp.route("/<int:user_id>/delete", methods=["POST"])
@admin_required
def delete(user_id):
admin_id = session["user"]["id"]
try:
delete_user(admin_id, user_id)
flash("User deleted successfully.", "success")
logger.info(f"User id={user_id} deleted by admin_id={admin_id}.")
except ValueError as e:
flash(str(e), "danger")
except Exception as e:
logger.error(f"delete_user error: {e}")
flash(f"Error deleting user: {e}", "danger")
return redirect(url_for("admin_users.users_list"))
+125
View File
@@ -0,0 +1,125 @@
"""
routes/admin_websites.py Website management CRUD routes.
"""
import logging
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
from models import (
get_all_websites, get_website_by_id, get_website_credentials,
get_website_assigned_users, create_website, update_website, delete_website,
get_all_users,
)
from utils.decorators import admin_required
logger = logging.getLogger("routes.admin_websites")
admin_websites_bp = Blueprint("admin_websites", __name__, url_prefix="/admin/websites")
def _parse_credentials(form) -> list:
"""Extract credential rows from a multivalue form submission."""
creds = []
labels = form.getlist("cred_label[]")
usernames = form.getlist("cred_username[]")
passwords = form.getlist("cred_password[]")
for label, username, password in zip(labels, usernames, passwords):
if username.strip():
creds.append({
"label": label.strip(),
"username": username.strip(),
"password": password,
})
return creds
@admin_websites_bp.route("/")
@admin_required
def websites_list():
websites = get_all_websites()
all_users = get_all_users()
return render_template("admin/websites.html", websites=websites, all_users=all_users)
@admin_websites_bp.route("/<int:website_id>/detail")
@admin_required
def detail(website_id):
"""JSON endpoint: returns website + credentials + assigned users for the edit modal."""
site = get_website_by_id(website_id)
creds = get_website_credentials(website_id)
assigned = get_website_assigned_users(website_id)
if not site:
return jsonify({"error": "Not found"}), 404
return jsonify({
"id": site["id"],
"name": site["name"],
"url": site["url"],
"check_type": site["check_type"],
"visibility": site["visibility"],
"note": site["note"] or "",
"credentials": [{"label": c["label"], "username": c["username"], "password": c["password"]} for c in creds],
"assigned_user_ids": [u["id"] for u in assigned],
})
@admin_websites_bp.route("/create", methods=["POST"])
@admin_required
def create():
admin_id = session["user"]["id"]
name = request.form.get("name", "").strip()
url = request.form.get("url", "").strip()
check_type = request.form.get("check_type", "daily")
visibility = request.form.get("visibility", "all")
note = request.form.get("note", "").strip()
creds = _parse_credentials(request.form)
assigned = [int(x) for x in request.form.getlist("assigned_user_ids[]") if x]
if not name or not url:
flash("Name and URL are required.", "danger")
return redirect(url_for("admin_websites.websites_list"))
try:
create_website(admin_id, name, url, check_type, note, creds, visibility, assigned)
flash(f"Website '{name}' created successfully.", "success")
logger.info(f"Website '{name}' created by admin_id={admin_id}.")
except Exception as e:
logger.error(f"create_website error: {e}")
flash(f"Error creating website: {e}", "danger")
return redirect(url_for("admin_websites.websites_list"))
@admin_websites_bp.route("/<int:website_id>/edit", methods=["POST"])
@admin_required
def edit(website_id):
admin_id = session["user"]["id"]
name = request.form.get("name", "").strip()
url = request.form.get("url", "").strip()
check_type = request.form.get("check_type", "daily")
visibility = request.form.get("visibility", "all")
note = request.form.get("note", "").strip()
creds = _parse_credentials(request.form)
assigned = [int(x) for x in request.form.getlist("assigned_user_ids[]") if x]
try:
update_website(admin_id, website_id, name, url, check_type, note, creds, visibility, assigned)
flash(f"Website '{name}' updated successfully.", "success")
logger.info(f"Website id={website_id} updated by admin_id={admin_id}.")
except Exception as e:
logger.error(f"update_website error: {e}")
flash(f"Error updating website: {e}", "danger")
return redirect(url_for("admin_websites.websites_list"))
@admin_websites_bp.route("/<int:website_id>/delete", methods=["POST"])
@admin_required
def delete(website_id):
admin_id = session["user"]["id"]
try:
delete_website(admin_id, website_id)
flash("Website deleted (soft) successfully.", "success")
logger.info(f"Website id={website_id} soft-deleted by admin_id={admin_id}.")
except Exception as e:
logger.error(f"delete_website error: {e}")
flash(f"Error deleting website: {e}", "danger")
return redirect(url_for("admin_websites.websites_list"))
+341
View File
@@ -0,0 +1,341 @@
"""
routes/ai_summary.py AI document analysis routes.
"""
import logging
import json
import io
import os
import re
import requests as http_requests
from flask import (Blueprint, render_template, request, redirect, url_for,
flash, session, jsonify)
from models import (
get_all_criteria, get_active_criteria, create_criterion, update_criterion,
delete_criterion, save_ai_analysis, get_ai_analysis_history,
get_ai_analysis_detail, log_action,
)
from config import get_setting
from utils.decorators import login_required, admin_required
logger = logging.getLogger("routes.ai_summary")
ai_summary_bp = Blueprint("ai_summary", __name__, url_prefix="/ai-summary")
GROQ_MODELS = [
"llama-3.3-70b-versatile",
"llama-3.1-8b-instant",
"gemma2-9b-it",
]
SUPPORTED_EXT = {".txt", ".md", ".csv", ".pdf", ".doc", ".docx", ".xlsx", ".xls"}
@ai_summary_bp.route("/")
@login_required
def ai_summary():
user = session["user"]
criteria = get_all_criteria()
history = get_ai_analysis_history(
user_id=(None if user["role"] == "admin" else user["id"])
)
groq_key = get_setting("groq.api_key", "")
groq_model = get_setting("groq.model", GROQ_MODELS[0])
return render_template("ai_summary.html",
criteria=criteria, history=history,
groq_key_set=bool(groq_key),
groq_model=groq_model,
groq_models=GROQ_MODELS,
is_admin=user["role"] == "admin")
@ai_summary_bp.route("/analyze", methods=["POST"])
@login_required
def analyze():
user = session["user"]
files = request.files.getlist("documents[]")
model = request.form.get("model", get_setting("groq.model", GROQ_MODELS[0]))
# Read API key: env var takes priority, then DB setting
api_key = (os.environ.get("GROQ_API_KEY") or "").strip() or get_setting("groq.api_key", "").strip()
if not api_key:
return jsonify({"error": "Groq API key is not configured. Set GROQ_API_KEY in .env or via Admin → Settings."}), 400
if not files or all(f.filename == "" for f in files):
return jsonify({"error": "No files uploaded."}), 400
texts = []
file_names = []
errors = []
for f in files:
if not f.filename:
continue
ext = ("." + f.filename.rsplit(".", 1)[-1].lower()) if "." in f.filename else ""
if ext not in SUPPORTED_EXT:
errors.append(f"{f.filename}: unsupported file type")
continue
file_names.append(f.filename)
try:
content = _extract_text(f, ext)
if content and content.strip():
texts.append(f"=== {f.filename} ===\n{content.strip()}")
else:
errors.append(f"{f.filename}: no text could be extracted")
except Exception as e:
logger.warning(f"Could not extract text from {f.filename}: {e}")
errors.append(f"{f.filename}: {e}")
if not texts:
detail = "; ".join(errors) if errors else "Check the file format and try again."
return jsonify({"error": f"Could not extract text from any uploaded file. {detail}"}), 400
criteria = get_active_criteria()
combined = "\n\n".join(texts)
try:
result = _call_groq(api_key, model, combined, criteria)
except Exception as e:
logger.error(f"Groq API error: {e}")
return jsonify({"error": f"AI analysis failed: {e}"}), 500
criteria_snap = json.dumps([
{"title": c["title"], "description": c["description"]} for c in criteria
])
analysis_id = save_ai_analysis(
user["id"], ", ".join(file_names), model,
result.get("verdict"), criteria_snap, result.get("summary", "")
)
log_action(user["id"], "AI_ANALYSIS", "ai_analysis_log", analysis_id,
f"AI analysis on {len(file_names)} file(s). Verdict: {result.get('verdict')}.")
return jsonify({"analysis_id": analysis_id, "model": model,
"file_count": len(file_names), **result})
def _extract_text(file_obj, ext: str) -> str:
"""Extract plain text from an uploaded file object."""
data = file_obj.read()
if ext in (".txt", ".md", ".csv"):
return data.decode("utf-8", errors="replace")
if ext == ".pdf":
import pypdf
reader = pypdf.PdfReader(io.BytesIO(data))
pages = []
for page in reader.pages:
t = page.extract_text()
if t:
pages.append(t)
return "\n".join(pages)
if ext in (".docx", ".doc"):
# Use python-docx (installed as 'docx') — do NOT use docx2txt
from docx import Document
doc = Document(io.BytesIO(data))
lines = [para.text for para in doc.paragraphs if para.text.strip()]
return "\n".join(lines)
if ext in (".xlsx", ".xls"):
import openpyxl
wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
lines = []
for ws in wb.worksheets:
for row in ws.iter_rows(values_only=True):
line = "\t".join(str(c) if c is not None else "" for c in row)
if line.strip():
lines.append(line)
return "\n".join(lines)
return ""
# Office address used as origin for distance/travel-time estimates.
_OFFICE_ADDRESS = "2815 Hartland Road, Falls Church, VA 22043, USA"
# Stage 1 — extraction prompt (always sent)
_EXTRACTION_PROMPT = """You are an expert government procurement analyst.
The user has provided {n} document(s). Your job is to extract specific information from each document and present it in a clean, structured format.
IMPORTANT Our office is located at:
{office}
Use this as the ORIGIN address for all driving distance and travel time calculations in field #9 below.
For EACH document, extract and clearly label the following fields (write "N/A" if a field is not found):
1. Solicitation Number
2. Solicitation Type (e.g. RFP, RFQ, IFB, etc.)
3. Set-Aside (e.g. Small Business, 8(a), N/A)
4. Description / Scope of Work
5. Work Site / Location(s)
6. Pre-Proposal Conference / Site-Visit (date, time, full address)
7. Point of Contact (POC) (name, phone, email)
8. Total Square Footage (if applicable)
9. Driving Distance & Travel Time
- Origin: {office}
- Destination: Pre-Proposal Conference or primary Work Site address
- Provide your best estimate of driving distance (miles) and typical driving time using major highways
- Note that these are AI estimates; actual times may vary with traffic
10. Last Day to Submit Questions
11. Due Date & Time
12. Any other notable requirements or deadlines
After the per-document breakdown, provide a detailed OVERALL SUMMARY covering:
A. Scope of Work
B. Contract Period
C. Proposal Submission Requirements
D. Key Deadlines & Action Items
Be precise, detailed, and use bullet points throughout.
If information is not explicitly stated in the documents, note it as "Not specified in the document."
DOCUMENTS:
{documents}"""
# Stage 2 — criteria evaluation suffix (appended only when active criteria exist)
_CRITERIA_PROMPT_SUFFIX = """
================================================================================
OPPORTUNITY ALIGNMENT EVALUATION
================================================================================
After completing the extraction and summary above, evaluate whether this
opportunity aligns with our company's interests based on the following criteria.
OUR EVALUATION CRITERIA:
{criteria_list}
For EACH criterion above:
- State whether the opportunity MEETS, DOES NOT MEET, or PARTIALLY MEETS it.
- Provide a brief, specific explanation citing details from the document(s).
Then provide an OVERALL RECOMMENDATION using EXACTLY one of these three labels
on its own line (this label is machine-read do not alter it):
RECOMMENDATION: PURSUE
RECOMMENDATION: PASS
RECOMMENDATION: UNCLEAR
Use PURSUE if the opportunity clearly meets most criteria and presents strong
alignment. Use PASS if it clearly fails key criteria. Use UNCLEAR if the
documents lack sufficient information to make a confident determination.
End with a 2-3 sentence EXECUTIVE SUMMARY explaining your recommendation
in plain business language."""
def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict:
"""Call the Groq chat completions REST API directly (no SDK required)."""
import re
n = text.count("=== ") or 1 # count file separators for the prompt header
# Build the two-stage prompt matching the desktop app exactly
prompt = _EXTRACTION_PROMPT.format(
n=n, office=_OFFICE_ADDRESS, documents=text[:14000]
)
if criteria:
criteria_list = "\n".join(
f" {i+1}. {c['title']}: {c['description']}"
for i, c in enumerate(criteria)
)
prompt += _CRITERIA_PROMPT_SUFFIX.format(criteria_list=criteria_list)
response = http_requests.post(
"https://api.groq.com/openai/v1/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 4096,
"temperature": 0.2,
},
timeout=90,
)
if not response.ok:
logger.error(f"Groq API {response.status_code}: {response.text[:300]}")
response.raise_for_status()
content = response.json()["choices"][0]["message"]["content"] or ""
# Parse the machine-readable RECOMMENDATION label (only present when criteria used)
verdict = None
if criteria:
match = re.search(
r"RECOMMENDATION\s*:\s*(PURSUE|PASS|UNCLEAR)",
content, re.IGNORECASE,
)
if match:
verdict = match.group(1).upper()
return {"verdict": verdict, "summary": content}
# ─── Criteria Management (admin only) ─────────────────────────────────────────
@ai_summary_bp.route("/criteria/create", methods=["POST"])
@admin_required
def create_criterion_view():
admin = session["user"]
title = request.form.get("title", "").strip()
desc = request.form.get("description", "").strip()
is_active = request.form.get("is_active", "1") == "1"
sort_order = int(request.form.get("sort_order", 0))
try:
create_criterion(admin["id"], title, desc, is_active, sort_order)
flash(f"Criterion '{title}' created.", "success")
except Exception as e:
flash(f"Error: {e}", "danger")
return redirect(url_for("ai_summary.ai_summary"))
@ai_summary_bp.route("/criteria/<int:criterion_id>/edit", methods=["POST"])
@admin_required
def edit_criterion_view(criterion_id):
admin = session["user"]
title = request.form.get("title", "").strip()
desc = request.form.get("description", "").strip()
is_active = request.form.get("is_active", "1") == "1"
sort_order = int(request.form.get("sort_order", 0))
try:
update_criterion(admin["id"], criterion_id, title, desc, is_active, sort_order)
flash(f"Criterion '{title}' updated.", "success")
except Exception as e:
flash(f"Error: {e}", "danger")
return redirect(url_for("ai_summary.ai_summary"))
@ai_summary_bp.route("/criteria/<int:criterion_id>/delete", methods=["POST"])
@admin_required
def delete_criterion_view(criterion_id):
admin = session["user"]
try:
delete_criterion(admin["id"], criterion_id)
flash("Criterion deleted.", "success")
except Exception as e:
flash(f"Error: {e}", "danger")
return redirect(url_for("ai_summary.ai_summary"))
@ai_summary_bp.route("/history/<int:analysis_id>")
@login_required
def analysis_detail(analysis_id):
record = get_ai_analysis_detail(analysis_id)
if not record:
return jsonify({"error": "Not found"}), 404
user = session["user"]
if user["role"] != "admin" and record["user_id"] != user["id"]:
return jsonify({"error": "Access denied"}), 403
return jsonify({
"id": record["id"],
"username": record.get("username"),
"file_names": record["file_names"],
"model": record["model"],
"verdict": record["verdict"],
"analyzed_at": str(record["analyzed_at"]),
"summary_text": record["summary_text"],
})
+88
View File
@@ -0,0 +1,88 @@
"""
routes/auth.py Authentication routes: login, logout, change-password.
"""
import logging
from flask import Blueprint, render_template, request, session, redirect, url_for, flash
from models import authenticate, check_login_allowed, change_password, log_action
from utils.decorators import login_required
logger = logging.getLogger("routes.auth")
auth_bp = Blueprint("auth", __name__)
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
if "user" in session:
return redirect(url_for("index"))
error = None
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
ip = request.remote_addr
allowed, seconds_remaining = check_login_allowed(username)
if not allowed:
mins = seconds_remaining // 60
secs = seconds_remaining % 60
error = f"Account locked. Try again in {mins}m {secs}s."
logger.warning(f"Login blocked for '{username}' — still locked ({seconds_remaining}s remaining).")
else:
user = authenticate(username, password)
if user:
session.permanent = True
# Store a safe subset — never store the password hash in session
session["user"] = {
"id": user["id"],
"username": user["username"],
"full_name": user.get("full_name") or user["username"],
"role": user["role"],
"email": user.get("email", ""),
}
logger.info(f"User '{username}' logged in from {ip}.")
flash(f"You have been signed in. Welcome, {session['user']['full_name']}!", "success")
if user["role"] == "admin":
return redirect(url_for("admin_dashboard.dashboard"))
return redirect(url_for("user_dashboard.my_shifts"))
else:
error = "Invalid username or password."
return render_template("login.html", error=error)
@auth_bp.route("/logout")
@login_required
def logout():
user = session.get("user", {})
if user:
log_action(user["id"], "LOGOUT", "users", user["id"],
f"User '{user['username']}' logged out.")
logger.info(f"User '{user['username']}' logged out.")
session.clear()
flash("You have been signed out.", "info")
return redirect(url_for("auth.login"))
@auth_bp.route("/change-password", methods=["GET", "POST"])
@login_required
def change_password_view():
user = session["user"]
success = None
error = None
if request.method == "POST":
old_pw = request.form.get("old_password", "")
new_pw = request.form.get("new_password", "")
confirm = request.form.get("confirm_password", "")
if new_pw != confirm:
error = "New password and confirmation do not match."
else:
ok, msg = change_password(user["id"], old_pw, new_pw)
if ok:
success = msg
else:
error = msg
return render_template("change_password.html", success=success, error=error)
+234
View File
@@ -0,0 +1,234 @@
"""
routes/bid_tracker.py Bid / Opportunity tracker routes.
"""
import logging
from flask import (Blueprint, render_template, request, redirect, url_for,
flash, session, jsonify)
from models import (
get_all_bids, get_bid, create_bid, update_bid, delete_bid,
get_bid_updates, add_bid_update, delete_bid_update, BID_STATUSES,
)
from utils.decorators import login_required
logger = logging.getLogger("routes.bid_tracker")
bid_tracker_bp = Blueprint("bid_tracker", __name__, url_prefix="/bids")
STATUS_LABELS = {
"open": "🟢 Open",
"monitoring": "🔵 Monitoring",
"awarded": "🏆 Awarded",
"no_bid": "⛔ No Bid",
"cancelled": "🚫 Cancelled",
}
@bid_tracker_bp.route("/")
@login_required
def bids_list():
status_filter = request.args.get("status", "")
bids = get_all_bids(status_filter=status_filter)
return render_template("bid_tracker.html",
bids=bids,
status_filter=status_filter,
bid_statuses=BID_STATUSES,
status_labels=STATUS_LABELS)
@bid_tracker_bp.route("/<int:bid_id>")
@login_required
def bid_detail(bid_id):
bid = get_bid(bid_id)
updates = get_bid_updates(bid_id)
if not bid:
flash("Bid not found.", "warning")
return redirect(url_for("bid_tracker.bids_list"))
return render_template("bid_detail.html",
bid=bid, updates=updates,
bid_statuses=BID_STATUSES,
status_labels=STATUS_LABELS)
@bid_tracker_bp.route("/create", methods=["POST"])
@login_required
def create():
user = session["user"]
title = request.form.get("title", "").strip()
url = request.form.get("url", "").strip()
source = request.form.get("source", "").strip()
sol_no = request.form.get("solicitation_number", "").strip()
status = request.form.get("status", "open")
due_date = request.form.get("due_date") or None
notes = request.form.get("notes", "").strip()
if not title or not url:
flash("Title and URL are required.", "danger")
return redirect(url_for("bid_tracker.bids_list"))
try:
create_bid(user["id"], title, url, source, sol_no, status, due_date, notes)
flash(f"Bid '{title}' added.", "success")
logger.info(f"Bid '{title}' created by user_id={user['id']}.")
except Exception as e:
logger.error(f"create_bid error: {e}")
flash(f"Error: {e}", "danger")
return redirect(url_for("bid_tracker.bids_list"))
@bid_tracker_bp.route("/<int:bid_id>/edit", methods=["POST"])
@login_required
def edit(bid_id):
user = session["user"]
title = request.form.get("title", "").strip()
url = request.form.get("url", "").strip()
source = request.form.get("source", "").strip()
sol_no = request.form.get("solicitation_number", "").strip()
status = request.form.get("status", "open")
due_date = request.form.get("due_date") or None
notes = request.form.get("notes", "").strip()
try:
update_bid(user["id"], bid_id, title, url, source, sol_no, status, due_date, notes)
flash(f"Bid '{title}' updated.", "success")
logger.info(f"Bid id={bid_id} updated by user_id={user['id']}.")
except Exception as e:
logger.error(f"update_bid error: {e}")
flash(f"Error: {e}", "danger")
return redirect(url_for("bid_tracker.bid_detail", bid_id=bid_id))
@bid_tracker_bp.route("/<int:bid_id>/delete", methods=["POST"])
@login_required
def delete(bid_id):
user = session["user"]
try:
delete_bid(user["id"], bid_id)
flash("Bid deleted.", "success")
logger.info(f"Bid id={bid_id} deleted by user_id={user['id']}.")
except Exception as e:
logger.error(f"delete_bid error: {e}")
flash(f"Error: {e}", "danger")
return redirect(url_for("bid_tracker.bids_list"))
@bid_tracker_bp.route("/<int:bid_id>/updates", methods=["POST"])
@login_required
def add_update(bid_id):
user = session["user"]
content = request.form.get("content", "").strip()
if not content:
flash("Update content cannot be empty.", "warning")
else:
try:
add_bid_update(user["id"], bid_id, content)
flash("Update posted.", "success")
logger.info(f"Bid update posted on bid_id={bid_id} by user_id={user['id']}.")
except Exception as e:
logger.error(f"add_bid_update error: {e}")
flash(f"Error: {e}", "danger")
return redirect(url_for("bid_tracker.bid_detail", bid_id=bid_id))
@bid_tracker_bp.route("/updates/<int:update_id>/delete", methods=["POST"])
@login_required
def delete_update(update_id):
user = session["user"]
try:
delete_bid_update(user["id"], update_id)
flash("Update deleted.", "success")
logger.info(f"Bid update id={update_id} deleted by user_id={user['id']}.")
except Exception as e:
logger.error(f"delete_bid_update error: {e}")
flash(f"Error: {e}", "danger")
bid_id = request.form.get("bid_id")
if bid_id:
return redirect(url_for("bid_tracker.bid_detail", bid_id=int(bid_id)))
return redirect(url_for("bid_tracker.bids_list"))
@bid_tracker_bp.route("/<int:bid_id>/json")
@login_required
def bid_json(bid_id):
"""Return bid detail + updates as JSON for the split-pane detail panel."""
user = session["user"]
bid = get_bid(bid_id)
if not bid:
return jsonify({"error": "Not found"}), 404
updates = get_bid_updates(bid_id)
is_owner = bid.get("added_by") == user["id"]
can_edit = user["role"] == "admin" or is_owner
def ser(row):
"""Make a dict JSON-serialisable (dates → str)."""
out = {}
for k, v in row.items():
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
else:
out[k] = v
return out
return jsonify({
"bid": ser(bid),
"updates": [ser(u) for u in updates],
"can_edit": can_edit,
"user_id": user["id"],
"is_admin": user["role"] == "admin",
})
@bid_tracker_bp.route("/list/json")
@login_required
def list_json():
"""Return all bids as JSON for the AJAX list panel."""
status_filter = request.args.get("status", "")
try:
bids = get_all_bids(status_filter=status_filter)
except Exception as e:
return jsonify({"error": str(e)}), 500
def ser(row):
out = {}
for k, v in row.items():
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
else:
out[k] = v
return out
return jsonify([ser(b) for b in bids])
@bid_tracker_bp.route("/<int:bid_id>/updates/json", methods=["POST"])
@login_required
def add_update_json(bid_id):
"""Post a new update; return JSON so the page doesn't reload."""
user = session["user"]
content = request.form.get("content", "").strip()
if not content:
return jsonify({"error": "Update content cannot be empty."}), 400
try:
update_id = add_bid_update(user["id"], bid_id, content)
logger.info(f"Bid update id={update_id} posted on bid_id={bid_id} by user_id={user['id']}.")
return jsonify({"ok": True, "update_id": update_id})
except Exception as e:
logger.error(f"add_update_json error: {e}")
return jsonify({"error": str(e)}), 500
@bid_tracker_bp.route("/updates/<int:update_id>/delete/json", methods=["POST"])
@login_required
def delete_update_json(update_id):
"""Delete an update; return JSON so the page doesn't reload."""
user = session["user"]
try:
delete_bid_update(user["id"], update_id)
logger.info(f"Bid update id={update_id} deleted by user_id={user['id']}.")
return jsonify({"ok": True})
except Exception as e:
logger.error(f"delete_update_json error: {e}")
return jsonify({"error": str(e)}), 500
+93
View File
@@ -0,0 +1,93 @@
"""
routes/user_dashboard.py Regular user: shift check dashboard.
"""
import logging
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
from models import (
get_today_checks, mark_website_checked, unmark_website_checked,
update_check_note, get_user_active_shifts, get_website_credentials,
)
from utils.decorators import login_required
logger = logging.getLogger("routes.user_dashboard")
user_dashboard_bp = Blueprint("user_dashboard", __name__, url_prefix="/dashboard")
@user_dashboard_bp.route("/")
@login_required
def my_shifts():
user_id = session["user"]["id"]
try:
sites = get_today_checks(user_id)
shifts = get_user_active_shifts(user_id)
except Exception as e:
logger.error(f"my_shifts error: {e}")
sites, shifts = [], []
checked = sum(1 for s in sites if s.get("check_id"))
total = len(sites)
pct = round(checked * 100 / total) if total else 0
return render_template("user/dashboard.html",
sites=sites, shifts=shifts,
checked=checked, total=total, pct=pct)
@user_dashboard_bp.route("/check/<int:website_id>", methods=["POST"])
@login_required
def check_site(website_id):
user_id = session["user"]["id"]
user_note = request.form.get("user_note", "").strip()
try:
mark_website_checked(user_id, website_id, user_note)
flash("Site marked as checked.", "success")
logger.info(f"User {user_id} checked website {website_id}.")
except Exception as e:
logger.error(f"check_site error: {e}")
flash(f"Error: {e}", "danger")
return redirect(url_for("user_dashboard.my_shifts"))
@user_dashboard_bp.route("/uncheck/<int:website_id>", methods=["POST"])
@login_required
def uncheck_site(website_id):
user_id = session["user"]["id"]
try:
unmark_website_checked(user_id, website_id)
flash("Check removed.", "info")
logger.info(f"User {user_id} unchecked website {website_id}.")
except Exception as e:
logger.error(f"uncheck_site error: {e}")
flash(f"Error: {e}", "danger")
return redirect(url_for("user_dashboard.my_shifts"))
@user_dashboard_bp.route("/note/<int:website_id>", methods=["POST"])
@login_required
def update_note(website_id):
user_id = session["user"]["id"]
user_note = request.form.get("user_note", "").strip()
try:
update_check_note(user_id, website_id, user_note)
flash("Note updated.", "success")
except Exception as e:
logger.error(f"update_note error: {e}")
flash(f"Error: {e}", "danger")
return redirect(url_for("user_dashboard.my_shifts"))
@user_dashboard_bp.route("/credentials/<int:website_id>")
@login_required
def view_credentials(website_id):
"""JSON endpoint: return decrypted credentials for a site."""
try:
creds = get_website_credentials(website_id)
return jsonify([{
"label": c.get("label", ""),
"username": c.get("username", ""),
"password": c.get("password", ""),
} for c in creds])
except Exception as e:
logger.error(f"view_credentials error: {e}")
return jsonify({"error": str(e)}), 500
+790
View File
@@ -0,0 +1,790 @@
/* ============================================================
Website Checker style.css (Light Theme v2)
Aesthetic : Modern SaaS warm white, slate sidebar, airy
Fonts : DM Sans (UI) + DM Mono (data)
============================================================ */
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap');
/* ── Variables ─────────────────────────────────────────────── */
:root {
--bg: #f4f6f9;
--bg-white: #ffffff;
--bg-subtle: #f0f2f6;
--bg-hover: #eaecf2;
--sidebar-bg: #1e2535;
--sidebar-hover: #2a3347;
--sidebar-active: #313d57;
--sidebar-border: #2c3750;
--sidebar-text: #9eabc2;
--sidebar-head: #ffffff;
--sidebar-accent: #5b8ef4;
--border: #e2e5ed;
--border-strong: #c9cedb;
--accent: #3b72f6;
--accent-hover: #2d5fd4;
--accent-light: #eef2ff;
--accent-glow: rgba(59,114,246,0.15);
--text: #111827;
--text-secondary: #4b5563;
--text-muted: #9ca3af;
--text-placeholder:#b5bcc9;
--success: #16a34a;
--success-bg: #f0fdf4;
--success-border: #bbf7d0;
--success-text: #15803d;
--warning: #d97706;
--warning-bg: #fffbeb;
--warning-border: #fde68a;
--danger: #dc2626;
--danger-bg: #fef2f2;
--danger-border: #fecaca;
--info: #2563eb;
--info-bg: #eff6ff;
--info-border: #bfdbfe;
--sidebar-w: 240px;
--r-sm: 5px;
--r: 8px;
--r-lg: 12px;
--r-xl: 16px;
--shadow-xs: 0 1px 2px rgba(0,0,0,0.05);
--shadow-sm: 0 1px 4px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
--shadow: 0 4px 14px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.05);
--shadow-lg: 0 12px 32px rgba(0,0,0,0.11), 0 2px 8px rgba(0,0,0,0.06);
--t: 0.15s ease;
}
/* ── Reset ─────────────────────────────────────────────────── */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{font-size:15px;scroll-behavior:smooth}
body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);
line-height:1.6;min-height:100vh;-webkit-font-smoothing:antialiased}
a{color:var(--accent);text-decoration:none;transition:color var(--t)}
a:hover{color:var(--accent-hover)}
h1,h2,h3,h4,h5,h6{font-weight:600;line-height:1.3;color:var(--text)}
code,.mono{font-family:'DM Mono',monospace;font-size:.85em}
hr{border:none;border-top:1px solid var(--border)}
/* ── Layout ────────────────────────────────────────────────── */
.layout{display:flex;min-height:100vh}
/*
SIDEBAR
*/
.sidebar{
width:var(--sidebar-w);background:var(--sidebar-bg);
display:flex;flex-direction:column;
position:fixed;top:0;left:0;height:100vh;
overflow-y:auto;overflow-x:hidden;z-index:200;
border-right:1px solid var(--sidebar-border);
}
.sidebar::-webkit-scrollbar{width:3px}
.sidebar::-webkit-scrollbar-thumb{background:var(--sidebar-border);border-radius:99px}
.sidebar-brand{
display:flex;align-items:center;gap:10px;
padding:1.1rem 1rem .9rem;
border-bottom:1px solid var(--sidebar-border);
}
.brand-icon{
width:32px;height:32px;background:var(--accent);border-radius:var(--r-sm);
display:flex;align-items:center;justify-content:center;
font-size:.95rem;flex-shrink:0;
box-shadow:0 2px 8px rgba(59,114,246,.35);
}
.brand-name{font-size:.82rem;font-weight:700;letter-spacing:-.01em;color:#fff;line-height:1.2}
.brand-name span{display:block;font-size:.62rem;font-weight:400;color:var(--sidebar-text);
letter-spacing:.06em;text-transform:uppercase;margin-top:1px}
.sidebar-divider{border-color:var(--sidebar-border);margin:.25rem 0}
.nav-group-label{
font-size:.6rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;
color:var(--sidebar-text);opacity:.55;padding:.85rem 1rem .25rem;
}
.nav-item{
display:flex;align-items:center;gap:8px;
padding:.5rem 1rem;color:var(--sidebar-text);
font-size:.85rem;font-weight:500;
transition:all var(--t);position:relative;
}
.nav-item::before{
content:'';position:absolute;left:0;top:0;bottom:0;width:3px;
background:var(--sidebar-accent);border-radius:0 2px 2px 0;
opacity:0;transition:opacity var(--t);
}
.nav-item:hover{background:var(--sidebar-hover);color:#fff}
.nav-item.active{background:var(--sidebar-active);color:#fff}
.nav-item.active::before{opacity:1}
.sidebar-footer{
margin-top:auto;padding:.65rem;
border-top:1px solid var(--sidebar-border);
display:flex;flex-direction:column;gap:.35rem;
}
.user-info{padding:.35rem .25rem .5rem}
.user-name{font-size:.85rem;font-weight:600;color:#fff;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.user-role{font-size:.65rem;letter-spacing:.07em;text-transform:uppercase;
color:var(--sidebar-accent);font-weight:700;margin-top:1px}
.sidebar-btn{
display:block;padding:.42rem .7rem;border-radius:var(--r-sm);
font-size:.78rem;font-weight:500;text-align:center;transition:all var(--t);
}
.btn-ghost{color:var(--sidebar-text);border:1px solid var(--sidebar-border)}
.btn-ghost:hover{color:#fff;background:var(--sidebar-hover);border-color:var(--sidebar-hover)}
.btn-danger{color:#fca5a5;border:1px solid rgba(239,68,68,.25)}
.btn-danger:hover{background:rgba(239,68,68,.15);border-color:rgba(239,68,68,.4);color:#fca5a5}
/*
MAIN CONTENT
*/
.main-content{margin-left:var(--sidebar-w);flex:1;padding:1.75rem 2rem 3rem;min-width:0}
/* ── Page Header ────────────────────────────────────────── */
.page-header{
display:flex;align-items:center;justify-content:space-between;
flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;
}
.page-header h1{font-size:1.35rem;font-weight:700;letter-spacing:-.02em}
.page-subtitle{font-size:.78rem;color:var(--text-muted);margin-top:2px}
/*
USER DASHBOARD PROGRESS STRIP
*/
.progress-strip{
display:flex;align-items:center;gap:1rem;
background:var(--bg-white);border:1px solid var(--border);
border-radius:var(--r-lg);padding:1rem 1.25rem;
box-shadow:var(--shadow-sm);margin-bottom:1rem;
}
.progress-strip-bar{
flex:1;height:8px;background:var(--bg-subtle);
border-radius:999px;overflow:hidden;border:1px solid var(--border);
}
.progress-strip-fill{
height:100%;border-radius:999px;transition:width .5s ease;
background:var(--accent);
}
.progress-strip-fill.fill-success{background:var(--success)}
.progress-strip-fill.fill-warning{background:var(--warning)}
.progress-strip-fill.fill-danger {background:var(--danger)}
.progress-strip-label{font-size:.875rem;color:var(--text-secondary);white-space:nowrap}
/* ── Action Bar ─────────────────────────────────────────── */
.action-bar{
display:flex;align-items:center;gap:.65rem;flex-wrap:wrap;
margin-bottom:1rem;
}
.search-wrap{
position:relative;flex:1;min-width:180px;max-width:320px;
}
.search-wrap .search-icon{
position:absolute;right:9px;top:50%;transform:translateY(-50%);
font-size:.85rem;pointer-events:none;
}
.search-wrap input{
width:100%;padding:.45rem 2.2rem .45rem .75rem;
background:var(--bg-white);border:1px solid var(--border-strong);
border-radius:var(--r-sm);font-family:'DM Sans',sans-serif;
font-size:.85rem;color:var(--text);outline:none;
transition:border-color var(--t),box-shadow var(--t);
}
.search-wrap input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow)}
.search-wrap input::placeholder{color:var(--text-placeholder)}
/*
SITE CARDS
*/
.site-list{display:flex;flex-direction:column;gap:.5rem}
.site-card{
display:block;
background:var(--bg-white);border:1px solid var(--border);
border-radius:var(--r-lg);overflow:hidden;
box-shadow:var(--shadow-sm);transition:box-shadow var(--t),border-color var(--t);
}
.site-card:hover{box-shadow:var(--shadow);border-color:var(--border-strong)}
.site-card.is-checked{
border-color:var(--success-border);background:#fafffe;
}
.site-card.is-checked:hover{border-color:#86efac}
/* Left — bulk checkbox */
.sc-select{flex-shrink:0}
.sc-select input[type=checkbox]{
width:15px;height:15px;accent-color:var(--accent);cursor:pointer;
}
.sc-select input:disabled{opacity:.3;cursor:default}
/* Status circle */
.sc-status{flex-shrink:0}
.status-dot{
display:flex;align-items:center;justify-content:center;
width:26px;height:26px;border-radius:50%;
font-size:.8rem;font-weight:700;
}
.dot-checked{background:var(--success);color:#fff;box-shadow:0 1px 4px rgba(22,163,74,.3)}
.dot-unchecked{background:var(--bg-subtle);color:var(--text-muted);border:2px solid var(--border-strong)}
/* Body */
.sc-body{flex:1;min-width:0}
.sc-title-row{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin-bottom:2px}
.sc-name{font-weight:700;font-size:.95rem;color:var(--accent);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.sc-name:hover{color:var(--accent-hover);text-decoration:underline}
.sc-url{font-family:'DM Mono',monospace;font-size:.72rem;color:var(--text-muted);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}
.sc-meta{display:flex;flex-wrap:wrap;gap:.4rem .75rem;margin-top:2px}
.sc-meta-item{font-size:.75rem;color:var(--text-muted)}
.sc-meta-item.text-success{color:var(--success-text)}
.sc-meta-item.text-accent{color:var(--accent)}
.note-tag{
background:var(--warning-bg);color:var(--warning);
border:1px solid var(--warning-border);
border-radius:var(--r-sm);padding:0 .4rem;
}
/* Actions */
.sc-actions{display:flex;align-items:center;gap:.4rem;flex-shrink:0;flex-wrap:wrap;margin-left:auto}
.checked-indicator{pointer-events:none;opacity:.85}
/* Health dot */
.sc-health{flex-shrink:0}
.health-dot{color:var(--text-muted);font-size:.65rem;cursor:default;
transition:color .4s}
/* ── Site card note row ────────────────────────────────────── */
.sc-row1{display:flex;align-items:center;gap:.75rem;padding:.85rem 1rem}
.sc-row2{
padding:.4rem 1rem .55rem 4.2rem;
border-top:1px dashed var(--border);
}
.sc-note-row{
font-size:.875rem;color:var(--warning);
background:var(--warning-bg);border:1px solid var(--warning-border);
border-radius:var(--r-sm);padding:.2rem .6rem;
display:inline-block;line-height:1.5;
}
/*
CARDS
*/
.card{
background:var(--bg-white);border:1px solid var(--border);
border-radius:var(--r-lg);padding:1.4rem;box-shadow:var(--shadow-sm);
}
.card-header{
display:flex;align-items:center;justify-content:space-between;
margin-bottom:1.2rem;padding-bottom:.8rem;border-bottom:1px solid var(--border);
}
.card-title{font-size:.9rem;font-weight:600;color:var(--text)}
.card-grid{
display:grid;grid-template-columns:repeat(auto-fit,minmax(185px,1fr));
gap:1rem;margin-bottom:1.6rem;
}
/* KPI stat cards */
.stat-card,.kpi-card{
background:var(--bg-white);border:1px solid var(--border);
border-radius:var(--r-lg);padding:1.2rem 1.4rem 1rem;
box-shadow:var(--shadow-sm);transition:box-shadow var(--t),transform var(--t);
position:relative;overflow:hidden;
}
.stat-card:hover,.kpi-card:hover{box-shadow:var(--shadow);transform:translateY(-1px)}
.stat-card::after,.kpi-card::after{
content:'';position:absolute;bottom:0;left:0;right:0;height:3px;
background:var(--border);border-radius:0 0 var(--r-lg) var(--r-lg);
}
.stat-card.success::after,.kpi-card.kpi-success::after{background:var(--success)}
.stat-card.warning::after,.kpi-card.kpi-warning::after{background:var(--warning)}
.stat-card.danger::after ,.kpi-card.kpi-danger::after {background:var(--danger)}
.stat-card.accent::after ,.kpi-card.kpi-accent::after {background:var(--accent)}
.stat-label,.kpi-label{
font-size:.68rem;font-weight:700;letter-spacing:.07em;
text-transform:uppercase;color:var(--text-muted);margin-bottom:.5rem;
}
.stat-value,.kpi-value{
font-family:'DM Mono',monospace;font-size:2rem;font-weight:500;
line-height:1;color:var(--text);
}
.stat-card.success .stat-value,.kpi-card.kpi-success .kpi-value{color:var(--success)}
.stat-card.warning .stat-value,.kpi-card.kpi-warning .kpi-value{color:var(--warning)}
.stat-card.danger .stat-value,.kpi-card.kpi-danger .kpi-value{color:var(--danger)}
.stat-card.accent .stat-value,.kpi-card.kpi-accent .kpi-value{color:var(--accent)}
/* alias grid for admin dashboard */
.kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(175px,1fr));gap:1rem;margin-bottom:1.6rem}
/*
TABLES
*/
.table-container{overflow-x:auto;border-radius:var(--r);border:1px solid var(--border);background:var(--bg-white)}
table{width:100%;border-collapse:collapse;font-size:.875rem}
thead th{
background:var(--bg-subtle);padding:.65rem 1rem;text-align:left;
font-size:.68rem;font-weight:700;letter-spacing:.07em;text-transform:uppercase;
color:var(--text-muted);border-bottom:1px solid var(--border);white-space:nowrap;
}
tbody tr{border-bottom:1px solid var(--border);transition:background var(--t)}
tbody tr:last-child{border-bottom:none}
tbody tr:hover{background:var(--bg-subtle)}
tbody td{padding:.72rem 1rem;color:var(--text-secondary);vertical-align:middle}
tbody td:first-child{color:var(--text);font-weight:500}
.table-sm td,.table-sm th{padding:.45rem .75rem}
.td-mono{font-family:'DM Mono',monospace;font-size:.78rem}
/*
BUTTONS
*/
.btn{
display:inline-flex;align-items:center;gap:5px;
padding:.48rem .95rem;border-radius:var(--r-sm);
font-family:'DM Sans',sans-serif;font-size:.8rem;font-weight:600;
cursor:pointer;border:1px solid transparent;
transition:all var(--t);text-decoration:none;white-space:nowrap;
}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.btn-primary{background:var(--accent);color:#fff;border-color:var(--accent);
box-shadow:0 1px 3px rgba(59,114,246,.3)}
.btn-primary:hover{background:var(--accent-hover);border-color:var(--accent-hover);color:#fff;
box-shadow:0 3px 8px rgba(59,114,246,.35)}
.btn-secondary{background:var(--bg-white);color:var(--text-secondary);border-color:var(--border-strong)}
.btn-secondary:hover{background:var(--bg-subtle);color:var(--text);border-color:var(--border-strong)}
.btn-success{background:var(--success-bg);color:var(--success);border-color:var(--success-border)}
.btn-success:hover{background:#dcfce7;color:var(--success)}
.btn-danger{background:var(--danger-bg);color:var(--danger);border-color:var(--danger-border)}
.btn-danger:hover{background:#fee2e2;color:var(--danger)}
.btn-warning{background:var(--warning-bg);color:var(--warning);border-color:var(--warning-border)}
.btn-warning:hover{background:#fef3c7}
.btn-info{background:var(--info-bg);color:var(--info);border-color:var(--info-border)}
.btn-info:hover{background:#dbeafe}
/* ghost used in sidebar only */
.btn.btn-ghost-inline{
background:transparent;color:var(--text-muted);border-color:var(--border-strong);
}
.btn.btn-ghost-inline:hover{background:var(--bg-hover);color:var(--text)}
.btn-sm{padding:.28rem .6rem;font-size:.75rem}
.btn-lg{padding:.62rem 1.3rem;font-size:.9rem}
/*
BADGES
*/
.badge{
display:inline-flex;align-items:center;padding:.18rem .5rem;
border-radius:999px;font-size:.67rem;font-weight:700;
letter-spacing:.04em;text-transform:uppercase;
}
.badge-success{background:var(--success-bg);color:var(--success);border:1px solid var(--success-border)}
.badge-danger {background:var(--danger-bg); color:var(--danger); border:1px solid var(--danger-border)}
.badge-warning{background:var(--warning-bg);color:var(--warning);border:1px solid var(--warning-border)}
.badge-info {background:var(--info-bg); color:var(--info); border:1px solid var(--info-border)}
.badge-accent {background:var(--accent-light);color:var(--accent);border:1px solid #c7d7fd}
.badge-muted {background:var(--bg-subtle); color:var(--text-muted);border:1px solid var(--border)}
.badge-neutral{background:var(--bg-subtle); color:var(--text-muted);border:1px solid var(--border)}
.badge-warn {background:var(--warning-bg);color:var(--warning);border:1px solid var(--warning-border)}
.badge-sm {font-size:.62rem;padding:.12rem .4rem}
/*
FORMS
*/
.form-group{margin-bottom:1rem}
label{display:block;font-size:.74rem;font-weight:600;color:var(--text-secondary);margin-bottom:.3rem}
input[type=text],input[type=email],input[type=password],input[type=url],
input[type=number],input[type=search],input[type=date],input[type=time],
select,textarea,.form-control{
width:100%;padding:.52rem .78rem;
background:var(--bg-white);border:1px solid var(--border-strong);
border-radius:var(--r-sm);color:var(--text);
font-family:'DM Sans',sans-serif;font-size:.875rem;
transition:border-color var(--t),box-shadow var(--t);outline:none;
}
input:focus,select:focus,textarea:focus,.form-control:focus{
border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow);
}
input::placeholder,textarea::placeholder{color:var(--text-placeholder)}
select{cursor:pointer}
textarea{resize:vertical;min-height:88px}
.form-check{display:flex;align-items:center;gap:7px;margin-bottom:.4rem}
.form-check input[type=checkbox],.form-check input[type=radio]{
width:15px;height:15px;accent-color:var(--accent);cursor:pointer;flex-shrink:0;
}
.form-check label{font-size:.875rem;font-weight:400;letter-spacing:0;
color:var(--text-secondary);cursor:pointer;margin:0}
.form-hint{font-size:.73rem;color:var(--text-muted);margin-top:.2rem}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
.form-label{display:block;font-size:.74rem;font-weight:600;color:var(--text-secondary);margin-bottom:.3rem}
.input-sm{padding:.3rem .55rem;font-size:.8rem}
/*
TABS
*/
.tabs,.tab-bar{display:flex;border-bottom:2px solid var(--border);margin-bottom:1.4rem}
.tab-btn,.tab-item{
padding:.55rem 1.1rem;font-size:.8rem;font-weight:600;
color:var(--text-muted);cursor:pointer;border:none;
background:transparent;border-bottom:2px solid transparent;
margin-bottom:-2px;transition:all var(--t);
text-decoration:none;display:inline-block;
}
.tab-btn:hover,.tab-item:hover{color:var(--text)}
.tab-btn.active,.tab-item.active{color:var(--accent);border-bottom-color:var(--accent)}
.tab-panel{display:none}
.tab-panel.active{display:block}
/*
PROGRESS (generic)
*/
.progress-wrap{display:flex;align-items:center;gap:10px}
.progress-bar-bg,.progress{
flex:1;height:7px;background:var(--bg-subtle);
border-radius:999px;overflow:hidden;border:1px solid var(--border);
}
.progress-bar-fill,.progress-bar{
height:100%;border-radius:999px;background:var(--accent);transition:width .5s ease;
}
.progress-bar-fill.success,.progress-success{background:var(--success)}
.progress-bar-fill.warning,.progress-warn {background:var(--warning)}
.progress-bar-fill.danger ,.progress-danger{background:var(--danger)}
.progress-bar-fill.accent ,.progress-accent{background:var(--accent)}
.progress-label{font-family:'DM Mono',monospace;font-size:.74rem;color:var(--text-muted);min-width:38px;text-align:right}
.progress-lg{height:10px}
/*
FLASH MESSAGES
*/
.flash-container{margin-bottom:1.4rem;display:flex;flex-direction:column;gap:.45rem}
.alert{
padding:.7rem 1rem;border-radius:var(--r);font-size:.875rem;font-weight:500;
border:1px solid;display:flex;align-items:center;gap:8px;
}
.alert-success{background:var(--success-bg);color:var(--success);border-color:var(--success-border)}
.alert-danger,.alert-error{background:var(--danger-bg);color:var(--danger);border-color:var(--danger-border)}
.alert-warning{background:var(--warning-bg);color:var(--warning);border-color:var(--warning-border)}
.alert-info {background:var(--info-bg); color:var(--info); border-color:var(--info-border)}
/*
MODALS
.modal-overlay = full-screen backdrop (flex container)
.modal = inner dialog box ONLY (never a backdrop)
.modal-dialog = alias for .modal inner box (old templates)
*/
/* Backdrop */
.modal-overlay{
display:none;position:fixed;inset:0;
background:rgba(17,24,39,.45);backdrop-filter:blur(3px);
z-index:500;align-items:center;justify-content:center;padding:1rem;
}
.modal-overlay.open{display:flex}
/* Inner dialog box — child of .modal-overlay */
.modal-overlay > .modal,
.modal-overlay > .modal-dialog{
background:var(--bg-white);border:1px solid var(--border);
border-radius:var(--r-xl);width:100%;max-width:560px;
max-height:90vh;overflow-y:auto;box-shadow:var(--shadow-lg);
animation:modal-in .18s ease;
}
@keyframes modal-in{
from{opacity:0;transform:translateY(-8px) scale(.98)}
to {opacity:1;transform:translateY(0) scale(1)}
}
.modal-header{
display:flex;align-items:center;justify-content:space-between;
padding:1.15rem 1.4rem .9rem;border-bottom:1px solid var(--border);
}
.modal-title{font-size:.95rem;font-weight:600}
.modal-close{
background:var(--bg-subtle);border:none;color:var(--text-muted);
font-size:.95rem;cursor:pointer;padding:.28rem .5rem;
border-radius:var(--r-sm);transition:all var(--t);line-height:1;
}
.modal-close:hover{background:var(--bg-hover);color:var(--text)}
.modal-body{padding:1.15rem 1.4rem}
.modal-footer{
display:flex;justify-content:flex-end;gap:.5rem;
padding:.9rem 1.4rem;border-top:1px solid var(--border);
background:var(--bg-subtle);border-radius:0 0 var(--r-xl) var(--r-xl);
}
/*
FILTER BAR
*/
.filter-bar{
display:flex;flex-wrap:wrap;gap:.65rem;align-items:flex-end;
margin-bottom:1.2rem;padding:.9rem 1.1rem;
background:var(--bg-white);border:1px solid var(--border);
border-radius:var(--r-lg);box-shadow:var(--shadow-sm);
}
.filter-bar .form-group{margin:0;min-width:145px}
.filter-bar label{margin-bottom:.28rem}
/*
LOGIN PAGE
*/
.login-wrap{
display:flex;align-items:center;justify-content:center;
min-height:100vh;
background:linear-gradient(135deg,#eef2ff 0%,#fafbff 55%,#f4f6f9 100%);
padding:1rem;
}
.login-box{
width:100%;max-width:390px;background:var(--bg-white);
border:1px solid var(--border);border-radius:var(--r-xl);
padding:2.4rem 2.1rem;box-shadow:var(--shadow-lg);
}
.login-logo{text-align:center;margin-bottom:1.8rem}
.login-logo .brand-icon{
width:52px;height:52px;background:var(--accent);border-radius:var(--r-lg);
display:inline-flex;align-items:center;justify-content:center;
font-size:1.5rem;margin-bottom:.8rem;
box-shadow:0 4px 14px rgba(59,114,246,.3);
}
.login-logo h1{font-size:1.15rem;font-weight:700;letter-spacing:-.02em;color:var(--text)}
.login-logo p{font-size:.78rem;color:var(--text-muted);margin-top:2px}
.login-box .btn-primary{width:100%;justify-content:center;padding:.6rem 1rem;
font-size:.875rem;margin-top:.2rem}
/*
CREDENTIALS DISPLAY (in modal)
*/
.cred-label-heading{
font-size:.72rem;font-weight:700;letter-spacing:.07em;text-transform:uppercase;
color:var(--accent);margin-bottom:.5rem;
}
.cred-row{
display:flex;align-items:center;gap:.6rem;
padding:.45rem 0;border-bottom:1px solid var(--border);font-size:.85rem;
}
.cred-row:last-child{border-bottom:none}
.cred-key{
font-size:.7rem;font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--text-muted);min-width:72px;flex-shrink:0;
}
.cred-val{
font-family:'DM Mono',monospace;font-size:.82rem;color:var(--text);
flex:1;word-break:break-all;
}
.pw-mask{
filter:blur(4px);user-select:none;cursor:pointer;
transition:filter .2s;
}
/*
DYNAMIC CREDENTIAL ROWS (website admin form)
*/
.credential-entry{
display:grid;grid-template-columns:1fr 1fr 1fr auto;
gap:.5rem;align-items:end;padding:.75rem;
background:var(--bg-subtle);border:1px solid var(--border);
border-radius:var(--r);margin-bottom:.5rem;
}
/*
AI RESULT BOX
*/
.ai-result-box{
background:var(--bg-subtle);border:1px solid var(--border);
border-radius:var(--r);padding:1.2rem;
font-size:.875rem;line-height:1.8;white-space:pre-wrap;
color:var(--text-secondary);min-height:120px;
}
.ai-streaming{border-left:3px solid var(--accent)}
/*
EMPTY STATE
*/
.empty-state{
display:flex;flex-direction:column;align-items:center;
justify-content:center;padding:3rem 1rem;text-align:center;
color:var(--text-muted);
}
.empty-icon{font-size:2.5rem;margin-bottom:.75rem}
.empty-state p{font-size:.9rem}
/*
LOG BADGES / ROWS
*/
.badge-log-info {background:var(--info-bg); color:var(--info); border:1px solid var(--info-border)}
.badge-log-warning {background:var(--warning-bg);color:var(--warning);border:1px solid var(--warning-border)}
.badge-log-error {background:var(--danger-bg); color:var(--danger); border:1px solid var(--danger-border)}
.badge-log-critical{background:#fff1f2;color:#9f1239;border:1px solid #fecdd3}
.badge-log-debug {background:var(--bg-subtle); color:var(--text-muted);border:1px solid var(--border)}
.log-error td,.log-critical td{background:#fff8f8}
.action-badge{
display:inline-block;padding:.14rem .45rem;background:var(--accent-light);
color:var(--accent);border-radius:var(--r-sm);font-size:.7rem;font-weight:700;
letter-spacing:.04em;font-family:'DM Mono',monospace;
}
/*
UTILITIES
*/
.text-muted {color:var(--text-muted)!important}
.text-success {color:var(--success)!important}
.text-danger {color:var(--danger)!important}
.text-warning {color:var(--warning)!important}
.text-info {color:var(--info)!important}
.text-accent {color:var(--accent)!important}
.text-sm {font-size:.8rem}
.text-xs {font-size:.72rem}
.text-right {text-align:right}
.text-center {text-align:center}
.nowrap {white-space:nowrap}
.mt-1{margin-top:.5rem}.mt-2{margin-top:1rem}.mt-3{margin-top:1.5rem}
.mt-4{margin-top:2rem}
.mb-1{margin-bottom:.5rem}.mb-2{margin-bottom:1rem}.mb-3{margin-bottom:1.5rem}
.flex{display:flex}
.flex-between{display:flex;align-items:center;justify-content:space-between}
.flex-end{display:flex;justify-content:flex-end}
.gap-1{gap:.5rem}.gap-2{gap:1rem}
.items-center{align-items:center}
.flex-wrap{flex-wrap:wrap}
.w-full{width:100%}
.hidden{display:none!important}
.divider{border:none;border-top:1px solid var(--border);margin:1.25rem 0}
.tag{
display:inline-block;padding:.14rem .48rem;background:var(--bg-subtle);
border:1px solid var(--border);border-radius:var(--r-sm);
font-size:.72rem;color:var(--text-muted);font-family:'DM Mono',monospace;
}
/* inline-check-form used in old template */
.inline-check-form{display:inline-flex;gap:.4rem;align-items:center}
/*
SCROLLBAR
*/
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-track{background:var(--bg-subtle)}
::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:999px}
::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}
/*
RESPONSIVE
*/
@media(max-width:960px){
:root{--sidebar-w:62px}
.brand-name,.nav-group-label,.user-info,.sidebar-btn{display:none}
.sidebar-brand{justify-content:center;padding:.9rem 0}
.brand-icon{margin:0 auto}
.nav-item{justify-content:center;padding:.65rem 0;gap:0}
.nav-item::before{display:none}
.sidebar-footer{align-items:center}
.main-content{padding:1.1rem .9rem 2rem}
}
@media(max-width:700px){
.sc-actions{flex-direction:column;align-items:flex-start}
.sc-url{display:none}
.form-row{grid-template-columns:1fr}
.card-grid,.kpi-grid{grid-template-columns:1fr 1fr}
.filter-bar{flex-direction:column}
.credential-entry{grid-template-columns:1fr 1fr}
.page-header{flex-direction:column;align-items:flex-start}
}
/*
ADMIN PAGE MISSING CLASSES
*/
/* btn-ghost in main content (not sidebar) */
.btn.btn-ghost{
background:var(--bg-white);color:var(--text-secondary);
border:1px solid var(--border-strong);
}
.btn.btn-ghost:hover{background:var(--bg-subtle);color:var(--text)}
/* modal-lg — wider modal for websites/shifts */
.modal-overlay > .modal-dialog.modal-lg,
.modal-overlay > .modal.modal-lg{max-width:720px}
/* flex helpers used inside modals */
.flex-1{flex:1}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
.form-row .flex-1{min-width:0}
/* table-hover */
.table-hover tbody tr:hover td{background:var(--bg-subtle)}
/* url-cell — truncate long URLs in table */
.url-cell{max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* log-message — wrap long log lines */
.log-message{word-break:break-all;font-size:.8rem;color:var(--text-secondary)}
/* credential rows in website form */
.cred-rows{display:flex;flex-direction:column;gap:.5rem;margin-top:.5rem}
.cred-row{display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:.5rem;align-items:end}
/* day-checkboxes for shift form */
.day-checkboxes{display:flex;flex-wrap:wrap;gap:.5rem}
.day-check{
display:inline-flex;align-items:center;gap:.35rem;
padding:.3rem .65rem;background:var(--bg-subtle);
border:1px solid var(--border-strong);border-radius:var(--r-sm);
font-size:.8rem;cursor:pointer;transition:all var(--t);
}
.day-check:hover{background:var(--accent-light);border-color:var(--accent)}
.day-check input{accent-color:var(--accent);cursor:pointer}
/* checkbox-list for user/site assignment */
.checkbox-list{
display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));
gap:.35rem;max-height:160px;overflow-y:auto;
padding:.5rem;background:var(--bg-subtle);
border:1px solid var(--border);border-radius:var(--r-sm);
}
.checkbox-list label{
display:flex;align-items:center;gap:.4rem;
font-size:.82rem;font-weight:400;color:var(--text-secondary);
cursor:pointer;padding:.15rem .25rem;border-radius:var(--r-sm);
transition:background var(--t);
}
.checkbox-list label:hover{background:var(--bg-hover)}
.checkbox-list input{accent-color:var(--accent);flex-shrink:0}
/* actions column */
td.actions{white-space:nowrap}
/* ── Settings page ─────────────────────────────────────── */
.settings-grid{
display:grid;
grid-template-columns:repeat(auto-fit,minmax(340px,480px));
gap:1.25rem;align-items:start;
}
/* Constrain form inputs inside settings cards */
.settings-grid .form-control{max-width:100%}
.settings-grid .modal-body{padding:1.1rem 1.4rem}
.settings-grid .modal-footer{
padding:.9rem 1.4rem;border-top:1px solid var(--border);
display:flex;justify-content:flex-end;
}
/* ── Reports filter bar — constrain input widths ────────── */
.filter-bar .form-control{
width:auto;min-width:120px;max-width:220px;
}
.filter-bar input[type=date]{max-width:160px}
.filter-bar input[type=search]{max-width:200px}
.filter-bar select{max-width:180px}
+209
View File
@@ -0,0 +1,209 @@
/* ============================================================
Website Checker app.js
Global utilities: modals, tabs, auto-dismiss alerts, helpers
============================================================ */
'use strict';
/* ── Modal helpers ─────────────────────────────────────────── */
function openModal(id) {
const el = document.getElementById(id);
if (el) {
el.classList.add('open');
document.body.style.overflow = 'hidden';
const firstInput = el.querySelector('input:not([type=hidden]), select, textarea');
if (firstInput) setTimeout(() => firstInput.focus(), 120);
}
}
function closeModal(id) {
const el = document.getElementById(id);
if (el) {
el.classList.remove('open');
document.body.style.overflow = '';
}
}
// Close modal when clicking the backdrop
document.addEventListener('click', (e) => {
if (e.target.classList.contains('modal-overlay')) {
e.target.classList.remove('open');
document.body.style.overflow = '';
}
});
// Close modal on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.open').forEach(m => {
m.classList.remove('open');
document.body.style.overflow = '';
});
}
});
/* ── Tab switching ─────────────────────────────────────────── */
function switchTab(tabId, groupId) {
const group = groupId
? document.getElementById(groupId)
: document.body;
group.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
group.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
const btn = group.querySelector(`[data-tab="${tabId}"]`);
const panel = document.getElementById(tabId);
if (btn) btn.classList.add('active');
if (panel) panel.classList.add('active');
}
// Wire up tab buttons declared in HTML
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.tab-btn[data-tab]').forEach(btn => {
btn.addEventListener('click', () => {
const tabId = btn.dataset.tab;
const groupId = btn.dataset.group || null;
switchTab(tabId, groupId);
});
});
});
/* ── Auto-dismiss flash messages ───────────────────────────── */
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.alert').forEach(alert => {
setTimeout(() => {
alert.style.transition = 'opacity 0.5s';
alert.style.opacity = '0';
setTimeout(() => alert.remove(), 500);
}, 5000);
});
});
/* ── Confirm-delete helper ─────────────────────────────────── */
function confirmDelete(message, formOrUrl) {
if (!confirm(message || 'Are you sure you want to delete this item?')) return false;
if (typeof formOrUrl === 'string') {
window.location.href = formOrUrl;
} else if (formOrUrl && formOrUrl.submit) {
formOrUrl.submit();
}
return true;
}
/* ── Password visibility toggle ────────────────────────────── */
function togglePasswordVisibility(inputId, btn) {
const input = document.getElementById(inputId);
if (!input) return;
if (input.type === 'password') {
input.type = 'text';
if (btn) btn.textContent = '🙈';
} else {
input.type = 'password';
if (btn) btn.textContent = '👁';
}
}
/* ── Dynamic credential rows (website form) ────────────────── */
function addCredentialRow(containerId) {
const container = document.getElementById(containerId);
if (!container) return;
const index = container.querySelectorAll('.credential-entry').length;
const row = document.createElement('div');
row.className = 'credential-entry';
row.innerHTML = `
<div class="form-group">
<label>Label</label>
<input type="text" name="cred_label[]" placeholder="e.g. Admin Login">
</div>
<div class="form-group">
<label>Username</label>
<input type="text" name="cred_username[]" placeholder="username">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="cred_password[]" placeholder="••••••••">
</div>
<div class="form-group">
<label>&nbsp;</label>
<button type="button" class="btn btn-danger btn-sm" onclick="this.closest('.credential-entry').remove()"></button>
</div>
`;
container.appendChild(row);
}
/* ── Copy to clipboard ─────────────────────────────────────── */
function copyToClipboard(text, btn) {
navigator.clipboard.writeText(text).then(() => {
const original = btn ? btn.textContent : null;
if (btn) { btn.textContent = '✓ Copied'; btn.disabled = true; }
setTimeout(() => {
if (btn) { btn.textContent = original; btn.disabled = false; }
}, 2000);
}).catch(() => {
alert('Copy failed. Please copy manually.');
});
}
/* ── Progress bar color logic ──────────────────────────────── */
function colorProgressBar(fill, pct) {
fill.classList.remove('success', 'warning', 'danger');
if (pct >= 100) fill.classList.add('success');
else if (pct >= 50) fill.classList.add('warning');
else fill.classList.add('danger');
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.progress-bar-fill[data-pct]').forEach(fill => {
const pct = parseInt(fill.dataset.pct, 10);
fill.style.width = pct + '%';
colorProgressBar(fill, pct);
});
});
/* ── Session timeout warning ───────────────────────────────── */
(function sessionWarning() {
const WARN_BEFORE_MS = 5 * 60 * 1000; // warn 5 min before expiry
const SESSION_MS = 30 * 60 * 1000; // match Flask SESSION_LIFETIME
let warningTimer = null;
let expireTimer = null;
function resetTimers() {
clearTimeout(warningTimer);
clearTimeout(expireTimer);
warningTimer = setTimeout(() => {
if (confirm('Your session will expire in 5 minutes. Click OK to stay logged in.')) {
fetch('/auth/ping', { credentials: 'same-origin' }).catch(() => {});
resetTimers();
}
}, SESSION_MS - WARN_BEFORE_MS);
expireTimer = setTimeout(() => {
alert('Your session has expired. You will be redirected to login.');
window.location.href = '/auth/login';
}, SESSION_MS);
}
['click', 'keydown', 'mousemove', 'scroll', 'touchstart'].forEach(evt => {
document.addEventListener(evt, () => resetTimers(), { passive: true });
});
resetTimers();
})();
/* ── Generic fetch-based form submit (JSON response) ───────── */
async function submitJson(url, data, method = 'POST') {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify(data),
credentials: 'same-origin',
});
return res.json();
}
/* ── CSRF helper (reads meta tag set by Flask) ─────────────── */
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.content : '';
}
+68
View File
@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block title %}Dashboard — Website Checker{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Dashboard</h1>
<span class="text-muted" id="today-date"></span>
</div>
<!-- KPI Cards -->
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-value">{{ stats.total_users }}</div>
<div class="kpi-label">Total Users</div>
</div>
<div class="kpi-card kpi-accent">
<div class="kpi-value">{{ stats.active_today }}</div>
<div class="kpi-label">Active Today</div>
</div>
<div class="kpi-card">
<div class="kpi-value">{{ stats.total_sites }}</div>
<div class="kpi-label">Total Sites</div>
</div>
</div>
<!-- Per-user completion table -->
<div class="card mt-4">
<div class="card-header">
<h2 class="card-title">Today's Completion</h2>
<button class="btn btn-ghost btn-sm" onclick="location.reload()">↻ Refresh</button>
</div>
<table class="table">
<thead>
<tr>
<th>User</th>
<th>Checked</th>
<th>Total</th>
<th style="width:200px">Progress</th>
<th>%</th>
</tr>
</thead>
<tbody>
{% for u in stats.user_stats %}
<tr>
<td>{{ u.full_name }}</td>
<td>{{ u.checked_count }}</td>
<td>{{ u.total_sites }}</td>
<td>
<div class="progress">
<div class="progress-bar {% if (u.pct_complete or 0) == 100 %}progress-success{% elif (u.pct_complete or 0) >= 50 %}progress-warn{% else %}progress-danger{% endif %}"
style="width: {{ u.pct_complete or 0 }}%"></div>
</div>
</td>
<td><strong>{{ u.pct_complete or 0 }}%</strong></td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted">No user data for today.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
document.getElementById('today-date').textContent =
new Date().toLocaleDateString('en-US', {weekday:'long', year:'numeric', month:'long', day:'numeric'});
// Auto-refresh every 60 seconds
setTimeout(() => location.reload(), 60000);
</script>
{% endblock %}
+83
View File
@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}Logs — Website Checker{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Logs</h1>
</div>
<!-- Tab bar -->
<div class="tab-bar">
<a class="tab-item {{ 'active' if tab == 'activity' }}"
href="{{ url_for('admin_logs.logs', tab='activity') }}">📋 Activity Log</a>
<a class="tab-item {{ 'active' if tab == 'app' }}"
href="{{ url_for('admin_logs.logs', tab='app') }}">🖥 App Log</a>
</div>
<!-- Search / filter bar -->
<form class="filter-bar" method="get">
<input type="hidden" name="tab" value="{{ tab }}">
<input class="form-control" name="search" placeholder="Search…" value="{{ search }}">
{% if tab == 'app' %}
<select class="form-control" name="level">
<option value="">All Levels</option>
{% for lvl in ['DEBUG','INFO','WARNING','ERROR','CRITICAL'] %}
<option {{ 'selected' if level_filter == lvl }}>{{ lvl }}</option>
{% endfor %}
</select>
{% endif %}
<select class="form-control" name="limit">
{% for n in [100, 200, 500] %}
<option value="{{ n }}" {{ 'selected' if limit == n }}>{{ n }} rows</option>
{% endfor %}
</select>
<button class="btn btn-ghost" type="submit">Filter</button>
</form>
{% if tab == 'activity' %}
<div class="card mt-2">
<table class="table table-sm">
<thead><tr><th>Time</th><th>User</th><th>Action</th><th>Entity</th><th>Detail</th></tr></thead>
<tbody>
{% for row in activity_rows %}
<tr>
<td class="text-muted nowrap">{{ row.created_at.strftime('%Y-%m-%d %H:%M:%S') if row.created_at else '—' }}</td>
<td>{{ row.username or '—' }}</td>
<td><code class="action-badge">{{ row.action }}</code></td>
<td class="text-muted">{{ row.entity or '' }} {% if row.entity_id %}#{{ row.entity_id }}{% endif %}</td>
<td class="text-muted">{{ row.detail or '' }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted">No records found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card mt-2">
<div class="card-header">
<span></span>
<form method="post" action="{{ url_for('admin_logs.purge') }}"
onsubmit="return confirm('Purge old app log records?')" style="display:flex;gap:.5rem;align-items:center">
<input class="form-control" name="days" type="number" value="30" style="width:80px">
<label class="form-label mb-0">days</label>
<button class="btn btn-danger btn-sm" type="submit">Purge</button>
</form>
</div>
<table class="table table-sm">
<thead><tr><th>Time</th><th>Level</th><th>Logger</th><th>Message</th></tr></thead>
<tbody>
{% for row in app_rows %}
<tr class="log-{{ row.level | lower }}">
<td class="text-muted nowrap">{{ row.logged_at.strftime('%Y-%m-%d %H:%M:%S') if row.logged_at else '—' }}</td>
<td><span class="badge badge-log-{{ row.level | lower }}">{{ row.level }}</span></td>
<td class="text-muted">{{ row.logger_name }}</td>
<td class="log-message">{{ row.message }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted">No records found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
+95
View File
@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}Reports — Website Checker{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Reports</h1>
<a class="btn btn-ghost" href="{{ url_for('admin_reports.export_csv', **request.args) }}">⬇ Export CSV</a>
</div>
<div class="tab-bar">
<a class="tab-item {{ 'active' if tab == 'detail' }}" href="{{ url_for('admin_reports.reports', tab='detail', date_from=date_from, date_to=date_to) }}">Shift Detail</a>
<a class="tab-item {{ 'active' if tab == 'unchecked' }}" href="{{ url_for('admin_reports.reports', tab='unchecked', target_date=target_date) }}">Unchecked</a>
<a class="tab-item {{ 'active' if tab == 'summary' }}" href="{{ url_for('admin_reports.reports', tab='summary', date_from=date_from, date_to=date_to) }}">Summary</a>
</div>
<form class="filter-bar mt-2" method="get">
<input type="hidden" name="tab" value="{{ tab }}">
{% if tab == 'unchecked' %}
<label class="form-label mb-0">Date:</label>
<input class="form-control" type="date" name="target_date" value="{{ target_date }}">
{% else %}
<label class="form-label mb-0">From:</label>
<input class="form-control" type="date" name="date_from" value="{{ date_from }}">
<label class="form-label mb-0">To:</label>
<input class="form-control" type="date" name="date_to" value="{{ date_to }}">
{% endif %}
{% if tab == 'detail' or tab == 'unchecked' %}
<select class="form-control" name="user_id">
<option value="">All Users</option>
{% for u in users %}<option value="{{ u.id }}" {{ 'selected' if user_id == u.id|string }}>{{ u.username }}</option>{% endfor %}
</select>
{% endif %}
{% if tab == 'detail' %}
<select class="form-control" name="website_id">
<option value="">All Websites</option>
{% for w in websites %}<option value="{{ w.id }}" {{ 'selected' if website_id == w.id|string }}>{{ w.name }}</option>{% endfor %}
</select>
{% endif %}
<button class="btn btn-ghost" type="submit">Filter</button>
</form>
<div class="card mt-2">
{% if tab == 'detail' %}
<table class="table table-sm">
<thead><tr><th>Date</th><th>Time</th><th>User</th><th>Website</th><th>URL</th><th>Note</th></tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ r.check_date }}</td>
<td class="text-muted nowrap">{{ r.checked_at.strftime('%H:%M:%S') if r.checked_at else '' }}</td>
<td>{{ r.full_name }}</td>
<td>{{ r.website_name }}</td>
<td><a href="{{ r.url }}" target="_blank" rel="noopener">{{ r.url[:40] }}…</a></td>
<td class="text-muted">{{ r.user_note }}</td>
</tr>
{% else %}<tr><td colspan="6" class="text-center text-muted">No data.</td></tr>{% endfor %}
</tbody>
</table>
{% elif tab == 'unchecked' %}
<table class="table table-sm">
<thead><tr><th>Date</th><th>User</th><th>Website</th><th>URL</th><th>Status</th></tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ r.check_date }}</td>
<td>{{ r.full_name }}</td>
<td>{{ r.website_name }}</td>
<td><a href="{{ r.url }}" target="_blank" rel="noopener">{{ r.url[:40] }}…</a></td>
<td><span class="badge badge-danger">Not Checked</span></td>
</tr>
{% else %}<tr><td colspan="5" class="text-center text-muted">No data.</td></tr>{% endfor %}
</tbody>
</table>
{% elif tab == 'summary' %}
<table class="table table-sm">
<thead><tr><th>Date</th><th>User</th><th>Checked</th><th>Total</th><th>Progress</th><th>%</th></tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ r.check_date }}</td>
<td>{{ r.full_name }}</td>
<td>{{ r.checked_count }}</td>
<td>{{ r.total_sites }}</td>
<td>
<div class="progress"><div class="progress-bar" style="width:{{ r.pct_complete or 0 }}%"></div></div>
</td>
<td><strong>{{ r.pct_complete or 0 }}%</strong></td>
</tr>
{% else %}<tr><td colspan="6" class="text-center text-muted">No data.</td></tr>{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{% endblock %}
+85
View File
@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block title %}Settings — Website Checker{% endblock %}
{% block content %}
<div class="page-header"><h1 class="page-title">Application Settings</h1></div>
<div class="settings-grid">
<!-- Email Settings -->
<div class="card">
<div class="card-header"><h2 class="card-title">📧 Email Report Settings</h2></div>
<form method="post" action="{{ url_for('admin_settings.save_email') }}">
<div class="modal-body">
<div class="form-group">
<label class="form-label">Enable Email Reports</label>
<select class="form-control" name="email_enabled">
<option value="1" {{ 'selected' if email.get('email.enabled')=='1' }}>Enabled</option>
<option value="0" {{ 'selected' if email.get('email.enabled')!='1' }}>Disabled</option>
</select>
</div>
<div class="form-group">
<label class="form-label">SMTP Host</label>
<input class="form-control" name="email_smtp_host" value="{{ email.get('email.smtp_host','') }}">
</div>
<div class="form-row">
<div class="form-group flex-1">
<label class="form-label">Port</label>
<input class="form-control" name="email_smtp_port" value="{{ email.get('email.smtp_port','587') }}">
</div>
<div class="form-group flex-1">
<label class="form-label">Security</label>
<select class="form-control" name="email_security">
{% for mode in ['starttls','ssl','none'] %}
<option {{ 'selected' if email.get('email.security')==mode }}>{{ mode }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">SMTP Username</label>
<input class="form-control" name="email_smtp_user" value="{{ email.get('email.smtp_user','') }}">
</div>
<div class="form-group">
<label class="form-label">SMTP Password</label>
<input class="form-control" type="password" name="email_smtp_password" placeholder="(unchanged)">
</div>
<div class="form-group">
<label class="form-label">Recipients (comma-separated)</label>
<input class="form-control" name="email_recipients" value="{{ email.get('email.recipients','') }}">
</div>
<div class="form-group">
<label class="form-label">Send Time (HH:MM)</label>
<input class="form-control" type="time" name="email_send_time" value="{{ email.get('email.send_time','08:00') }}">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" type="submit">Save Email Settings</button>
</div>
</form>
</div>
<!-- Groq / AI Settings -->
<div class="card">
<div class="card-header"><h2 class="card-title">🤖 Groq AI Settings</h2></div>
<form method="post" action="{{ url_for('admin_settings.save_groq') }}">
<div class="modal-body">
<div class="form-group">
<label class="form-label">Groq API Key</label>
<input class="form-control" type="password" name="groq_api_key"
placeholder="{{ '(key set)' if groq.get('groq.api_key') else 'Enter API key' }}">
</div>
<div class="form-group">
<label class="form-label">Default Model</label>
<select class="form-control" name="groq_model">
{% for m in ['llama-3.3-70b-versatile','llama-3.1-8b-instant','gemma2-9b-it'] %}
<option {{ 'selected' if groq.get('groq.model')==m }}>{{ m }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" type="submit">Save AI Settings</button>
</div>
</form>
</div>
</div>
{% endblock %}
+173
View File
@@ -0,0 +1,173 @@
{% extends "base.html" %}
{% block title %}Shifts — Website Checker{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Shift Management</h1>
<button class="btn btn-primary" onclick="openModal('modal-create-shift')"> New Shift</button>
</div>
<div class="card">
<table class="table table-hover">
<thead>
<tr><th>Name</th><th>Days</th><th>Time</th><th>Users</th><th>Sites</th><th>Active</th><th>Actions</th></tr>
</thead>
<tbody>
{% for s in shifts %}
<tr>
<td><strong>{{ s.name }}</strong><br><span class="text-muted text-sm">{{ s.note or '' }}</span></td>
<td>{{ s.days_of_week }}</td>
<td class="text-muted">{{ s.start_time }} {{ s.end_time }}</td>
<td>{{ s.user_count }}</td>
<td>{{ s.website_count }}</td>
<td><span class="badge badge-{{ 'success' if s.is_active else 'neutral' }}">{{ 'Active' if s.is_active else 'Inactive' }}</span></td>
<td class="actions">
<button class="btn btn-ghost btn-sm" onclick="loadEditShift({{ s.id }})">✎ Edit</button>
<form method="post" action="{{ url_for('admin_shifts.delete', shift_id=s.id) }}"
style="display:inline" onsubmit="return confirm('Deactivate shift {{ s.name }}?')">
<button class="btn btn-danger btn-sm" type="submit"></button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-center text-muted">No shifts configured.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Create Shift Modal -->
<div class="modal-overlay" id="modal-create-shift">
<div class="modal-dialog modal-lg">
<div class="modal-header">
<h3>New Shift</h3>
<button class="modal-close" onclick="closeModal('modal-create-shift')"></button>
</div>
<form method="post" action="{{ url_for('admin_shifts.create') }}">
<div class="modal-body">
<div class="form-group">
<label class="form-label">Shift Name *</label>
<input class="form-control" name="name" required>
</div>
<div class="form-group">
<label class="form-label">Days of Week</label>
<div class="day-checkboxes">
{% for label, val in day_map %}
<label class="day-check">
<input type="checkbox" name="days_of_week[]" value="{{ val }}"> {{ label }}
</label>
{% endfor %}
</div>
</div>
<div class="form-row">
<div class="form-group flex-1">
<label class="form-label">Start Time</label>
<input class="form-control" type="time" name="start_time" value="08:00">
</div>
<div class="form-group flex-1">
<label class="form-label">End Time</label>
<input class="form-control" type="time" name="end_time" value="17:00">
</div>
</div>
<div class="form-group">
<label class="form-label">Assign Users</label>
<div class="checkbox-list">
{% for u in all_users %}
<label><input type="checkbox" name="user_ids[]" value="{{ u.id }}"> {{ u.username }}</label>
{% endfor %}
</div>
</div>
<div class="form-group">
<label class="form-label">Assign Websites</label>
<div class="checkbox-list">
{% for w in all_sites %}
<label><input type="checkbox" name="website_ids[]" value="{{ w.id }}"> {{ w.name }}</label>
{% endfor %}
</div>
</div>
<div class="form-group">
<label class="form-label">Note</label>
<textarea class="form-control" name="note" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeModal('modal-create-shift')">Cancel</button>
<button type="submit" class="btn btn-primary">Create Shift</button>
</div>
</form>
</div>
</div>
<!-- Edit Shift Modal -->
<div class="modal-overlay" id="modal-edit-shift">
<div class="modal-dialog modal-lg">
<div class="modal-header">
<h3>Edit Shift</h3>
<button class="modal-close" onclick="closeModal('modal-edit-shift')"></button>
</div>
<form method="post" id="edit-shift-form">
<div class="modal-body" id="edit-shift-body"><div class="text-center text-muted">Loading…</div></div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeModal('modal-edit-shift')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<script>
const ALL_USERS = {{ all_users | tojson }};
const ALL_SITES = {{ all_sites | tojson }};
const DAY_MAP = {{ day_map | tojson }};
function loadEditShift(id) {
fetch('/admin/shifts/' + id + '/detail')
.then(r => r.json())
.then(s => {
document.getElementById('edit-shift-form').action = '/admin/shifts/' + id + '/edit';
document.getElementById('edit-shift-body').innerHTML = buildShiftForm(s);
openModal('modal-edit-shift');
});
}
function buildShiftForm(s) {
const days = DAY_MAP.map(([lbl, val]) => `
<label class="day-check">
<input type="checkbox" name="days_of_week[]" value="${val}"
${s.days_of_week.includes(val)?'checked':''}>${lbl}
</label>`).join('');
const users = ALL_USERS.map(u => `
<label><input type="checkbox" name="user_ids[]" value="${u.id}"
${s.user_ids.includes(u.id)?'checked':''}> ${u.username}</label>`).join('');
const sites = ALL_SITES.map(w => `
<label><input type="checkbox" name="website_ids[]" value="${w.id}"
${s.website_ids.includes(w.id)?'checked':''}> ${w.name}</label>`).join('');
return `
<div class="form-group"><label class="form-label">Shift Name *</label>
<input class="form-control" name="name" value="${esc(s.name)}" required></div>
<div class="form-group"><label class="form-label">Days of Week</label>
<div class="day-checkboxes">${days}</div></div>
<div class="form-row">
<div class="form-group flex-1"><label class="form-label">Start Time</label>
<input class="form-control" type="time" name="start_time" value="${esc(s.start_time)}"></div>
<div class="form-group flex-1"><label class="form-label">End Time</label>
<input class="form-control" type="time" name="end_time" value="${esc(s.end_time)}"></div>
</div>
<div class="form-group"><label class="form-label">Active</label>
<select class="form-control" name="is_active">
<option value="1" ${s.is_active?'selected':''}>Yes</option>
<option value="0" ${!s.is_active?'selected':''}>No</option>
</select></div>
<div class="form-group"><label class="form-label">Assign Users</label>
<div class="checkbox-list">${users}</div></div>
<div class="form-group"><label class="form-label">Assign Websites</label>
<div class="checkbox-list">${sites}</div></div>
<div class="form-group"><label class="form-label">Note</label>
<textarea class="form-control" name="note" rows="2">${esc(s.note)}</textarea></div>`;
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
</script>
{% endblock %}
+145
View File
@@ -0,0 +1,145 @@
{% extends "base.html" %}
{% block title %}Users — Website Checker{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">User Management</h1>
<button class="btn btn-primary" onclick="openModal('modal-create-user')"> Add User</button>
</div>
<div class="card">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th><th>Username</th><th>Full Name</th><th>Email</th>
<th>Role</th><th>Active</th><th>Created</th><th>Actions</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.id }}</td>
<td><strong>{{ u.username }}</strong></td>
<td>{{ u.full_name or '—' }}</td>
<td>{{ u.email or '—' }}</td>
<td><span class="badge badge-{{ 'accent' if u.role == 'admin' else 'neutral' }}">{{ u.role }}</span></td>
<td><span class="badge badge-{{ 'success' if u.is_active else 'danger' }}">{{ 'Yes' if u.is_active else 'No' }}</span></td>
<td class="text-muted">{{ u.created_at.strftime('%Y-%m-%d') if u.created_at else '—' }}</td>
<td class="actions">
<button class="btn btn-ghost btn-sm"
onclick='openEditUser({{ u|tojson }})'>✎ Edit</button>
<form method="post" action="{{ url_for('admin_users.delete', user_id=u.id) }}"
style="display:inline"
onsubmit="return confirm('Delete user {{ u.username }}?')">
<button class="btn btn-danger btn-sm" type="submit"></button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-center text-muted">No users found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Create User Modal -->
<div class="modal-overlay" id="modal-create-user">
<div class="modal-dialog">
<div class="modal-header">
<h3>Add User</h3>
<button class="modal-close" onclick="closeModal('modal-create-user')"></button>
</div>
<form method="post" action="{{ url_for('admin_users.create') }}">
<div class="modal-body">
<div class="form-group">
<label class="form-label">Username *</label>
<input class="form-control" name="username" required>
</div>
<div class="form-group">
<label class="form-label">Full Name</label>
<input class="form-control" name="full_name">
</div>
<div class="form-group">
<label class="form-label">Email</label>
<input class="form-control" type="email" name="email">
</div>
<div class="form-group">
<label class="form-label">Role</label>
<select class="form-control" name="role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Password *</label>
<input class="form-control" type="password" name="password" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeModal('modal-create-user')">Cancel</button>
<button type="submit" class="btn btn-primary">Create User</button>
</div>
</form>
</div>
</div>
<!-- Edit User Modal -->
<div class="modal-overlay" id="modal-edit-user">
<div class="modal-dialog">
<div class="modal-header">
<h3>Edit User</h3>
<button class="modal-close" onclick="closeModal('modal-edit-user')"></button>
</div>
<form method="post" id="edit-user-form">
<div class="modal-body">
<div class="form-group">
<label class="form-label">Username *</label>
<input class="form-control" name="username" id="edit-username" required>
</div>
<div class="form-group">
<label class="form-label">Full Name</label>
<input class="form-control" name="full_name" id="edit-full-name">
</div>
<div class="form-group">
<label class="form-label">Email</label>
<input class="form-control" type="email" name="email" id="edit-email">
</div>
<div class="form-group">
<label class="form-label">Role</label>
<select class="form-control" name="role" id="edit-role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Active</label>
<select class="form-control" name="is_active" id="edit-is-active">
<option value="1">Yes</option>
<option value="0">No</option>
</select>
</div>
<div class="form-group">
<label class="form-label">New Password (leave blank to keep current)</label>
<input class="form-control" type="password" name="password">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeModal('modal-edit-user')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<script>
function openEditUser(u) {
document.getElementById('edit-username').value = u.username;
document.getElementById('edit-full-name').value = u.full_name || '';
document.getElementById('edit-email').value = u.email || '';
document.getElementById('edit-role').value = u.role;
document.getElementById('edit-is-active').value = u.is_active ? '1' : '0';
document.getElementById('edit-user-form').action =
'/admin/users/' + u.id + '/edit';
openModal('modal-edit-user');
}
</script>
{% endblock %}
+176
View File
@@ -0,0 +1,176 @@
{% extends "base.html" %}
{% block title %}Websites — Website Checker{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Website Link Management</h1>
<button class="btn btn-primary" onclick="openModal('modal-create-site')"> Add Website</button>
</div>
<div class="card">
<table class="table table-hover">
<thead>
<tr><th>ID</th><th>Name</th><th>Type</th><th>URL</th><th>Note</th><th>Visibility</th><th>Created By</th><th>Actions</th></tr>
</thead>
<tbody>
{% for w in websites %}
<tr class="{{ 'row-weekly' if w.check_type == 'weekly' else '' }}">
<td>{{ w.id }}</td>
<td><strong>{{ w.name }}</strong></td>
<td><span class="badge badge-{{ 'warn' if w.check_type == 'weekly' else 'neutral' }}">{{ w.check_type }}</span></td>
<td class="url-cell"><a href="{{ w.url }}" target="_blank" rel="noopener">{{ w.url[:60] }}{% if w.url|length > 60 %}…{% endif %}</a></td>
<td class="text-muted">{{ (w.note or '')[:40] }}{% if (w.note or '')|length > 40 %}…{% endif %}</td>
<td><span class="badge badge-{{ 'accent' if w.visibility == 'assigned' else 'neutral' }}">{{ w.visibility }}</span></td>
<td class="text-muted">{{ w.creator or '—' }}</td>
<td class="actions">
<button class="btn btn-ghost btn-sm" onclick="loadEditSite({{ w.id }})">✎ Edit</button>
<form method="post" action="{{ url_for('admin_websites.delete', website_id=w.id) }}"
style="display:inline" onsubmit="return confirm('Soft-delete {{ w.name }}?')">
<button class="btn btn-danger btn-sm" type="submit"></button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-center text-muted">No websites configured.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Create Website Modal -->
<div class="modal-overlay" id="modal-create-site">
<div class="modal-dialog modal-lg">
<div class="modal-header">
<h3>Add Website</h3>
<button class="modal-close" onclick="closeModal('modal-create-site')"></button>
</div>
<form method="post" action="{{ url_for('admin_websites.create') }}">
<div class="modal-body">
<div class="form-row">
<div class="form-group flex-1">
<label class="form-label">Name *</label>
<input class="form-control" name="name" required>
</div>
<div class="form-group flex-1">
<label class="form-label">Check Type</label>
<select class="form-control" name="check_type">
<option value="daily">📅 Daily</option>
<option value="weekly">🗓 Weekly</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">URL *</label>
<input class="form-control" name="url" type="url" required>
</div>
<div class="form-group">
<label class="form-label">Visibility</label>
<select class="form-control" name="visibility">
<option value="all">All users</option>
<option value="assigned">Assigned users only</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Note</label>
<textarea class="form-control" name="note" rows="2"></textarea>
</div>
<hr>
<h4 style="margin-bottom:.5rem">Credentials <button type="button" class="btn btn-secondary btn-sm" onclick="addCredRow(this)"> Add</button></h4>
<div class="cred-rows">
<div class="cred-row">
<div class="form-group"><label class="form-label">Label</label><input class="form-control" name="cred_label[]" placeholder="e.g. Admin"></div>
<div class="form-group"><label class="form-label">Username</label><input class="form-control" name="cred_username[]"></div>
<div class="form-group"><label class="form-label">Password</label><input class="form-control" name="cred_password[]" type="password"></div>
<div class="form-group"><label class="form-label">&nbsp;</label><button type="button" class="btn btn-danger btn-sm" onclick="removeRow(this)"></button></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeModal('modal-create-site')">Cancel</button>
<button type="submit" class="btn btn-primary">Create Website</button>
</div>
</form>
</div>
</div>
<!-- Edit Website Modal -->
<div class="modal-overlay" id="modal-edit-site">
<div class="modal-dialog modal-lg">
<div class="modal-header">
<h3>Edit Website</h3>
<button class="modal-close" onclick="closeModal('modal-edit-site')"></button>
</div>
<form method="post" id="edit-site-form">
<div class="modal-body" id="edit-site-body">
<div class="text-center text-muted">Loading…</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeModal('modal-edit-site')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<script>
function loadEditSite(id) {
fetch('/admin/websites/' + id + '/detail')
.then(r => r.json())
.then(site => {
const form = document.getElementById('edit-site-form');
form.action = '/admin/websites/' + id + '/edit';
document.getElementById('edit-site-body').innerHTML = buildSiteForm(site);
openModal('modal-edit-site');
});
}
function buildSiteForm(s) {
const creds = (s.credentials || []).map(c => `
<div class="cred-row">
<input class="form-control" name="cred_label[]" placeholder="Label" value="${esc(c.label)}">
<input class="form-control" name="cred_username[]" placeholder="Username" value="${esc(c.username)}">
<input class="form-control" name="cred_password[]" placeholder="Password" type="password" value="${esc(c.password)}">
<button type="button" class="btn btn-danger btn-sm" onclick="removeRow(this)"></button>
</div>`).join('') || emptyCredRow();
return `
<div class="form-group"><label class="form-label">Name *</label>
<input class="form-control" name="name" value="${esc(s.name)}" required></div>
<div class="form-row">
<div class="form-group flex-1"><label class="form-label">Check Type</label>
<select class="form-control" name="check_type">
<option value="daily" ${s.check_type==='daily'?'selected':''}>📅 Daily</option>
<option value="weekly" ${s.check_type==='weekly'?'selected':''}>🗓 Weekly</option>
</select></div>
<div class="form-group flex-1"><label class="form-label">Visibility</label>
<select class="form-control" name="visibility">
<option value="all" ${s.visibility==='all'?'selected':''}>All users</option>
<option value="assigned" ${s.visibility==='assigned'?'selected':''}>Assigned only</option>
</select></div>
</div>
<div class="form-group"><label class="form-label">URL *</label>
<input class="form-control" name="url" type="url" value="${esc(s.url)}" required></div>
<div class="form-group"><label class="form-label">Note</label>
<textarea class="form-control" name="note" rows="2">${esc(s.note)}</textarea></div>
<hr>
<h4>Credentials <button type="button" class="btn btn-ghost btn-sm" onclick="addCredRow(this)"> Add</button></h4>
<div class="cred-rows">${creds}</div>`;
}
function emptyCredRow() {
return `<div class="cred-row">
<input class="form-control" name="cred_label[]" placeholder="Label">
<input class="form-control" name="cred_username[]" placeholder="Username">
<input class="form-control" name="cred_password[]" placeholder="Password" type="password">
<button type="button" class="btn btn-danger btn-sm" onclick="removeRow(this)"></button>
</div>`;
}
function addCredRow(btn) {
btn.closest('.modal-body').querySelector('.cred-rows').insertAdjacentHTML('beforeend', emptyCredRow());
}
function removeRow(btn) { btn.closest('.cred-row').remove(); }
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
</script>
{% endblock %}
+542
View File
@@ -0,0 +1,542 @@
{% extends "base.html" %}
{% block title %}AI Summary — Website Checker{% endblock %}
{% block content %}
<div class="page-header">
<h1>🤖 AI Document Analysis</h1>
{% if is_admin %}
<a href="{{ url_for('admin_settings.settings') }}" class="btn btn-secondary btn-sm">⚙ API Settings</a>
{% endif %}
</div>
{% if not groq_key_set %}
<div class="alert alert-warning mb-2">⚠ Groq API key not configured. An admin must add it under Settings.</div>
{% endif %}
<div class="ai-layout">
<!-- ── Left panel ──────────────────────────────────────────── -->
<div class="ai-left">
<!-- Upload card -->
<div class="card">
<div class="card-header"><span class="card-title">Upload Documents</span></div>
<div class="card-body-pad">
{% if is_admin %}
<div class="form-group">
<label>Model</label>
<select id="ai-model">
{% for m in groq_models %}
<option {{ 'selected' if m == groq_model }}>{{ m }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="form-group">
<label>Documents <span class="text-muted">(PDF, DOCX, TXT, CSV, XLSX)</span></label>
<input type="file" id="ai-files" multiple
accept=".pdf,.docx,.doc,.txt,.csv,.xlsx,.xls,.md">
</div>
<button class="btn btn-primary w-full" id="btn-analyze" onclick="runAnalysis()">
🔍 Analyze with AI
</button>
</div>
</div>
<!-- Criteria card -->
<div class="card mt-2">
<div class="card-header">
<span class="card-title">Evaluation Criteria</span>
{% if is_admin %}
<button class="btn btn-secondary btn-sm" onclick="openModal('modal-add-criterion')"> Add</button>
{% endif %}
</div>
<div class="criteria-list">
{% for c in criteria %}
<div class="criterion-row {{ '' if c.is_active else 'criterion-inactive' }}"
data-title="{{ c.title }}"
data-desc="{{ c.description }}"
data-id="{{ c.id }}"
data-active="{{ '1' if c.is_active else '0' }}"
data-order="{{ c.sort_order }}">
<div class="criterion-header">
<strong class="criterion-title">{{ c.title }}</strong>
<div class="criterion-badges">
{% if not c.is_active %}<span class="badge badge-muted">Inactive</span>{% endif %}
{% if is_admin %}
<button class="btn btn-secondary btn-sm js-edit-criterion">✎ Edit</button>
<form method="post"
action="{{ url_for('ai_summary.delete_criterion_view', criterion_id=c.id) }}"
style="display:inline"
onsubmit="return confirm('Delete this criterion?')">
<button class="btn btn-danger btn-sm" type="submit"></button>
</form>
{% endif %}
</div>
</div>
<div class="criterion-desc">{{ c.description[:140] }}{% if c.description|length > 140 %}…{% endif %}</div>
<button class="btn-link-sm js-view-criterion">View full details </button>
</div>
{% else %}
<p class="text-muted text-center" style="padding:1rem">No criteria defined yet.</p>
{% endfor %}
</div>
</div>
</div>
<!-- ── Right panel ─────────────────────────────────────────── -->
<div class="ai-right">
<!-- Output card -->
<div class="card ai-output-card">
<div class="card-header">
<span class="card-title">Extracted Information</span>
<div class="flex gap-1">
<button class="btn btn-secondary btn-sm" id="btn-copy" onclick="copyOutput()" style="display:none">📋 Copy</button>
<button class="btn btn-secondary btn-sm" id="btn-save" onclick="saveOutput()" style="display:none">💾 Save as TXT</button>
<button class="btn btn-secondary btn-sm" id="btn-clear" onclick="clearOutput()" style="display:none">🗑 Clear</button>
</div>
</div>
<!-- Verdict banner — hidden until result arrives -->
<div id="verdict-banner" style="display:none"></div>
<!-- Output body -->
<div id="ai-output" class="ai-output-body">
<div class="ai-placeholder">
<p>Upload a document and click <strong>Analyze with AI</strong> to begin.</p>
<p class="text-muted text-sm mt-1">The AI will extract: solicitation number &amp; type, set-aside, scope of work, work site, pre-proposal conference, POC, square footage, driving distance &amp; travel time, due date, and key deadlines.</p>
</div>
</div>
</div>
<!-- History card -->
<div class="card mt-2">
<div class="card-header"><span class="card-title">Analysis History</span></div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Date</th>
{% if is_admin %}<th>User</th>{% endif %}
<th>Files</th>
<th>Verdict</th>
</tr>
</thead>
<tbody>
{% for h in history %}
<tr class="history-row" data-id="{{ h.id }}" style="cursor:pointer" title="Click to view result">
<td class="nowrap text-muted">{{ h.analyzed_at.strftime('%Y-%m-%d %H:%M') if h.analyzed_at else '—' }}</td>
{% if is_admin %}<td class="text-muted">{{ h.username or '—' }}</td>{% endif %}
<td class="text-muted">{{ h.file_names[:50] }}{% if h.file_names|length > 50 %}…{% endif %}</td>
<td>
{% if h.verdict %}
<span class="badge badge-{{ 'success' if h.verdict=='PURSUE' else 'danger' if h.verdict=='PASS' else 'warning' }}">
{{ h.verdict }}
</span>
{% else %}—{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted" style="padding:1rem">No history yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ── View Criterion Modal ─────────────────────────────────── -->
<div class="modal-overlay" id="modal-view-criterion">
<div class="modal">
<div class="modal-header">
<span class="modal-title" id="vc-title">Criterion Details</span>
<button class="modal-close" onclick="closeModal('modal-view-criterion')"></button>
</div>
<div class="modal-body">
<p id="vc-desc" style="white-space:pre-wrap;line-height:1.7;color:var(--text-secondary)"></p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-view-criterion')">Close</button>
</div>
</div>
</div>
{% if is_admin %}
<!-- ── Add Criterion Modal ──────────────────────────────────── -->
<div class="modal-overlay" id="modal-add-criterion">
<div class="modal">
<div class="modal-header">
<span class="modal-title">Add Criterion</span>
<button class="modal-close" onclick="closeModal('modal-add-criterion')"></button>
</div>
<form method="post" action="{{ url_for('ai_summary.create_criterion_view') }}">
<div class="modal-body">
<div class="form-group"><label>Title *</label><input name="title" required></div>
<div class="form-group"><label>Description *</label><textarea name="description" rows="5" required></textarea></div>
<div class="form-row">
<div class="form-group"><label>Sort Order</label><input type="number" name="sort_order" value="0"></div>
<div class="form-group"><label>Active</label>
<select name="is_active"><option value="1">Yes</option><option value="0">No</option></select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-add-criterion')">Cancel</button>
<button type="submit" class="btn btn-primary">Add Criterion</button>
</div>
</form>
</div>
</div>
<!-- ── Edit Criterion Modal ─────────────────────────────────── -->
<div class="modal-overlay" id="modal-edit-criterion">
<div class="modal">
<div class="modal-header">
<span class="modal-title">Edit Criterion</span>
<button class="modal-close" onclick="closeModal('modal-edit-criterion')"></button>
</div>
<form method="post" id="edit-criterion-form">
<div class="modal-body">
<div class="form-group"><label>Title *</label><input name="title" id="ec-title" required></div>
<div class="form-group"><label>Description *</label><textarea name="description" id="ec-desc" rows="5" required></textarea></div>
<div class="form-row">
<div class="form-group"><label>Sort Order</label><input type="number" name="sort_order" id="ec-order"></div>
<div class="form-group"><label>Active</label>
<select name="is_active" id="ec-active"><option value="1">Yes</option><option value="0">No</option></select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-edit-criterion')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
{% endif %}
<!-- ── History Detail Modal ─────────────────────────────────── -->
<div class="modal-overlay" id="modal-history">
<div class="modal" style="max-width:780px">
<div class="modal-header">
<span class="modal-title" id="hist-title">Analysis Result</span>
<button class="modal-close" onclick="closeModal('modal-history')"></button>
</div>
<div id="hist-verdict-banner" style="display:none"></div>
<div class="modal-body" style="padding:0">
<div class="ai-meta-bar" id="hist-meta"></div>
<div id="hist-body" class="ai-rendered-output" style="max-height:65vh;overflow-y:auto;padding:1.25rem 1.5rem"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-history')">Close</button>
</div>
</div>
</div>
<!-- marked.js for markdown rendering -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script>
<script>
/* ─── Markdown renderer config ──────────────────────────────── */
marked.setOptions({ breaks: true, gfm: true });
/* ─── Render AI text: markdown + escape safety ──────────────── */
function renderAI(text) {
// marked.parse handles markdown; it escapes HTML internally for safety
return marked.parse(String(text || ''));
}
/* ─── Verdict banner HTML ───────────────────────────────────── */
function verdictBannerHTML(verdict) {
if (!verdict) return '';
var cfg = {
'PURSUE': { bg:'#16a34a', icon:'✅', label:'PURSUE THIS OPPORTUNITY', sub:'Strong alignment with your evaluation criteria.' },
'PASS': { bg:'#dc2626', icon:'🚫', label:'PASS ON THIS OPPORTUNITY', sub:'Does not meet one or more key criteria.' },
'UNCLEAR': { bg:'#d97706', icon:'⚠️', label:'UNCLEAR — REVIEW MANUALLY', sub:'Insufficient information for a confident determination.' },
};
var c = cfg[verdict.toUpperCase()] || { bg:'#4b5563', icon:'•', label: verdict, sub:'' };
return '<div class="verdict-banner" style="background:' + c.bg + '">'
+ '<span class="verdict-icon">' + c.icon + '</span>'
+ '<div><div class="verdict-label">' + c.label + '</div>'
+ (c.sub ? '<div class="verdict-sub">' + c.sub + '</div>' : '')
+ '</div></div>';
}
/* ─── Show/clear output ─────────────────────────────────────── */
var _rawText = ''; // kept for copy/save
function showResult(data) {
_rawText = data.summary || '';
var fileList = (data.file_names || '').split(', ').map(function(f) {
return ' - ' + f;
}).join('\n');
var criteriaNote = data.criteria_count > 0
? 'Criteria evaluated: ' + data.criteria_count
: 'No evaluation criteria configured.';
var header = 'AI Extraction | Model: ' + (data.model || '')
+ '\nFiles analyzed (' + (data.file_count || 1) + '):\n' + fileList
+ '\n' + criteriaNote
+ '\n' + '='.repeat(60);
// Verdict banner
var banner = document.getElementById('verdict-banner');
if (data.verdict) {
banner.innerHTML = verdictBannerHTML(data.verdict);
banner.style.display = '';
} else {
banner.style.display = 'none';
banner.innerHTML = '';
}
// Render output: header as plain preformatted, then markdown body
document.getElementById('ai-output').innerHTML =
'<pre class="ai-header-block">' + escHtml(header) + '</pre>'
+ '<div class="ai-rendered-output">' + renderAI(_rawText) + '</div>';
// Show action buttons
document.getElementById('btn-copy').style.display = '';
document.getElementById('btn-save').style.display = '';
document.getElementById('btn-clear').style.display = '';
}
function clearOutput() {
_rawText = '';
document.getElementById('ai-output').innerHTML =
'<div class="ai-placeholder"><p>Upload a document and click <strong>Analyze with AI</strong> to begin.</p></div>';
document.getElementById('verdict-banner').style.display = 'none';
document.getElementById('verdict-banner').innerHTML = '';
document.getElementById('btn-copy').style.display = 'none';
document.getElementById('btn-save').style.display = 'none';
document.getElementById('btn-clear').style.display = 'none';
}
function copyOutput() {
if (!_rawText) return;
navigator.clipboard.writeText(_rawText).then(function() {
var btn = document.getElementById('btn-copy');
var orig = btn.textContent;
btn.textContent = '✓ Copied';
setTimeout(function() { btn.textContent = orig; }, 2000);
});
}
function saveOutput() {
if (!_rawText) return;
var blob = new Blob([_rawText], { type: 'text/plain' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'ai_extraction.txt';
a.click();
}
/* ─── Analyze ───────────────────────────────────────────────── */
async function runAnalysis() {
var files = document.getElementById('ai-files').files;
if (!files.length) { alert('Please select at least one file.'); return; }
var modelEl = document.getElementById('ai-model');
var model = modelEl ? modelEl.value : '';
var btn = document.getElementById('btn-analyze');
btn.disabled = true;
btn.textContent = '⏳ Analysing…';
document.getElementById('ai-output').innerHTML =
'<div class="ai-placeholder"><p class="text-muted">Running AI analysis… this may take a moment.</p></div>';
document.getElementById('verdict-banner').style.display = 'none';
document.getElementById('btn-copy').style.display = 'none';
document.getElementById('btn-save').style.display = 'none';
document.getElementById('btn-clear').style.display = 'none';
var fd = new FormData();
for (var i = 0; i < files.length; i++) fd.append('documents[]', files[i]);
if (model) fd.append('model', model);
try {
var resp = await fetch('/ai-summary/analyze', { method: 'POST', body: fd });
var data = await resp.json();
if (data.error) {
document.getElementById('ai-output').innerHTML =
'<div class="alert alert-danger" style="margin:1rem">' + escHtml(data.error) + '</div>';
} else {
// Attach metadata for header rendering
data.file_names = Array.from(files).map(function(f){ return f.name; }).join(', ');
data.file_count = files.length;
data.model = model || '{{ groq_model }}';
data.criteria_count = {{ criteria | selectattr('is_active') | list | length }};
showResult(data);
}
} catch(e) {
document.getElementById('ai-output').innerHTML =
'<div class="alert alert-danger" style="margin:1rem">Request failed: ' + escHtml(String(e)) + '</div>';
} finally {
btn.disabled = false;
btn.textContent = '🔍 Analyze with AI';
}
}
/* ─── Criteria modals ───────────────────────────────────────── */
document.querySelectorAll('.js-view-criterion').forEach(function(btn) {
btn.addEventListener('click', function() {
var row = btn.closest('.criterion-row');
document.getElementById('vc-title').textContent = row.dataset.title;
document.getElementById('vc-desc').textContent = row.dataset.desc;
openModal('modal-view-criterion');
});
});
document.querySelectorAll('.js-edit-criterion').forEach(function(btn) {
btn.addEventListener('click', function() {
var row = btn.closest('.criterion-row');
document.getElementById('ec-title').value = row.dataset.title;
document.getElementById('ec-desc').value = row.dataset.desc;
document.getElementById('ec-order').value = row.dataset.order;
document.getElementById('ec-active').value = row.dataset.active;
document.getElementById('edit-criterion-form').action =
'/ai-summary/criteria/' + row.dataset.id + '/edit';
openModal('modal-edit-criterion');
});
});
/* ─── History rows ──────────────────────────────────────────── */
document.querySelectorAll('.history-row').forEach(function(row) {
row.addEventListener('click', function() {
var id = row.dataset.id;
document.getElementById('hist-title').textContent = 'Loading…';
document.getElementById('hist-meta').textContent = '';
document.getElementById('hist-body').innerHTML = '';
document.getElementById('hist-verdict-banner').style.display = 'none';
openModal('modal-history');
fetch('/ai-summary/history/' + id)
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.error) {
document.getElementById('hist-body').textContent = d.error;
return;
}
document.getElementById('hist-title').textContent =
'Analysis — ' + (d.file_names || '');
document.getElementById('hist-meta').textContent =
(d.analyzed_at || '').slice(0, 16)
+ (d.username ? ' · ' + d.username : '')
+ ' · ' + (d.model || '');
if (d.verdict) {
var vb = document.getElementById('hist-verdict-banner');
vb.innerHTML = verdictBannerHTML(d.verdict);
vb.style.display = '';
}
document.getElementById('hist-body').innerHTML =
renderAI(d.summary_text || '');
})
.catch(function() {
document.getElementById('hist-body').textContent = 'Failed to load analysis.';
});
});
});
/* ─── Utility ───────────────────────────────────────────────── */
function escHtml(str) {
return String(str || '').replace(/[&<>"']/g, function(c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
</script>
<style>
/* ── Layout ────────────────────────────────────────────────── */
.ai-layout{display:grid;grid-template-columns:320px 1fr;gap:1.25rem;align-items:start}
.card-body-pad{padding:1.1rem 1.4rem 1.4rem}
.ai-output-card{display:flex;flex-direction:column;overflow:hidden}
/* ── Verdict banner ────────────────────────────────────────── */
.verdict-banner{
display:flex;align-items:center;gap:1rem;
padding:.9rem 1.4rem;color:#fff;
}
.verdict-icon{font-size:1.3rem;flex-shrink:0}
.verdict-label{font-size:1rem;font-weight:700;letter-spacing:.01em}
.verdict-sub{font-size:.8rem;opacity:.9;margin-top:2px}
/* ── Output area ───────────────────────────────────────────── */
.ai-output-body{
flex:1;overflow-y:auto;
min-height:300px;max-height:calc(100vh - 280px);
}
.ai-placeholder{
padding:2rem 1.5rem;color:var(--text-muted);
}
.ai-header-block{
background:var(--bg-subtle);border-bottom:1px solid var(--border);
padding:.85rem 1.4rem;font-family:'DM Mono',monospace;font-size:.78rem;
color:var(--text-secondary);margin:0;white-space:pre-wrap;line-height:1.6;
}
.ai-meta-bar{
padding:.6rem 1.5rem;font-size:.78rem;font-family:'DM Mono',monospace;
color:var(--text-muted);background:var(--bg-subtle);
border-bottom:1px solid var(--border);
}
/* ── Rendered markdown output ──────────────────────────────── */
.ai-rendered-output{
padding:1.1rem 1.4rem 1.5rem;
font-size:.9rem;line-height:1.8;color:var(--text-secondary);
}
.ai-rendered-output h1,.ai-rendered-output h2{
font-size:1rem;font-weight:700;color:var(--text);
margin:1.4rem 0 .5rem;padding-bottom:.3rem;
border-bottom:1px solid var(--border);
}
.ai-rendered-output h3,.ai-rendered-output h4{
font-size:.9rem;font-weight:700;color:var(--text);margin:1rem 0 .35rem;
}
.ai-rendered-output p{margin:.35rem 0}
.ai-rendered-output strong{color:var(--text);font-weight:700}
.ai-rendered-output ul,.ai-rendered-output ol{
padding-left:1.4rem;margin:.35rem 0;
}
.ai-rendered-output li{margin:.2rem 0}
.ai-rendered-output hr{
border:none;border-top:2px solid var(--border);margin:1.25rem 0;
}
.ai-rendered-output pre,.ai-rendered-output code{
font-family:'DM Mono',monospace;font-size:.82rem;
background:var(--bg-subtle);border-radius:var(--r-sm);
}
.ai-rendered-output pre{padding:.75rem 1rem;overflow-x:auto}
.ai-rendered-output code{padding:.1rem .3rem}
/* ── Criteria list ─────────────────────────────────────────── */
.criteria-list{display:flex;flex-direction:column}
.criterion-row{
padding:.8rem 1.2rem;border-bottom:1px solid var(--border);
transition:background var(--t);
}
.criterion-row:last-child{border-bottom:none}
.criterion-row:hover{background:var(--bg-subtle)}
.criterion-inactive{opacity:.55}
.criterion-header{display:flex;align-items:center;justify-content:space-between;
gap:.5rem;margin-bottom:.2rem}
.criterion-title{font-size:.875rem;font-weight:600;color:var(--text)}
.criterion-badges{display:flex;align-items:center;gap:.35rem;flex-shrink:0}
.criterion-desc{font-size:.78rem;color:var(--text-muted);line-height:1.5;margin-bottom:.3rem}
.btn-link-sm{
background:none;border:none;padding:0;font-size:.73rem;
color:var(--accent);cursor:pointer;font-family:inherit;
}
.btn-link-sm:hover{text-decoration:underline}
/* ── History ───────────────────────────────────────────────── */
.history-row:hover td{background:var(--bg-subtle)}
@media(max-width:960px){
.ai-layout{grid-template-columns:1fr}
.ai-output-body{max-height:60vh}
}
</style>
{% endblock %}
+68
View File
@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Website Checker{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body class="layout">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-brand">
<span class="brand-icon">🌐</span>
<span class="brand-name">Website<br>Checker</span>
</div>
<hr class="sidebar-divider">
{% if current_user %}
{% if current_user.role == 'admin' %}
<p class="nav-group-label">MANAGEMENT</p>
<a href="{{ url_for('admin_dashboard.dashboard') }}" class="nav-item {% if request.endpoint == 'admin_dashboard.dashboard' %}active{% endif %}">🏠 Dashboard</a>
<a href="{{ url_for('admin_users.users_list') }}" class="nav-item {% if 'admin_users' in request.endpoint %}active{% endif %}">👤 Users</a>
<a href="{{ url_for('admin_websites.websites_list') }}" class="nav-item {% if 'admin_websites' in request.endpoint %}active{% endif %}">🌐 Websites</a>
<a href="{{ url_for('admin_shifts.shifts_list') }}" class="nav-item {% if 'admin_shifts' in request.endpoint %}active{% endif %}">🗓 Shifts</a>
<a href="{{ url_for('admin_reports.reports') }}" class="nav-item {% if 'admin_reports' in request.endpoint %}active{% endif %}">📑 Reports</a>
<a href="{{ url_for('admin_logs.logs') }}" class="nav-item {% if 'admin_logs' in request.endpoint %}active{% endif %}">📋 Activity Log</a>
<a href="{{ url_for('admin_settings.settings') }}" class="nav-item {% if 'admin_settings' in request.endpoint %}active{% endif %}">⚙ Settings</a>
<hr class="sidebar-divider">
<p class="nav-group-label">MY WORKSPACE</p>
{% endif %}
<a href="{{ url_for('user_dashboard.my_shifts') }}" class="nav-item {% if 'user_dashboard' in request.endpoint %}active{% endif %}">📊 My Shifts</a>
<a href="{{ url_for('ai_summary.ai_summary') }}" class="nav-item {% if 'ai_summary' in request.endpoint %}active{% endif %}">🤖 AI Summary</a>
<a href="{{ url_for('bid_tracker.bids_list') }}" class="nav-item {% if 'bid_tracker' in request.endpoint %}active{% endif %}">📌 Bid Tracker</a>
{% endif %}
<div class="sidebar-footer">
{% if current_user %}
<div class="user-info">
<div class="user-name">{{ current_user.full_name or current_user.username }}</div>
<div class="user-role">{{ current_user.role | capitalize }}</div>
</div>
<a href="{{ url_for('auth.change_password_view') }}" class="sidebar-btn btn-ghost">🔑 Change Password</a>
<a href="{{ url_for('auth.logout') }}" class="sidebar-btn btn-danger">⇠ Sign Out</a>
{% endif %}
</div>
</aside>
<!-- Main content -->
<main class="main-content">
<!-- Flash messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-container">
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
</body>
</html>
+540
View File
@@ -0,0 +1,540 @@
{% extends "base.html" %}
{% block title %}Bid Tracker — Website Checker{% endblock %}
{% block content %}
<div class="page-header">
<h1>📌 Bid Tracker</h1>
<button class="btn btn-primary" onclick="openModal('modal-add-bid')"> Add Bid</button>
</div>
<!-- ── Toolbar ─────────────────────────────────────────────── -->
<div class="bt-toolbar">
<div class="search-wrap" style="max-width:260px">
<span class="search-icon">🔍</span>
<input type="text" id="bid-search" placeholder="Search title, source, sol #…" autocomplete="off">
</div>
<div class="bt-filters">
<button class="bt-filter-btn active" data-status="">All</button>
{% for s in bid_statuses %}
<button class="bt-filter-btn" data-status="{{ s }}">{{ status_labels[s] }}</button>
{% endfor %}
</div>
<button class="btn btn-secondary btn-sm" onclick="loadBids()">↻ Refresh</button>
</div>
<!-- ── Split pane ─────────────────────────────────────────── -->
<div class="bt-layout">
<!-- Left — bid list -->
<div class="bt-left">
<div id="bid-list" class="bt-list">
<div class="bt-loading">Loading…</div>
</div>
</div>
<!-- Right — detail panel -->
<div class="bt-right" id="bt-detail">
<div class="bt-empty-state">
<div class="empty-icon">📌</div>
<p>Select a bid to view its details and updates.</p>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════
ADD BID MODAL
══════════════════════════════════════════════════════════ -->
<div class="modal-overlay" id="modal-add-bid">
<div class="modal" style="max-width:600px">
<div class="modal-header">
<span class="modal-title">Add Opportunity</span>
<button class="modal-close" onclick="closeModal('modal-add-bid')"></button>
</div>
<form method="post" action="{{ url_for('bid_tracker.create') }}">
<div class="modal-body">
{% include "_bid_form_fields.html" ignore missing %}
<div class="form-group">
<label>Title *</label>
<input type="text" name="title" required>
</div>
<div class="form-group">
<label>URL *</label>
<input type="url" name="url" placeholder="https://" required>
</div>
<div class="form-row">
<div class="form-group">
<label>Source</label>
<input type="text" name="source" placeholder="e.g. SAM.gov">
</div>
<div class="form-group">
<label>Solicitation #</label>
<input type="text" name="solicitation_number">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Status</label>
<select name="status">
{% for s in bid_statuses %}
<option value="{{ s }}" {% if s == 'open' %}selected{% endif %}>{{ status_labels[s] }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Due Date</label>
<input type="date" name="due_date">
</div>
</div>
<div class="form-group">
<label>Notes</label>
<textarea name="notes" rows="3" placeholder="Any relevant notes…"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-add-bid')">Cancel</button>
<button type="submit" class="btn btn-primary">Add Bid</button>
</div>
</form>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════
EDIT BID MODAL
══════════════════════════════════════════════════════════ -->
<div class="modal-overlay" id="modal-edit-bid">
<div class="modal" style="max-width:600px">
<div class="modal-header">
<span class="modal-title">Edit Opportunity</span>
<button class="modal-close" onclick="closeModal('modal-edit-bid')"></button>
</div>
<form method="post" id="edit-bid-form">
<div class="modal-body">
<div class="form-group">
<label>Title *</label>
<input type="text" name="title" id="eb-title" required>
</div>
<div class="form-group">
<label>URL *</label>
<input type="url" name="url" id="eb-url" placeholder="https://" required>
</div>
<div class="form-row">
<div class="form-group">
<label>Source</label>
<input type="text" name="source" id="eb-source">
</div>
<div class="form-group">
<label>Solicitation #</label>
<input type="text" name="solicitation_number" id="eb-sol">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Status</label>
<select name="status" id="eb-status">
{% for s in bid_statuses %}
<option value="{{ s }}">{{ status_labels[s] }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Due Date</label>
<input type="date" name="due_date" id="eb-due">
</div>
</div>
<div class="form-group">
<label>Notes</label>
<textarea name="notes" id="eb-notes" rows="3"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-edit-bid')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<script>
/* ══════════════════════════════════════════════════════════
State
══════════════════════════════════════════════════════════ */
var _activeBidId = null;
var _activeStatus = '';
var _allBids = []; // cached from last load
var _currentUserId = {{ session['user']['id'] }};
var _isAdmin = {{ 'true' if session['user']['role'] == 'admin' else 'false' }};
/* ══════════════════════════════════════════════════════════
Status config
══════════════════════════════════════════════════════════ */
var STATUS_LABELS = {
'open': '🟢 Open',
'monitoring': '🔵 Monitoring',
'awarded': '🏆 Awarded',
'no_bid': '⛔ No Bid',
'cancelled': '🚫 Cancelled',
};
var STATUS_BADGE = {
'open': 'badge-success',
'monitoring': 'badge-info',
'awarded': 'badge-warning',
'no_bid': 'badge-danger',
'cancelled': 'badge-muted',
};
/* ══════════════════════════════════════════════════════════
Load / render bid list
══════════════════════════════════════════════════════════ */
function loadBids() {
fetch('/bids/list/json?status=' + encodeURIComponent(_activeStatus))
.then(function(r) { return r.json(); })
.then(function(bids) {
_allBids = bids;
renderBidList(bids);
// Restore selection
if (_activeBidId) {
var row = document.querySelector('.bt-row[data-id="' + _activeBidId + '"]');
if (row) row.classList.add('active');
}
})
.catch(function() {
document.getElementById('bid-list').innerHTML =
'<p class="text-muted text-center" style="padding:1rem">Failed to load bids.</p>';
});
}
function renderBidList(bids) {
var q = document.getElementById('bid-search').value.trim().toLowerCase();
var filtered = q ? bids.filter(function(b) {
return (b.title || '').toLowerCase().includes(q)
|| (b.source || '').toLowerCase().includes(q)
|| (b.solicitation_number || '').toLowerCase().includes(q);
}) : bids;
var list = document.getElementById('bid-list');
if (!filtered.length) {
list.innerHTML = '<p class="text-muted text-center" style="padding:1.5rem">No bids found.</p>';
return;
}
list.innerHTML = filtered.map(function(b) {
var due = b.due_date ? b.due_date.slice(0, 10) : '—';
var badge = STATUS_BADGE[b.status] || 'badge-muted';
var label = STATUS_LABELS[b.status] || b.status;
var active = b.id === _activeBidId ? ' active' : '';
var upds = b.update_count || 0;
return '<div class="bt-row' + active + '" data-id="' + b.id + '" onclick="selectBid(' + b.id + ')">'
+ '<div class="bt-row-title">' + esc(b.title) + '</div>'
+ '<div class="bt-row-meta">'
+ '<span class="badge ' + badge + ' badge-sm">' + label + '</span>'
+ '<span class="text-muted text-xs">Due: ' + due + '</span>'
+ (upds ? '<span class="text-muted text-xs">💬 ' + upds + '</span>' : '')
+ '</div>'
+ '</div>';
}).join('');
}
/* ══════════════════════════════════════════════════════════
Select + load detail
══════════════════════════════════════════════════════════ */
function selectBid(id) {
_activeBidId = id;
document.querySelectorAll('.bt-row').forEach(function(r) {
r.classList.toggle('active', r.dataset.id == id);
});
loadDetail(id);
}
function loadDetail(id) {
var panel = document.getElementById('bt-detail');
panel.innerHTML = '<div class="bt-detail-loading">Loading…</div>';
fetch('/bids/' + id + '/json')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) { panel.innerHTML = '<p class="text-muted" style="padding:1rem">' + esc(data.error) + '</p>'; return; }
renderDetail(data);
})
.catch(function() {
panel.innerHTML = '<p class="text-muted" style="padding:1rem">Failed to load details.</p>';
});
}
function renderDetail(data) {
var b = data.bid;
var updates = data.updates;
var canEdit = data.can_edit;
var userId = data.user_id;
var due = b.due_date ? b.due_date.slice(0, 10) : 'Not specified';
var badge = STATUS_BADGE[b.status] || 'badge-muted';
var label = STATUS_LABELS[b.status] || b.status;
var sol = b.solicitation_number || '—';
var src = b.source || '—';
var addedBy = b.added_by_username || '—';
var addedAt = (b.created_at || '').slice(0, 16);
var notes = b.notes || '';
var updatesHtml = updates.length ? updates.map(function(u) {
var poster = u.posted_by_full_name || u.posted_by_username || 'Unknown';
var dt = (u.created_at || '').slice(0, 16);
var canDel = data.is_admin || u.user_id === userId;
var delBtn = canDel
? '<button class="bt-upd-del" onclick="deleteUpdate(' + u.id + ',' + b.id + ')" title="Delete update"></button>'
: '';
return '<div class="bt-update-card">'
+ '<div class="bt-upd-hdr">'
+ '<span class="bt-upd-poster">👤 ' + esc(poster) + '</span>'
+ '<span class="bt-upd-dt text-muted text-xs">' + dt + '</span>'
+ delBtn
+ '</div>'
+ '<div class="bt-upd-body">' + esc(u.content) + '</div>'
+ '</div>';
}).join('') : '<p class="text-muted text-sm" style="padding:.5rem 0">No updates yet. Be the first to post one.</p>';
var editBtn = canEdit
? '<button class="btn btn-secondary btn-sm" onclick="openEditModal(' + b.id + ')">✎ Edit</button>'
+ '<form method="post" action="/bids/' + b.id + '/delete" style="display:inline"'
+ ' onsubmit="return confirm(\'Delete \\\'' + esc(b.title) + '\\\'?\')">'
+ '<button class="btn btn-danger btn-sm" type="submit">✕ Delete</button></form>'
: '';
document.getElementById('bt-detail').innerHTML = ''
+ '<div class="bt-detail-header">'
+ '<div class="bt-detail-title-row">'
+ '<h2 class="bt-detail-title">' + esc(b.title) + '</h2>'
+ '<span class="badge ' + badge + '">' + label + '</span>'
+ '</div>'
+ '<div class="bt-detail-actions">'
+ editBtn
+ '<a href="' + esc(b.url) + '" target="_blank" rel="noopener" class="btn btn-secondary btn-sm">↗ Open URL</a>'
+ '</div>'
+ '</div>'
+ '<div class="bt-meta-grid">'
+ metaItem('Due Date', due)
+ metaItem('Sol #', sol)
+ metaItem('Source', src)
+ metaItem('Added by', addedBy + (addedAt ? ' · ' + addedAt : ''))
+ '</div>'
+ (notes ? '<div class="bt-notes"><strong>Notes:</strong> ' + esc(notes) + '</div>' : '')
+ '<div class="bt-updates-section">'
+ '<div class="bt-updates-header">📝 Updates</div>'
+ '<div class="bt-compose">'
+ '<textarea id="upd-text-' + b.id + '" class="bt-compose-txt" rows="2"'
+ ' placeholder="Write an update, e.g. \'Amendment 1 issued — due date extended to…\'"></textarea>'
+ '<button class="btn btn-primary btn-sm" onclick="postUpdate(' + b.id + ')">📤 Post Update</button>'
+ '</div>'
+ '<div class="bt-updates-list" id="upd-list-' + b.id + '">' + updatesHtml + '</div>'
+ '</div>';
}
function metaItem(label, value) {
return '<div class="bt-meta-item"><span class="bt-meta-key">' + label + '</span>'
+ '<span class="bt-meta-val">' + esc(value) + '</span></div>';
}
/* ══════════════════════════════════════════════════════════
Post update (AJAX — no page reload)
══════════════════════════════════════════════════════════ */
function postUpdate(bidId) {
var ta = document.getElementById('upd-text-' + bidId);
var content = ta ? ta.value.trim() : '';
if (!content) { alert('Please write an update before posting.'); return; }
var fd = new FormData();
fd.append('content', content);
fetch('/bids/' + bidId + '/updates/json', { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.error) { alert(d.error); return; }
ta.value = '';
// Reload detail to show new update
loadDetail(bidId);
// Refresh list to update count
loadBids();
})
.catch(function(e) { alert('Failed to post update: ' + e); });
}
/* ══════════════════════════════════════════════════════════
Delete update (AJAX)
══════════════════════════════════════════════════════════ */
function deleteUpdate(updateId, bidId) {
if (!confirm('Delete this update?')) return;
var fd = new FormData();
fetch('/bids/updates/' + updateId + '/delete/json', { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.error) { alert(d.error); return; }
loadDetail(bidId);
loadBids();
})
.catch(function(e) { alert('Failed to delete update: ' + e); });
}
/* ══════════════════════════════════════════════════════════
Edit modal
══════════════════════════════════════════════════════════ */
function openEditModal(bidId) {
fetch('/bids/' + bidId + '/json')
.then(function(r) { return r.json(); })
.then(function(data) {
var b = data.bid;
document.getElementById('eb-title').value = b.title || '';
document.getElementById('eb-url').value = b.url || '';
document.getElementById('eb-source').value = b.source || '';
document.getElementById('eb-sol').value = b.solicitation_number || '';
document.getElementById('eb-status').value = b.status || 'open';
document.getElementById('eb-due').value = b.due_date ? b.due_date.slice(0, 10) : '';
document.getElementById('eb-notes').value = b.notes || '';
document.getElementById('edit-bid-form').action = '/bids/' + bidId + '/edit';
openModal('modal-edit-bid');
});
}
/* ══════════════════════════════════════════════════════════
Filter buttons
══════════════════════════════════════════════════════════ */
document.querySelectorAll('.bt-filter-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
document.querySelectorAll('.bt-filter-btn').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
_activeStatus = btn.dataset.status;
loadBids();
});
});
/* ── Search ─────────────────────────────────────────────── */
document.getElementById('bid-search').addEventListener('input', function() {
renderBidList(_allBids);
// Restore active class after re-render
if (_activeBidId) {
var row = document.querySelector('.bt-row[data-id="' + _activeBidId + '"]');
if (row) row.classList.add('active');
}
});
/* ── Escape ─────────────────────────────────────────────── */
function esc(str) {
return String(str || '').replace(/[&<>"']/g, function(c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
/* ── Init ───────────────────────────────────────────────── */
loadBids();
</script>
<style>
/* ── Toolbar ───────────────────────────────────────────── */
.bt-toolbar{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;margin-bottom:1rem}
.bt-filters{display:flex;gap:.35rem;flex-wrap:wrap}
.bt-filter-btn{
padding:.3rem .75rem;border-radius:999px;font-size:.75rem;font-weight:600;
border:1px solid var(--border-strong);background:var(--bg-white);
color:var(--text-muted);cursor:pointer;transition:all var(--t);
font-family:'DM Sans',sans-serif;
}
.bt-filter-btn:hover{background:var(--bg-hover);color:var(--text)}
.bt-filter-btn.active{background:var(--accent);color:#fff;border-color:var(--accent)}
/* ── Layout ────────────────────────────────────────────── */
.bt-layout{display:grid;grid-template-columns:340px 1fr;gap:1rem;
align-items:start;min-height:calc(100vh - 200px)}
.bt-left{background:var(--bg-white);border:1px solid var(--border);
border-radius:var(--r-lg);overflow:hidden;box-shadow:var(--shadow-sm)}
.bt-right{background:var(--bg-white);border:1px solid var(--border);
border-radius:var(--r-lg);box-shadow:var(--shadow-sm);
min-height:400px;overflow:hidden}
/* ── Bid list rows ─────────────────────────────────────── */
.bt-list{display:flex;flex-direction:column}
.bt-loading{padding:1.5rem;text-align:center;color:var(--text-muted);font-size:.875rem}
.bt-row{
padding:.8rem 1rem;border-bottom:1px solid var(--border);
cursor:pointer;transition:background var(--t);
}
.bt-row:last-child{border-bottom:none}
.bt-row:hover{background:var(--bg-subtle)}
.bt-row.active{background:var(--accent-light);border-left:3px solid var(--accent)}
.bt-row-title{font-size:.875rem;font-weight:600;color:var(--text);
margin-bottom:.3rem;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bt-row-meta{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}
/* ── Detail panel ──────────────────────────────────────── */
.bt-empty-state{display:flex;flex-direction:column;align-items:center;
justify-content:center;padding:3rem 1rem;color:var(--text-muted);
text-align:center}
.bt-detail-loading{padding:2rem;text-align:center;color:var(--text-muted)}
.bt-detail-header{
padding:1.1rem 1.25rem .9rem;border-bottom:1px solid var(--border);
background:var(--bg-subtle);
}
.bt-detail-title-row{display:flex;align-items:flex-start;gap:.6rem;margin-bottom:.6rem}
.bt-detail-title{font-size:1.1rem;font-weight:700;color:var(--text);flex:1;line-height:1.3}
.bt-detail-actions{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}
/* ── Metadata grid ─────────────────────────────────────── */
.bt-meta-grid{
display:grid;grid-template-columns:1fr 1fr;gap:0;
padding:.75rem 1.25rem;border-bottom:1px solid var(--border);
}
.bt-meta-item{padding:.35rem 0;display:flex;flex-direction:column;gap:2px}
.bt-meta-key{font-size:.68rem;font-weight:700;letter-spacing:.07em;
text-transform:uppercase;color:var(--text-muted)}
.bt-meta-val{font-size:.85rem;color:var(--text-secondary)}
.bt-notes{
padding:.7rem 1.25rem;font-size:.85rem;color:var(--text-secondary);
border-bottom:1px solid var(--border);line-height:1.6;
}
/* ── Updates section ───────────────────────────────────── */
.bt-updates-section{padding:0 1.25rem 1.25rem}
.bt-updates-header{font-size:.85rem;font-weight:700;color:var(--text);
padding:.9rem 0 .6rem;border-bottom:1px solid var(--border);
margin-bottom:.75rem}
.bt-compose{display:flex;flex-direction:column;gap:.5rem;margin-bottom:.9rem}
.bt-compose-txt{
width:100%;padding:.55rem .75rem;border:1px solid var(--border-strong);
border-radius:var(--r-sm);font-family:'DM Sans',sans-serif;font-size:.875rem;
color:var(--text);background:var(--bg-white);resize:vertical;outline:none;
transition:border-color var(--t),box-shadow var(--t);
}
.bt-compose-txt:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow)}
.bt-compose .btn{align-self:flex-end}
/* ── Update cards ──────────────────────────────────────── */
.bt-updates-list{display:flex;flex-direction:column;gap:.6rem;
max-height:380px;overflow-y:auto;padding-right:2px}
.bt-update-card{
background:var(--bg-subtle);border:1px solid var(--border);
border-radius:var(--r);padding:.7rem .9rem;
}
.bt-upd-hdr{display:flex;align-items:center;gap:.6rem;margin-bottom:.4rem}
.bt-upd-poster{font-size:.8rem;font-weight:600;color:var(--text);flex:1}
.bt-upd-dt{flex-shrink:0}
.bt-upd-del{
background:none;border:none;color:var(--text-muted);cursor:pointer;
font-size:.8rem;padding:.1rem .3rem;border-radius:var(--r-sm);
transition:all var(--t);
}
.bt-upd-del:hover{background:var(--danger-bg);color:var(--danger)}
.bt-upd-body{font-size:.875rem;color:var(--text-secondary);line-height:1.6;
white-space:pre-wrap}
@media(max-width:900px){
.bt-layout{grid-template-columns:1fr;grid-template-rows:auto auto}
.bt-right{min-height:300px}
}
</style>
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Change Password — Website Checker{% endblock %}
{% block content %}
<div class="page-header"><h1 class="page-title">🔑 Change Password</h1></div>
<div class="card" style="max-width:480px">
<div class="modal-body">
{% if success %}<div class="alert alert-success">{{ success }}</div>{% endif %}
{% if error %}<div class="alert alert-danger">{{ error }}</div>{% endif %}
<form method="post" action="{{ url_for('auth.change_password_view') }}">
<div class="form-group">
<label class="form-label">Current Password</label>
<input class="form-control" type="password" name="old_password" required>
</div>
<div class="form-group">
<label class="form-label">New Password</label>
<input class="form-control" type="password" name="new_password" required>
<small class="text-muted">Min 8 chars, uppercase, number, special character.</small>
</div>
<div class="form-group">
<label class="form-label">Confirm New Password</label>
<input class="form-control" type="password" name="confirm_password" required>
</div>
<button class="btn btn-primary" type="submit">Change Password</button>
</form>
</div>
</div>
{% endblock %}
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login — Website Checker</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body class="login-wrap">
<div class="login-box">
<div class="login-logo">
<span class="brand-icon">🌐</span>
<h1>Website Checker</h1>
<p>Shift Monitoring Tool</p>
</div>
{% if error %}
<div class="alert alert-danger mb-2">{{ error }}</div>
{% endif %}
<form method="post" action="{{ url_for('auth.login') }}">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username"
autocomplete="username" autofocus required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password"
autocomplete="current-password" required>
</div>
<button class="btn btn-primary" type="submit">Sign In</button>
</form>
</div>
</body>
</html>
+479
View File
@@ -0,0 +1,479 @@
{% extends "base.html" %}
{% block title %}My Shifts — Website Checker{% endblock %}
{% block content %}
<!-- ── Page header ─────────────────────────────────────────── -->
<div class="page-header">
<div>
<h1>My Shift — Website Checklist</h1>
<div class="page-subtitle" id="today-date"></div>
</div>
<div class="flex gap-1 items-center flex-wrap">
{% for s in shifts %}
<span class="badge badge-accent">{{ s.name }}
{% if s.start_time and s.end_time %}
· {{ s.start_time|hhmm }}{{ s.end_time|hhmm }}
{% endif %}
</span>
{% endfor %}
<button class="btn btn-secondary btn-sm" onclick="location.reload()">↻ Refresh</button>
</div>
</div>
<!-- ── Progress bar ─────────────────────────────────────────── -->
<div class="progress-strip">
<div class="progress-strip-bar">
<div class="progress-strip-fill {% if pct == 100 %}fill-success{% elif pct >= 50 %}fill-warning{% else %}fill-danger{% endif %}"
style="width:{{ pct }}%"></div>
</div>
<div class="progress-strip-label">
<strong>{{ checked }} / {{ total }}</strong> checked
<span class="text-muted">&nbsp;·&nbsp; {{ pct }}%</span>
</div>
</div>
<!-- ── Action bar ───────────────────────────────────────────── -->
<div class="action-bar">
<!-- Search: magnifier on the RIGHT -->
<div class="search-wrap">
<input type="text" id="site-search" placeholder="Search sites…" autocomplete="off">
<span class="search-icon">🔍</span>
</div>
<button class="btn btn-secondary btn-sm" id="btn-select-all">☑ Select All Unchecked</button>
<button class="btn btn-success btn-sm" id="btn-bulk-check">✔ Mark Selected Checked</button>
</div>
<!-- ── Site list grouped by frequency ──────────────────────── -->
<div id="site-list">
{# Group sites by check_type #}
{% set daily_sites = sites | selectattr('check_type', 'equalto', 'daily') | list %}
{% set weekly_sites = sites | selectattr('check_type', 'equalto', 'weekly') | list %}
{% set other_sites = sites | rejectattr('check_type', 'in', ['daily','weekly']) | list %}
{% for group_name, group_sites in [('Daily', daily_sites), ('Weekly', weekly_sites), ('Other', other_sites)] %}
{% if group_sites %}
<!-- Group header (collapsible) -->
<div class="group-header" data-group="{{ group_name | lower }}" onclick="toggleGroup('{{ group_name | lower }}')">
<span class="group-toggle" id="toggle-{{ group_name | lower }}"></span>
<span class="group-label">{{ group_name }}</span>
<span class="group-count">
{{ group_sites | selectattr('check_id') | list | length }} / {{ group_sites | length }} checked
</span>
<div class="group-mini-bar">
{% set g_total = group_sites | length %}
{% set g_checked = group_sites | selectattr('check_id') | list | length %}
{% set g_pct = ((g_checked / g_total) * 100) | int if g_total > 0 else 0 %}
<div class="group-mini-fill
{% if g_pct == 100 %}fill-success{% elif g_pct >= 50 %}fill-warning{% else %}fill-danger{% endif %}"
style="width:{{ g_pct }}%"></div>
</div>
</div>
<!-- Sites in this group -->
<div class="site-group" id="group-{{ group_name | lower }}">
{% for site in group_sites %}
<div class="site-card {% if site.check_id %}is-checked{% endif %}"
data-id="{{ site.id }}"
data-group="{{ group_name | lower }}"
data-name="{{ site.name | lower }}"
data-url="{{ (site.url or '') | lower }}"
data-site-name="{{ site.name }}"
data-user-note="{{ site.user_note or '' }}">
<!-- Row 1: checkbox · status · info · actions · health -->
<div class="sc-row1">
<div class="sc-select">
<input type="checkbox" class="bulk-cb" data-id="{{ site.id }}"
{% if site.check_id %}disabled{% endif %}>
</div>
<div class="sc-status">
{% if site.check_id %}
<span class="status-dot dot-checked"
title="Checked at {{ site.checked_at.strftime('%H:%M') if site.checked_at else 'today' }}">✔</span>
{% else %}
<span class="status-dot dot-unchecked"></span>
{% endif %}
</div>
<div class="sc-body">
<div class="sc-title-row">
<a class="sc-name" href="{{ site.url }}" target="_blank" rel="noopener">{{ site.name }}</a>
<span class="badge {% if site.check_type == 'weekly' %}badge-warning{% else %}badge-muted{% endif %}">
{{ site.check_type | capitalize }}
</span>
{% if site.check_id and site.checked_at %}
<span class="sc-checked-time text-success">✔ {{ site.checked_at.strftime('%H:%M') }}</span>
{% endif %}
</div>
<div class="sc-url">{{ site.url }}</div>
<div class="sc-meta">
{% if site.site_note %}
<span class="sc-meta-item">📌 {{ site.site_note }}</span>
{% endif %}
{% if site.shift_names %}
<span class="sc-meta-item text-accent">🕐 {{ site.shift_names }}</span>
{% endif %}
</div>
</div>
<div class="sc-actions">
{% if site.check_id %}
<span class="btn btn-success btn-sm checked-indicator">✔ Checked</span>
<button class="btn btn-secondary btn-sm js-note"
data-id="{{ site.id }}"
data-note="{{ site.user_note or '' }}">📝 Note</button>
<button class="btn btn-secondary btn-sm js-creds"
data-id="{{ site.id }}">🔑 Creds</button>
<form method="post"
action="{{ url_for('user_dashboard.uncheck_site', website_id=site.id) }}"
style="display:inline">
<button class="btn btn-secondary btn-sm js-undo" type="button"
data-name="{{ site.name }}">↩ Undo</button>
</form>
{% else %}
<button class="btn btn-primary btn-sm js-check"
data-id="{{ site.id }}"
data-name="{{ site.name }}">✔ Mark Checked</button>
<button class="btn btn-secondary btn-sm js-note"
data-id="{{ site.id }}"
data-note="">📝 Note</button>
<button class="btn btn-secondary btn-sm js-creds"
data-id="{{ site.id }}">🔑 Creds</button>
{% endif %}
</div>
<div class="sc-health">
<span class="health-dot" id="health-{{ site.id }}" title="Checking…"></span>
</div>
</div><!-- /sc-row1 -->
<!-- Row 2: user note (only shown when a note exists) -->
{% if site.user_note %}
<div class="sc-row2">
<span class="sc-note-row">📝 {{ site.user_note }}</span>
</div>
{% endif %}
</div><!-- /site-card -->
{% endfor %}
</div><!-- /site-group -->
{% endif %}
{% endfor %}
{% if not sites %}
<div class="empty-state">
<div class="empty-icon"></div>
<p>No sites to check today, or you're all done!</p>
</div>
{% endif %}
</div><!-- /site-list -->
<!-- ── Mark Checked Modal ───────────────────────────────────── -->
<div class="modal-overlay" id="modal-check">
<div class="modal">
<div class="modal-header">
<span class="modal-title" id="check-modal-title">Mark Checked</span>
<button class="modal-close" onclick="closeModal('modal-check')"></button>
</div>
<form method="post" id="check-form">
<div class="modal-body">
<div class="form-group">
<label>Note (optional)</label>
<textarea name="user_note" id="check-note" rows="3"
placeholder="Any observations or notes…"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-check')">Cancel</button>
<button type="submit" class="btn btn-success">✔ Confirm Checked</button>
</div>
</form>
</div>
</div>
<!-- ── Note Modal ──────────────────────────────────────────── -->
<div class="modal-overlay" id="modal-note">
<div class="modal">
<div class="modal-header">
<span class="modal-title">Update Note</span>
<button class="modal-close" onclick="closeModal('modal-note')"></button>
</div>
<form method="post" id="note-form">
<div class="modal-body">
<div class="form-group">
<label>Note</label>
<textarea name="user_note" id="note-text" rows="3"
placeholder="Any observations or notes…"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-note')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Note</button>
</div>
</form>
</div>
</div>
<!-- ── Credentials Modal ────────────────────────────────────── -->
<div class="modal-overlay" id="modal-creds">
<div class="modal">
<div class="modal-header">
<span class="modal-title">🔑 Credentials</span>
<button class="modal-close" onclick="closeModal('modal-creds')"></button>
</div>
<div class="modal-body" id="creds-body">
<div class="text-center text-muted">Loading…</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-creds')">Close</button>
</div>
</div>
</div>
<script>
/* ── Date ──────────────────────────────────────────────────── */
document.getElementById('today-date').textContent =
new Date().toLocaleDateString('en-US', {weekday:'long',year:'numeric',month:'long',day:'numeric'});
/* ── Group collapse/expand ─────────────────────────────────── */
var _collapsed = {};
function toggleGroup(name) {
var group = document.getElementById('group-' + name);
var toggle = document.getElementById('toggle-' + name);
if (!group) return;
_collapsed[name] = !_collapsed[name];
group.style.display = _collapsed[name] ? 'none' : '';
toggle.textContent = _collapsed[name] ? '▸' : '▾';
}
/* ── Search — shows matching cards, auto-expands collapsed groups ── */
document.getElementById('site-search').addEventListener('input', function() {
var q = this.value.trim().toLowerCase();
document.querySelectorAll('.site-card').forEach(function(card) {
var match = !q || card.dataset.name.includes(q) || card.dataset.url.includes(q);
card.style.display = match ? '' : 'none';
// Auto-expand the group containing a matched card
if (match && q) {
var group = card.closest('.site-group');
if (group) {
group.style.display = '';
var name = group.id.replace('group-', '');
var toggle = document.getElementById('toggle-' + name);
if (toggle) toggle.textContent = '▾';
_collapsed[name] = false;
}
}
});
});
/* ── Bulk select ───────────────────────────────────────────── */
document.getElementById('btn-select-all').addEventListener('click', function() {
document.querySelectorAll('.bulk-cb:not(:disabled)').forEach(function(cb) {
var card = cb.closest('.site-card');
if (!card.classList.contains('is-checked') && card.style.display !== 'none')
cb.checked = true;
});
});
/* ── Bulk check ────────────────────────────────────────────── */
document.getElementById('btn-bulk-check').addEventListener('click', function() {
var selected = Array.from(document.querySelectorAll('.bulk-cb:checked')).map(function(cb){ return cb.dataset.id; });
if (!selected.length) { alert('No sites selected. Tick the checkboxes first.'); return; }
if (!confirm('Mark ' + selected.length + ' site(s) as checked?')) return;
Promise.all(selected.map(function(id) {
return fetch('/dashboard/check/' + id, {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'user_note='
});
})).then(function() { location.reload(); });
});
/* ── Delegated button handler ──────────────────────────────── */
document.getElementById('site-list').addEventListener('click', function(e) {
var btn = e.target.closest('button[class]');
if (!btn) return;
/* Mark Checked */
if (btn.classList.contains('js-check')) {
document.getElementById('check-modal-title').textContent = 'Mark Checked — ' + btn.dataset.name;
document.getElementById('check-form').action = '/dashboard/check/' + btn.dataset.id;
document.getElementById('check-note').value = '';
openModal('modal-check');
return;
}
/* Note */
if (btn.classList.contains('js-note')) {
document.getElementById('note-form').action = '/dashboard/note/' + btn.dataset.id;
document.getElementById('note-text').value = btn.dataset.note || '';
openModal('modal-note');
return;
}
/* Credentials */
if (btn.classList.contains('js-creds')) {
var body = document.getElementById('creds-body');
body.innerHTML = '<div class="text-center text-muted">Loading…</div>';
openModal('modal-creds');
fetch('/dashboard/credentials/' + btn.dataset.id)
.then(function(r){ return r.json(); })
.then(function(creds) {
if (!creds.length) {
body.innerHTML = '<p class="text-muted text-center">No credentials stored.</p>';
return;
}
body.innerHTML = creds.map(function(c, i) {
var uid = 'pw-' + i + '-' + btn.dataset.id;
return '<div class="' + (i > 0 ? 'mt-2' : '') + '">'
+ (c.label ? '<div class="cred-label-heading">' + esc(c.label) + '</div>' : '')
+ '<div class="cred-row"><span class="cred-key">Username</span>'
+ '<span class="cred-val">' + esc(c.username || '—') + '</span>'
+ '<button class="btn btn-secondary btn-sm" data-copy="' + esc(c.username) + '">📋</button></div>'
+ '<div class="cred-row"><span class="cred-key">Password</span>'
+ '<span class="cred-val pw-mask" id="' + uid + '">' + esc(c.password) + '</span>'
+ '<button class="btn btn-secondary btn-sm" data-toggle-pw="' + uid + '">👁</button>'
+ '<button class="btn btn-secondary btn-sm" data-copy="' + esc(c.password) + '">📋</button></div>'
+ '</div>'
+ (i < creds.length - 1 ? '<hr class="divider">' : '');
}).join('');
})
.catch(function(){ body.innerHTML = '<p class="alert alert-danger">Failed to load.</p>'; });
return;
}
/* Undo */
if (btn.classList.contains('js-undo')) {
if (confirm('Remove check for ' + btn.dataset.name + '?')) {
btn.closest('form').submit();
}
return;
}
});
/* ── Credential modal actions ──────────────────────────────── */
document.getElementById('modal-creds').addEventListener('click', function(e) {
var copyBtn = e.target.closest('[data-copy]');
if (copyBtn) { copyToClipboard(copyBtn.dataset.copy, copyBtn); return; }
var toggleBtn = e.target.closest('[data-toggle-pw]');
if (toggleBtn) {
var el = document.getElementById(toggleBtn.dataset.togglePw);
if (el) {
var masked = el.classList.toggle('pw-mask');
toggleBtn.textContent = masked ? '👁' : '🙈';
}
}
});
/* ── HTML escape ───────────────────────────────────────────── */
function esc(str) {
return String(str || '').replace(/[&<>"']/g, function(c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
/* ── Health dots ───────────────────────────────────────────── */
document.querySelectorAll('.site-card').forEach(function(card) {
var id = card.dataset.id;
var url = card.dataset.url;
var dot = document.getElementById('health-' + id);
if (!dot || !url) return;
var img = new Image();
var t0 = Date.now();
img.onload = function() {
var ms = Date.now() - t0;
dot.style.color = ms > 3000 ? '#d97706' : '#16a34a';
dot.title = ms > 3000 ? 'Slow (' + ms + 'ms)' : 'Reachable (' + ms + 'ms)';
};
img.onerror = function() {
var ms = Date.now() - t0;
dot.style.color = ms < 6000 ? '#d97706' : '#dc2626';
dot.title = ms < 6000 ? 'Reachable (restricted)' : 'Unreachable';
};
img.src = 'https://www.google.com/s2/favicons?domain=' + encodeURIComponent(url) + '&t=' + Date.now();
});
</script>
<style>
/* ── Group header ───────────────────────────────────────────── */
.group-header {
display:flex; align-items:center; gap:.65rem;
padding:.6rem 1rem; margin-bottom:2px; margin-top:.75rem;
background:var(--bg-subtle); border:1px solid var(--border);
border-radius:var(--r); cursor:pointer;
transition:background var(--t);
user-select:none;
}
.group-header:first-child { margin-top:0; }
.group-header:hover { background:var(--bg-hover); }
.group-toggle { font-size:.8rem; color:var(--text-muted); width:12px; flex-shrink:0; }
.group-label { font-size:.85rem; font-weight:700; color:var(--text);
text-transform:uppercase; letter-spacing:.05em; }
.group-count { font-size:.8rem; color:var(--text-muted); margin-left:.25rem; }
.group-mini-bar {
flex:1; height:5px; background:var(--border); border-radius:999px;
overflow:hidden; max-width:120px;
}
.group-mini-fill {
height:100%; border-radius:999px; background:var(--accent);
transition:width .4s ease;
}
.group-mini-fill.fill-success { background:var(--success); }
.group-mini-fill.fill-warning { background:var(--warning); }
.group-mini-fill.fill-danger { background:var(--danger); }
/* ── Site group wrapper ─────────────────────────────────────── */
.site-group { display:flex; flex-direction:column; gap:.45rem; margin-bottom:.5rem; }
/* ── Site card ──────────────────────────────────────────────── */
.site-card {
background:var(--bg-white); border:1px solid var(--border);
border-radius:var(--r-lg); box-shadow:var(--shadow-sm);
transition:box-shadow var(--t), border-color var(--t);
overflow:hidden;
}
.site-card:hover { box-shadow:var(--shadow); border-color:var(--border-strong); }
.site-card.is-checked { border-color:var(--success-border); background:#fafffe; }
.site-card.is-checked:hover { border-color:#86efac; }
/* ── Row 1: main content ────────────────────────────────────── */
.sc-row1 {
display:flex; align-items:center; gap:.85rem; padding:.95rem 1rem;
}
/* ── Row 2: note row ────────────────────────────────────────── */
.sc-row2 {
padding:.45rem 1rem .6rem calc(1rem + 15px + .85rem + 26px + .85rem);
/* indent to align with sc-body, past checkbox + status */
border-top:1px dashed var(--border);
}
.sc-note-row {
font-size:.875rem; color:var(--warning);
background:var(--warning-bg); border:1px solid var(--warning-border);
border-radius:var(--r-sm); padding:.25rem .65rem;
display:inline-block; line-height:1.5;
}
/* ── Font size bumps ─────────────────────────────────────────── */
.sc-name { font-size:1rem; }
.sc-url { font-size:.76rem; }
.sc-meta-item{ font-size:.82rem; }
.sc-checked-time { font-size:.78rem; color:var(--success); font-weight:600; }
@media(max-width:700px) {
.sc-row1 { flex-wrap:wrap; }
.sc-actions { margin-left:0; flex-direction:column; align-items:flex-start; }
.sc-url { display:none; }
.sc-row2 { padding-left:1rem; }
}
</style>
{% endblock %}
+1
View File
@@ -0,0 +1 @@
# utils package
+142
View File
@@ -0,0 +1,142 @@
"""
utils/crypto.py Fernet symmetric encryption for website credentials.
IMPORTANT: This module is intentionally byte-for-byte compatible with the
desktop application's utils/crypto.py so that both apps can share the same
MySQL database and read/write each other's encrypted credential values.
Key derivation (must not change without re-encrypting all stored values):
- APP_SECRET : b"WebsiteChecker-v1-CredentialKey" (fixed, same as desktop)
- Salt : 32 random bytes, stored base64-encoded in app_settings
under key "crypto.salt" (same as desktop)
- KDF : PBKDF2-HMAC-SHA256, 100,000 iterations (same as desktop)
- Ciphertext : prefixed with "enc:" so plaintext legacy values are
distinguishable without attempting decryption (same as desktop)
Salt storage format:
- Stored as base64 (NOT hex) in app_settings.value for "crypto.salt".
- The desktop generates 32 bytes; we do the same to stay consistent.
Compatibility:
- decrypt() returns the raw value unchanged for strings that are NOT
prefixed with "enc:" this handles legacy plaintext credentials
written before encryption was introduced, identical to desktop behaviour.
"""
import base64
import logging
import os
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
logger = logging.getLogger("utils.crypto")
# ── Must match desktop exactly ────────────────────────────────────────────────
_APP_SECRET = b"WebsiteChecker-v1-CredentialKey"
_ITERATIONS = 100_000
_SETTING_KEY = "crypto.salt"
_fernet: "Fernet | None" = None
# ── Key bootstrap ─────────────────────────────────────────────────────────────
def _get_or_create_salt() -> bytes:
"""
Read the 32-byte salt from app_settings (base64-encoded), creating and
persisting a fresh one if absent. The base64 format matches the desktop.
"""
from config import get_setting, set_setting
raw = get_setting(_SETTING_KEY, "")
if raw:
try:
return base64.b64decode(raw)
except Exception as e:
logger.warning(f"Crypto: could not decode stored salt: {e} — generating new salt.")
# Generate a fresh 32-byte salt (same size as desktop)
salt = os.urandom(32)
b64_salt = base64.b64encode(salt).decode("ascii")
try:
set_setting(_SETTING_KEY, b64_salt)
logger.info("Crypto: generated and persisted new salt to app_settings.")
except Exception as e:
logger.warning(f"Crypto: could not persist new salt: {e}")
return salt
def _build_fernet() -> Fernet:
salt = _get_or_create_salt()
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=_ITERATIONS,
)
key = base64.urlsafe_b64encode(kdf.derive(_APP_SECRET))
return Fernet(key)
def _get_fernet() -> Fernet:
global _fernet
if _fernet is None:
_fernet = _build_fernet()
return _fernet
def reset_fernet():
"""Force key reload — call if the salt in app_settings is ever rotated."""
global _fernet
_fernet = None
# ── Public API ────────────────────────────────────────────────────────────────
def encrypt(plaintext: str) -> str:
"""
Encrypt a plaintext string.
Returns a UTF-8-safe ciphertext string prefixed with 'enc:' so callers
can distinguish encrypted values from legacy plaintext ones.
Returns the original string unchanged if plaintext is empty.
Compatible with desktop encrypt().
"""
if not plaintext:
return plaintext
try:
token = _get_fernet().encrypt(plaintext.encode("utf-8"))
return "enc:" + token.decode("ascii")
except Exception as e:
logger.error(f"Credential encryption failed: {e}")
return plaintext # safe fallback — don't lose data
def decrypt(ciphertext: str) -> str:
"""
Decrypt a ciphertext string produced by encrypt().
- If ciphertext starts with 'enc:', decrypts with Fernet.
- Otherwise returns the value as-is (plaintext legacy credential).
Returns empty string on decryption failure.
Compatible with desktop decrypt().
"""
if not ciphertext:
return ciphertext
if not ciphertext.startswith("enc:"):
# Legacy plaintext credential — return as-is, identical to desktop
return ciphertext
try:
token = ciphertext[4:].encode("ascii")
return _get_fernet().decrypt(token).decode("utf-8")
except InvalidToken:
logger.error("Credential decryption failed — wrong key or corrupted data.")
return ""
except Exception as e:
logger.error(f"Credential decryption error: {e}")
return ""
def is_encrypted(value: str) -> bool:
"""Return True if the value was produced by encrypt()."""
return isinstance(value, str) and value.startswith("enc:")
+30
View File
@@ -0,0 +1,30 @@
"""
utils/decorators.py Route protection decorators for the Flask web app.
"""
import functools
from flask import session, redirect, url_for, flash, abort
def login_required(f):
"""Redirect to login if the user is not authenticated."""
@functools.wraps(f)
def decorated(*args, **kwargs):
if "user" not in session:
flash("Please log in to access this page.", "warning")
return redirect(url_for("auth.login"))
return f(*args, **kwargs)
return decorated
def admin_required(f):
"""Abort 403 if the authenticated user is not an admin."""
@functools.wraps(f)
def decorated(*args, **kwargs):
if "user" not in session:
flash("Please log in to access this page.", "warning")
return redirect(url_for("auth.login"))
if session["user"].get("role") != "admin":
abort(403)
return f(*args, **kwargs)
return decorated
+16
View File
@@ -0,0 +1,16 @@
"""
wsgi.py Gunicorn entry point for Website Checker.
Usage:
gunicorn --bind unix:/run/webchecker/webchecker.sock \
--workers 4 \
--timeout 120 \
wsgi:application
"""
from app import create_app
application = create_app()
if __name__ == '__main__':
application.run()