05/24 Enhance functionalities
This commit is contained in:
@@ -99,12 +99,15 @@ Without this, sessions and CSRF tokens are broken across the 4 Gunicorn workers.
|
|||||||
| `app.py` | Flask factory | Registers all 11 blueprints; `CSRFProtect(app)`; `hhmm` template filter; logs WARNING if `SECRET_KEY` not set |
|
| `app.py` | Flask factory | Registers all 11 blueprints; `CSRFProtect(app)`; `hhmm` template filter; logs WARNING if `SECRET_KEY` not set |
|
||||||
| `wsgi.py` | Gunicorn entry point | Only place that calls `create_app()` — do not add a second call elsewhere |
|
| `wsgi.py` | Gunicorn entry point | Only place that calls `create_app()` — do not add a second call elsewhere |
|
||||||
| `config.py` | DB config, DDL, settings | Calls `load_dotenv()` at top — **must be before `DB_CONFIG` dict**; contains safe re-runnable migrations |
|
| `config.py` | DB config, DDL, settings | Calls `load_dotenv()` at top — **must be before `DB_CONFIG` dict**; contains safe re-runnable migrations |
|
||||||
| `models.py` | All DB queries | ~1,430 lines; no ORM; every function opens/closes its own connection; `import datetime` at top |
|
| `models.py` | All DB queries | ~1,650 lines; no ORM; every function opens/closes its own connection; `import datetime` at top |
|
||||||
| `utils/crypto.py` | Fernet encryption | Must match desktop exactly |
|
| `utils/crypto.py` | Fernet encryption | Must match desktop exactly |
|
||||||
| `utils/decorators.py` | `@login_required`, `@admin_required` | Simple session checks |
|
| `utils/decorators.py` | `@login_required`, `@admin_required` | Simple session checks |
|
||||||
|
| `utils/email.py` | SMTP email helper | `send_email(to, subject, body_text)` reads smtp settings from `app_settings`; used by bid reminders and password reset |
|
||||||
| `static/css/style.css` | Full design system | Light theme, DM Sans + DM Mono, CSS variables in `:root` |
|
| `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, `getCsrfToken()`, CSRF auto-inject IIFE |
|
| `static/js/app.js` | Global JS utilities | `openModal()`, `closeModal()`, `copyToClipboard()`, `timeAgo()`, mobile sidebar toggle, session timeout warning, `getCsrfToken()`, CSRF auto-inject IIFE |
|
||||||
| `templates/login.html` | Standalone login page | Does NOT extend `base.html`; has its own `<head>` and no `app.js`; CSRF token must be a direct hidden input |
|
| `templates/login.html` | Standalone login page | Does NOT extend `base.html`; has its own `<head>` and no `app.js`; CSRF token must be a direct hidden input |
|
||||||
|
| `templates/forgot_password.html` | Standalone forgot-password page | Same constraints as `login.html` — standalone, direct CSRF hidden input, no `app.js` |
|
||||||
|
| `templates/reset_password.html` | Standalone reset-password page | Same constraints as `login.html` — standalone, direct CSRF hidden input, no `app.js` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -333,9 +336,9 @@ mysql -u webchecker_user -p webchecker -e "SELECT key_name, value FROM app_setti
|
|||||||
- [ ] **Paginate bid list** — `get_all_bids()` fetches all rows with no limit; add `LIMIT`/`OFFSET` to the model query and a "load more" button in the split-pane list
|
- [ ] **Paginate bid list** — `get_all_bids()` fetches all rows with no limit; add `LIMIT`/`OFFSET` to the model query and a "load more" button in the split-pane list
|
||||||
|
|
||||||
### Functionality
|
### Functionality
|
||||||
- [ ] **Missed-shift alerting** — Query or report that flags shifts where zero `shift_checks` records exist for a given date, surfaced on the admin dashboard or via email
|
- [x] **Missed-shift alerting** — `get_missed_shifts_today()` in `models.py` queries shift+user pairs scheduled today with 0 checks; surfaced as a warning card on the admin dashboard
|
||||||
- [ ] **Bid deadline email reminders** — Use the existing `email.smtp_*` settings to send a daily digest of bids with `due_date` within the next 7 days
|
- [x] **Bid deadline email reminders** — `get_bids_due_soon()` + `get_admin_emails()` in `models.py`; `send_reminders` route in `bid_tracker.py`; "📧 Remind" button in bid tracker toolbar (admin only); uses `utils/email.py`
|
||||||
- [ ] **Server-side health checks** — Replace the Google favicon proxy in the user dashboard with a `/dashboard/health/<id>` route that makes a server-side `HEAD` request (with short timeout) for a real reachability signal
|
- [x] **Server-side health checks** — `/dashboard/health/<id>` in `user_dashboard.py` does a server-side HEAD request; user dashboard JS updated to call this instead of the Google favicon proxy
|
||||||
- [ ] **Password reset via email** — Time-limited token flow so users can self-service instead of requiring an admin edit; needs a `password_reset_tokens` table and SMTP integration
|
- [x] **Password reset via email** — `password_reset_tokens` table (migration-safe DDL in `config.py`); model functions in `models.py`; `/forgot-password` and `/reset-password/<token>` routes in `auth.py`; standalone templates `forgot_password.html` and `reset_password.html`; "Forgot password?" link on login page; uses `utils/email.py`
|
||||||
- [ ] **"Copy password" button in Credentials modal** — Wire `copyToClipboard()` (already in `app.js`) to the password field in the user dashboard credentials modal
|
- [x] **"Copy password" button in Credentials modal** — Already implemented in the original code via `data-copy` attribute and delegated click handler in `user/dashboard.html`
|
||||||
- [ ] **Shift calendar view** — Weekly grid (Mon–Sun columns, shifts as rows) on the admin shifts page to make schedule gaps and overlaps visible at a glance
|
- [x] **Shift calendar view** — Weekly grid tab added to admin shifts page; Mon–Sun columns, active shifts as rows; server-side rendered with Jinja2 using existing `day_map` data
|
||||||
|
|||||||
@@ -311,6 +311,18 @@ def initialize_database():
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
""",
|
""",
|
||||||
"""
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
token VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_prt_token (token),
|
||||||
|
INDEX idx_prt_expires (expires_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
""",
|
||||||
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS app_settings (
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
key_name VARCHAR(100) NOT NULL PRIMARY KEY,
|
key_name VARCHAR(100) NOT NULL PRIMARY KEY,
|
||||||
value TEXT NULL,
|
value TEXT NULL,
|
||||||
|
|||||||
@@ -1480,3 +1480,191 @@ def purge_app_log(older_than_days: int = 30):
|
|||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Missed Shifts ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_missed_shifts_today() -> list:
|
||||||
|
"""Return shift+user pairs scheduled today where the user has checked 0 sites."""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor(dictionary=True)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT s.name AS shift_name,
|
||||||
|
u.id AS user_id, u.username,
|
||||||
|
COALESCE(u.full_name, u.username) AS full_name,
|
||||||
|
COUNT(DISTINCT sw.website_id) AS total_sites,
|
||||||
|
COUNT(DISTINCT sc.website_id) AS checked_sites
|
||||||
|
FROM shifts s
|
||||||
|
JOIN shift_users su ON su.shift_id = s.id
|
||||||
|
JOIN users u ON u.id = su.user_id AND u.is_active = 1
|
||||||
|
JOIN shift_websites sw ON sw.shift_id = s.id
|
||||||
|
LEFT JOIN shift_checks sc
|
||||||
|
ON sc.user_id = su.user_id
|
||||||
|
AND sc.website_id = sw.website_id
|
||||||
|
AND DATE(sc.checked_at) = CURDATE()
|
||||||
|
WHERE s.is_active = 1
|
||||||
|
AND LOCATE(CAST(DAYOFWEEK(CURDATE()) AS CHAR), s.days_of_week) > 0
|
||||||
|
GROUP BY s.id, u.id
|
||||||
|
HAVING total_sites > 0 AND checked_sites = 0
|
||||||
|
ORDER BY s.name, u.username
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
return rows
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Bid Reminders ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_bids_due_soon(days: int = 7) -> list:
|
||||||
|
"""Return open/monitoring bids with due_date within the next `days` days."""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor(dictionary=True)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT b.*, u.username AS added_by_username, u.full_name AS added_by_full_name"
|
||||||
|
" FROM bid_tracker b"
|
||||||
|
" LEFT JOIN users u ON u.id = b.added_by"
|
||||||
|
" WHERE b.status IN ('open','monitoring')"
|
||||||
|
" AND b.due_date IS NOT NULL"
|
||||||
|
" AND b.due_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL %s DAY)"
|
||||||
|
" ORDER BY b.due_date ASC",
|
||||||
|
(days,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
return rows
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_emails() -> list:
|
||||||
|
"""Return email addresses of all active admin users that have one set."""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor(dictionary=True)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT email FROM users"
|
||||||
|
" WHERE role='admin' AND is_active=1 AND email IS NOT NULL AND email != ''"
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
return [r["email"] for r in rows]
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Server-side Health Check ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_website_url(website_id: int):
|
||||||
|
"""Return the URL of an active website by ID, or None if not found."""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT url FROM websites WHERE id=%s AND is_active=1", (website_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
return row[0] if row else None
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Password Reset ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_user_by_email(email: str):
|
||||||
|
"""Return the active user matching the given email (case-insensitive), or None."""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor(dictionary=True)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT * FROM users WHERE LOWER(email)=%s AND is_active=1 LIMIT 1",
|
||||||
|
(email.lower(),),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
return row
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def create_password_reset_token(user_id: int) -> str:
|
||||||
|
"""Generate a secure 1-hour reset token; invalidates any prior token for the user."""
|
||||||
|
import secrets
|
||||||
|
token = secrets.token_urlsafe(48)
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("DELETE FROM password_reset_tokens WHERE user_id=%s", (user_id,))
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO password_reset_tokens (user_id, token, expires_at)"
|
||||||
|
" VALUES (%s, %s, DATE_ADD(NOW(), INTERVAL 1 HOUR))",
|
||||||
|
(user_id, token),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
return token
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_reset_user(token: str):
|
||||||
|
"""Return the user row for a valid, unexpired reset token, or None."""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor(dictionary=True)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT u.* FROM password_reset_tokens t"
|
||||||
|
" JOIN users u ON u.id = t.user_id"
|
||||||
|
" WHERE t.token=%s AND t.expires_at > NOW()",
|
||||||
|
(token,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
return row
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def consume_password_reset_token(token: str, new_password: str) -> bool:
|
||||||
|
"""Validate token, update the user's password, and delete the token. Returns True on success."""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor(dictionary=True)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT user_id FROM password_reset_tokens WHERE token=%s AND expires_at > NOW()",
|
||||||
|
(token,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
cur.close()
|
||||||
|
return False
|
||||||
|
user_id = row["user_id"]
|
||||||
|
new_hash = _hash_password(new_password)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("UPDATE users SET password=%s WHERE id=%s", (new_hash, user_id))
|
||||||
|
cur.execute("DELETE FROM password_reset_tokens WHERE token=%s", (token,))
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ routes/admin_dashboard.py — Admin dashboard: today's completion stats.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from flask import Blueprint, render_template
|
from flask import Blueprint, render_template
|
||||||
from models import get_admin_dashboard_stats
|
from models import get_admin_dashboard_stats, get_missed_shifts_today
|
||||||
from utils.decorators import admin_required
|
from utils.decorators import admin_required
|
||||||
|
|
||||||
logger = logging.getLogger("routes.admin_dashboard")
|
logger = logging.getLogger("routes.admin_dashboard")
|
||||||
@@ -19,4 +19,9 @@ def dashboard():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Dashboard stats error: {e}")
|
logger.error(f"Dashboard stats error: {e}")
|
||||||
stats = {"user_stats": [], "total_sites": 0, "total_users": 0, "active_today": 0}
|
stats = {"user_stats": [], "total_sites": 0, "total_users": 0, "active_today": 0}
|
||||||
return render_template("admin/dashboard.html", stats=stats)
|
try:
|
||||||
|
missed = get_missed_shifts_today()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Missed shifts error: {e}")
|
||||||
|
missed = []
|
||||||
|
return render_template("admin/dashboard.html", stats=stats, missed=missed)
|
||||||
|
|||||||
+64
-1
@@ -4,8 +4,13 @@ routes/auth.py — Authentication routes: login, logout, change-password.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from flask import Blueprint, render_template, request, session, redirect, url_for, flash
|
from flask import Blueprint, render_template, request, session, redirect, url_for, flash
|
||||||
from models import authenticate, check_login_allowed, change_password, log_action
|
from models import (
|
||||||
|
authenticate, check_login_allowed, change_password, log_action,
|
||||||
|
get_user_by_email, create_password_reset_token,
|
||||||
|
get_password_reset_user, consume_password_reset_token,
|
||||||
|
)
|
||||||
from utils.decorators import login_required
|
from utils.decorators import login_required
|
||||||
|
from utils.email import send_email
|
||||||
|
|
||||||
logger = logging.getLogger("routes.auth")
|
logger = logging.getLogger("routes.auth")
|
||||||
auth_bp = Blueprint("auth", __name__)
|
auth_bp = Blueprint("auth", __name__)
|
||||||
@@ -95,3 +100,61 @@ def ping():
|
|||||||
"""Keep-alive endpoint for the session timeout warning in app.js."""
|
"""Keep-alive endpoint for the session timeout warning in app.js."""
|
||||||
session.modified = True
|
session.modified = True
|
||||||
return "", 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/forgot-password", methods=["GET", "POST"])
|
||||||
|
def forgot_password():
|
||||||
|
if "user" in session:
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
if request.method == "GET":
|
||||||
|
return render_template("forgot_password.html")
|
||||||
|
|
||||||
|
email = request.form.get("email", "").strip().lower()
|
||||||
|
if email:
|
||||||
|
user = get_user_by_email(email)
|
||||||
|
if user and user.get("email"):
|
||||||
|
try:
|
||||||
|
token = create_password_reset_token(user["id"])
|
||||||
|
reset_url = url_for("auth.reset_password", token=token, _external=True)
|
||||||
|
body = (
|
||||||
|
f"Hi {user.get('full_name') or user['username']},\n\n"
|
||||||
|
f"A password reset was requested for your Website Checker account.\n"
|
||||||
|
f"Click the link below to set a new password (valid for 1 hour):\n\n"
|
||||||
|
f"{reset_url}\n\n"
|
||||||
|
f"If you did not request this, you can safely ignore this email.\n"
|
||||||
|
)
|
||||||
|
send_email(user["email"], "Website Checker — Password Reset", body)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Password reset email error for '{email}': {e}")
|
||||||
|
|
||||||
|
# Always show the same message to prevent user enumeration
|
||||||
|
flash("If that email is registered, a reset link has been sent.", "info")
|
||||||
|
return render_template("forgot_password.html")
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/reset-password/<token>", methods=["GET", "POST"])
|
||||||
|
def reset_password(token):
|
||||||
|
user = get_password_reset_user(token)
|
||||||
|
if not user:
|
||||||
|
flash("This reset link is invalid or has expired.", "danger")
|
||||||
|
return redirect(url_for("auth.forgot_password"))
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
return render_template("reset_password.html", token=token)
|
||||||
|
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
confirm = request.form.get("confirm_password", "")
|
||||||
|
|
||||||
|
if len(password) < 8:
|
||||||
|
flash("Password must be at least 8 characters.", "danger")
|
||||||
|
return render_template("reset_password.html", token=token)
|
||||||
|
if password != confirm:
|
||||||
|
flash("Passwords do not match.", "danger")
|
||||||
|
return render_template("reset_password.html", token=token)
|
||||||
|
|
||||||
|
if consume_password_reset_token(token, password):
|
||||||
|
logger.info(f"Password reset successfully for user id={user['id']}.")
|
||||||
|
flash("Password reset successfully. Please log in.", "success")
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
flash("Reset link expired or already used. Please request a new one.", "danger")
|
||||||
|
return redirect(url_for("auth.forgot_password"))
|
||||||
|
|||||||
+41
-2
@@ -8,9 +8,10 @@ from flask import (Blueprint, render_template, request, redirect, url_for,
|
|||||||
from models import (
|
from models import (
|
||||||
get_all_bids, get_bid, create_bid, update_bid, delete_bid,
|
get_all_bids, get_bid, create_bid, update_bid, delete_bid,
|
||||||
get_bid_updates, add_bid_update, delete_bid_update, BID_STATUSES,
|
get_bid_updates, add_bid_update, delete_bid_update, BID_STATUSES,
|
||||||
log_action,
|
log_action, get_bids_due_soon, get_admin_emails,
|
||||||
)
|
)
|
||||||
from utils.decorators import login_required
|
from utils.decorators import login_required, admin_required
|
||||||
|
from utils.email import send_email
|
||||||
|
|
||||||
logger = logging.getLogger("routes.bid_tracker")
|
logger = logging.getLogger("routes.bid_tracker")
|
||||||
bid_tracker_bp = Blueprint("bid_tracker", __name__, url_prefix="/bids")
|
bid_tracker_bp = Blueprint("bid_tracker", __name__, url_prefix="/bids")
|
||||||
@@ -237,3 +238,41 @@ def delete_update_json(update_id):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"delete_update_json error: {e}")
|
logger.error(f"delete_update_json error: {e}")
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bid_tracker_bp.route("/remind", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def send_reminders():
|
||||||
|
"""Send an email digest of bids due within 7 days to all admin users with emails."""
|
||||||
|
user = session["user"]
|
||||||
|
bids = get_bids_due_soon(days=7)
|
||||||
|
recipients = get_admin_emails()
|
||||||
|
|
||||||
|
if not bids:
|
||||||
|
flash("No active bids due within 7 days.", "info")
|
||||||
|
return redirect(url_for("bid_tracker.bids_list"))
|
||||||
|
|
||||||
|
if not recipients:
|
||||||
|
flash("No admin email addresses configured. Add them via Admin → Users.", "warning")
|
||||||
|
return redirect(url_for("bid_tracker.bids_list"))
|
||||||
|
|
||||||
|
lines = [f"Bid Deadline Reminder — {len(bids)} bid(s) due within 7 days:\n"]
|
||||||
|
for b in bids:
|
||||||
|
due = str(b["due_date"])[:10] if b.get("due_date") else "N/A"
|
||||||
|
line = f" • {b['title']} — Due: {due} — Status: {b['status']}"
|
||||||
|
if b.get("solicitation_number"):
|
||||||
|
line += f" — Sol#: {b['solicitation_number']}"
|
||||||
|
lines.append(line)
|
||||||
|
lines.append("\nLog in to Website Checker for full details.")
|
||||||
|
body = "\n".join(lines)
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_email(recipients, "Website Checker — Upcoming Bid Deadlines", body)
|
||||||
|
log_action(user["id"], "BID_REMIND", "bid_tracker", None,
|
||||||
|
f"Bid reminder email sent to {len(recipients)} admin(s), {len(bids)} bid(s) listed.")
|
||||||
|
flash(f"Reminder digest sent to {len(recipients)} recipient(s).", "success")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"send_reminders error: {e}")
|
||||||
|
flash(f"Failed to send email: {e}", "danger")
|
||||||
|
|
||||||
|
return redirect(url_for("bid_tracker.bids_list"))
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ routes/user_dashboard.py — Regular user: shift check dashboard.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
import requests as _http_req
|
||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
|
||||||
from models import (
|
from models import (
|
||||||
get_today_checks, mark_website_checked, unmark_website_checked,
|
get_today_checks, mark_website_checked, unmark_website_checked,
|
||||||
update_check_note, get_user_active_shifts, get_website_credentials,
|
update_check_note, get_user_active_shifts, get_website_credentials,
|
||||||
|
get_website_url,
|
||||||
)
|
)
|
||||||
from utils.decorators import login_required
|
from utils.decorators import login_required
|
||||||
|
|
||||||
@@ -91,3 +94,23 @@ def view_credentials(website_id):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"view_credentials error: {e}")
|
logger.error(f"view_credentials error: {e}")
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@user_dashboard_bp.route("/health/<int:website_id>")
|
||||||
|
@login_required
|
||||||
|
def health_check(website_id):
|
||||||
|
"""Server-side HEAD probe for a website's reachability."""
|
||||||
|
url = get_website_url(website_id)
|
||||||
|
if not url:
|
||||||
|
return jsonify({"status": "not_found"}), 404
|
||||||
|
try:
|
||||||
|
t0 = time.time()
|
||||||
|
resp = _http_req.head(url, timeout=6, allow_redirects=True,
|
||||||
|
headers={"User-Agent": "WebsiteChecker/1.0 (health-check)"})
|
||||||
|
ms = int((time.time() - t0) * 1000)
|
||||||
|
ok = resp.status_code < 400
|
||||||
|
return jsonify({"status": "ok" if ok else "error", "ms": ms})
|
||||||
|
except _http_req.exceptions.Timeout:
|
||||||
|
return jsonify({"status": "timeout", "ms": 6000})
|
||||||
|
except Exception:
|
||||||
|
return jsonify({"status": "unreachable", "ms": None})
|
||||||
|
|||||||
@@ -22,6 +22,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Missed shifts alert -->
|
||||||
|
{% if missed %}
|
||||||
|
<div class="card mt-3" style="border-color:var(--warning-border)">
|
||||||
|
<div class="card-header" style="background:var(--warning-bg)">
|
||||||
|
<span class="card-title" style="color:var(--warning)">⚠ Missed Shifts Today</span>
|
||||||
|
<span class="text-muted text-sm">Users with 0 sites checked so far</span>
|
||||||
|
</div>
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead><tr><th>Shift</th><th>User</th><th>Sites Checked</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in missed %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ m.shift_name }}</td>
|
||||||
|
<td>{{ m.full_name }}</td>
|
||||||
|
<td class="text-muted">{{ m.checked_sites }} / {{ m.total_sites }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Per-user completion table -->
|
<!-- Per-user completion table -->
|
||||||
<div class="card mt-4">
|
<div class="card mt-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
|
|||||||
@@ -3,9 +3,17 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title">Shift Management</h1>
|
<h1 class="page-title">Shift Management</h1>
|
||||||
<button class="btn btn-primary" onclick="openModal('modal-create-shift')">+ New Shift</button>
|
<div style="display:flex;gap:.5rem">
|
||||||
|
<button class="btn btn-ghost btn-sm tab-btn active" data-tab="tab-list" data-group="shifts-tabs">☰ List</button>
|
||||||
|
<button class="btn btn-ghost btn-sm tab-btn" data-tab="tab-calendar" data-group="shifts-tabs">📅 Calendar</button>
|
||||||
|
<button class="btn btn-primary" onclick="openModal('modal-create-shift')">+ New Shift</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="shifts-tabs">
|
||||||
|
|
||||||
|
<!-- List tab -->
|
||||||
|
<div class="tab-panel active" id="tab-list">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<table class="table table-hover">
|
<table class="table table-hover">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -34,6 +42,49 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calendar tab -->
|
||||||
|
<div class="tab-panel" id="tab-calendar">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span class="card-title">Weekly Schedule</span></div>
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="min-width:160px">Shift</th>
|
||||||
|
{% for label, _ in day_map %}
|
||||||
|
<th style="text-align:center;min-width:64px">{{ label }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in shifts if s.is_active %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ s.name }}</strong>
|
||||||
|
<div class="text-muted text-sm">{{ s.start_time|hhmm }}–{{ s.end_time|hhmm }}</div>
|
||||||
|
</td>
|
||||||
|
{% for _, day_val in day_map %}
|
||||||
|
<td style="text-align:center">
|
||||||
|
{% if day_val in s.days_of_week %}
|
||||||
|
<span class="badge badge-success" title="{{ s.name }} runs on this day">✓</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted" style="font-size:.9rem">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="8" class="text-center text-muted">No active shifts.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /shifts-tabs -->
|
||||||
|
|
||||||
<!-- Create Shift Modal -->
|
<!-- Create Shift Modal -->
|
||||||
<div class="modal-overlay" id="modal-create-shift">
|
<div class="modal-overlay" id="modal-create-shift">
|
||||||
|
|||||||
@@ -22,6 +22,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-secondary btn-sm" onclick="loadBids()">↻ Refresh</button>
|
<button class="btn btn-secondary btn-sm" onclick="loadBids()">↻ Refresh</button>
|
||||||
|
{% if session['user']['role'] == 'admin' %}
|
||||||
|
<form method="post" action="{{ url_for('bid_tracker.send_reminders') }}" style="display:inline">
|
||||||
|
<button class="btn btn-secondary btn-sm" type="submit" title="Email a digest of bids due within 7 days to admins">📧 Remind</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Split pane ─────────────────────────────────────────── -->
|
<!-- ── Split pane ─────────────────────────────────────────── -->
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Forgot Password — 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>Password Reset</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for cat, msg in messages %}
|
||||||
|
<div class="alert alert-{{ cat }} mb-2">{{ msg }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('auth.forgot_password') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email Address</label>
|
||||||
|
<input type="email" id="email" name="email"
|
||||||
|
autocomplete="email" autofocus required
|
||||||
|
placeholder="your@email.com">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary w-full" type="submit">Send Reset Link</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style="text-align:center;margin-top:1rem;font-size:.875rem">
|
||||||
|
<a href="{{ url_for('auth.login') }}" style="color:var(--accent)">Back to sign in</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -34,6 +34,10 @@
|
|||||||
<button class="btn btn-primary" type="submit">Sign In</button>
|
<button class="btn btn-primary" type="submit">Sign In</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p style="text-align:center;margin-top:1rem;font-size:.875rem">
|
||||||
|
<a href="{{ url_for('auth.forgot_password') }}" style="color:var(--accent)">Forgot password?</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Set New Password — 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>Set New Password</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for cat, msg in messages %}
|
||||||
|
<div class="alert alert-{{ cat }} mb-2">{{ msg }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('auth.reset_password', token=token) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">New Password</label>
|
||||||
|
<input type="password" id="password" name="password"
|
||||||
|
autofocus required minlength="8"
|
||||||
|
placeholder="Minimum 8 characters">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirm_password">Confirm Password</label>
|
||||||
|
<input type="password" id="confirm_password" name="confirm_password"
|
||||||
|
required placeholder="Repeat new password">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary w-full" type="submit">Set New Password</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style="text-align:center;margin-top:1rem;font-size:.875rem">
|
||||||
|
<a href="{{ url_for('auth.login') }}" style="color:var(--accent)">Back to sign in</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -381,25 +381,29 @@ function esc(str) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Health dots ───────────────────────────────────────────── */
|
/* ── Health dots (server-side probe) ───────────────────────── */
|
||||||
document.querySelectorAll('.site-card').forEach(function(card) {
|
document.querySelectorAll('.site-card').forEach(function(card) {
|
||||||
var id = card.dataset.id;
|
var id = card.dataset.id;
|
||||||
var url = card.dataset.url;
|
|
||||||
var dot = document.getElementById('health-' + id);
|
var dot = document.getElementById('health-' + id);
|
||||||
if (!dot || !url) return;
|
if (!dot) return;
|
||||||
var img = new Image();
|
fetch('/dashboard/health/' + id, {credentials: 'same-origin'})
|
||||||
var t0 = Date.now();
|
.then(function(r) { return r.json(); })
|
||||||
img.onload = function() {
|
.then(function(d) {
|
||||||
var ms = Date.now() - t0;
|
if (d.status === 'ok') {
|
||||||
dot.style.color = ms > 3000 ? '#d97706' : '#16a34a';
|
dot.style.color = d.ms > 3000 ? '#d97706' : '#16a34a';
|
||||||
dot.title = ms > 3000 ? 'Slow (' + ms + 'ms)' : 'Reachable (' + ms + 'ms)';
|
dot.title = 'Reachable (' + d.ms + 'ms)';
|
||||||
};
|
} else if (d.status === 'timeout') {
|
||||||
img.onerror = function() {
|
dot.style.color = '#d97706';
|
||||||
var ms = Date.now() - t0;
|
dot.title = 'Timeout (>6s)';
|
||||||
dot.style.color = ms < 6000 ? '#d97706' : '#dc2626';
|
} else {
|
||||||
dot.title = ms < 6000 ? 'Reachable (restricted)' : 'Unreachable';
|
dot.style.color = '#dc2626';
|
||||||
};
|
dot.title = 'Unreachable';
|
||||||
img.src = 'https://www.google.com/s2/favicons?domain=' + encodeURIComponent(url) + '&t=' + Date.now();
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
dot.style.color = '#9ca3af';
|
||||||
|
dot.title = 'Health check failed';
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
utils/email.py — SMTP email helper.
|
||||||
|
Reads server settings from app_settings (email.smtp_*) with .env fallbacks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from config import get_setting
|
||||||
|
|
||||||
|
logger = logging.getLogger("utils.email")
|
||||||
|
|
||||||
|
|
||||||
|
def send_email(to, subject: str, body_text: str, body_html: str = None) -> None:
|
||||||
|
"""Send email via configured SMTP. `to` may be a str or list[str]. Raises on failure."""
|
||||||
|
host = get_setting("email.smtp_host", "").strip()
|
||||||
|
port = get_setting("email.smtp_port", "587").strip()
|
||||||
|
user = get_setting("email.smtp_user", "").strip()
|
||||||
|
passwd = get_setting("email.smtp_password", "").strip()
|
||||||
|
from_ = get_setting("email.smtp_from", "").strip() or user
|
||||||
|
|
||||||
|
if not host:
|
||||||
|
raise ValueError("SMTP host not configured. Add it via Admin → Settings.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
port = int(port)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
port = 587
|
||||||
|
|
||||||
|
recipients = [to] if isinstance(to, str) else list(to)
|
||||||
|
if not recipients:
|
||||||
|
raise ValueError("No recipients specified.")
|
||||||
|
|
||||||
|
if body_html:
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg.attach(MIMEText(body_text, "plain"))
|
||||||
|
msg.attach(MIMEText(body_html, "html"))
|
||||||
|
else:
|
||||||
|
msg = MIMEText(body_text, "plain")
|
||||||
|
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = from_
|
||||||
|
msg["To"] = ", ".join(recipients)
|
||||||
|
|
||||||
|
with smtplib.SMTP(host, port, timeout=15) as smtp:
|
||||||
|
smtp.ehlo()
|
||||||
|
try:
|
||||||
|
smtp.starttls()
|
||||||
|
smtp.ehlo()
|
||||||
|
except smtplib.SMTPException:
|
||||||
|
pass # server may not support STARTTLS
|
||||||
|
if user and passwd:
|
||||||
|
smtp.login(user, passwd)
|
||||||
|
smtp.sendmail(from_, recipients, msg.as_string())
|
||||||
|
|
||||||
|
logger.info("Email sent to %s: %r", recipients, subject)
|
||||||
Reference in New Issue
Block a user