04/30 Web Checker web app ver. 1.0
This commit is contained in:
@@ -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
|
||||
@@ -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 1–3 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
|
||||
@@ -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.*
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# routes package
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
@@ -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"],
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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> </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 : '';
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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"> </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,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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 & type, set-aside, scope of work, work site, pre-proposal conference, POC, square footage, driving distance & 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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|
||||
@@ -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>
|
||||
@@ -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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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"> · {{ 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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|
||||
@@ -0,0 +1 @@
|
||||
# utils package
|
||||
+142
@@ -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:")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user