diff --git a/CLAUDE.md b/CLAUDE.md
index 501ca39..2d6eabc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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 |
| `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 |
-| `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/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/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 `
` 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
### 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
-- [ ] **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
-- [ ] **Server-side health checks** — Replace the Google favicon proxy in the user dashboard with a `/dashboard/health/` route that makes a server-side `HEAD` request (with short timeout) for a real reachability signal
-- [ ] **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
-- [ ] **"Copy password" button in Credentials modal** — Wire `copyToClipboard()` (already in `app.js`) to the password field in the user dashboard credentials modal
-- [ ] **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] **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
+- [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`
+- [x] **Server-side health checks** — `/dashboard/health/` in `user_dashboard.py` does a server-side HEAD request; user dashboard JS updated to call this instead of the Google favicon proxy
+- [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/` routes in `auth.py`; standalone templates `forgot_password.html` and `reset_password.html`; "Forgot password?" link on login page; uses `utils/email.py`
+- [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`
+- [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
diff --git a/config.py b/config.py
index 1d174c3..5e4d8f9 100644
--- a/config.py
+++ b/config.py
@@ -311,6 +311,18 @@ def initialize_database():
) 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 (
key_name VARCHAR(100) NOT NULL PRIMARY KEY,
value TEXT NULL,
diff --git a/models.py b/models.py
index 44d556d..3865bf1 100644
--- a/models.py
+++ b/models.py
@@ -1480,3 +1480,191 @@ def purge_app_log(older_than_days: int = 30):
finally:
if conn:
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()
diff --git a/routes/admin_dashboard.py b/routes/admin_dashboard.py
index 2112e34..1c273e9 100644
--- a/routes/admin_dashboard.py
+++ b/routes/admin_dashboard.py
@@ -4,7 +4,7 @@ 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 models import get_admin_dashboard_stats, get_missed_shifts_today
from utils.decorators import admin_required
logger = logging.getLogger("routes.admin_dashboard")
@@ -19,4 +19,9 @@ def dashboard():
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)
+ 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)
diff --git a/routes/auth.py b/routes/auth.py
index ff9ece7..bd6bedb 100644
--- a/routes/auth.py
+++ b/routes/auth.py
@@ -4,8 +4,13 @@ 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 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.email import send_email
logger = logging.getLogger("routes.auth")
auth_bp = Blueprint("auth", __name__)
@@ -95,3 +100,61 @@ def ping():
"""Keep-alive endpoint for the session timeout warning in app.js."""
session.modified = True
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/", 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"))
diff --git a/routes/bid_tracker.py b/routes/bid_tracker.py
index 08c348c..007f2a5 100644
--- a/routes/bid_tracker.py
+++ b/routes/bid_tracker.py
@@ -8,9 +8,10 @@ from flask import (Blueprint, render_template, request, redirect, url_for,
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,
- 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")
bid_tracker_bp = Blueprint("bid_tracker", __name__, url_prefix="/bids")
@@ -237,3 +238,41 @@ def delete_update_json(update_id):
except Exception as e:
logger.error(f"delete_update_json error: {e}")
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"))
diff --git a/routes/user_dashboard.py b/routes/user_dashboard.py
index dd28816..f806d7b 100644
--- a/routes/user_dashboard.py
+++ b/routes/user_dashboard.py
@@ -3,10 +3,13 @@ routes/user_dashboard.py — Regular user: shift check dashboard.
"""
import logging
+import time
+import requests as _http_req
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,
+ get_website_url,
)
from utils.decorators import login_required
@@ -91,3 +94,23 @@ def view_credentials(website_id):
except Exception as e:
logger.error(f"view_credentials error: {e}")
return jsonify({"error": str(e)}), 500
+
+
+@user_dashboard_bp.route("/health/")
+@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})
diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html
index 387b7e0..0ee6b79 100644
--- a/templates/admin/dashboard.html
+++ b/templates/admin/dashboard.html
@@ -22,6 +22,28 @@
+
+{% if missed %}
+
+
+ ⚠ Missed Shifts Today
+ Users with 0 sites checked so far
+