diff --git a/CLAUDE.md b/CLAUDE.md index 5c0162b..5ad7244 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -366,5 +366,7 @@ All planned items have been implemented. The list below serves as a record and c - [x] User profile page — `GET/POST /profile`; users edit own `full_name` and `email`; session updated immediately; "👤 My Profile" in sidebar - [x] SMTP From Address field — `email.smtp_from` exposed in Admin → Settings form and saved alongside other email fields +- [x] Incomplete-shift reminder emails — manual `POST /admin/shifts/send-incomplete-reminders` + automated `GET /internal/cron/shift-reminders?token=`; `get_incomplete_shift_users_near_end()` + `record_shift_reminder()` in `models.py`; `shift_reminder_log` table deduplicates to one email per user/shift/day; `do_send_incomplete_reminders()` shared helper in `admin_shifts.py`; systemd timer fires every 5 min + ### Pending - No known pending items. Add new items here as they are identified. diff --git a/app.py b/app.py index cc96e49..a500f28 100644 --- a/app.py +++ b/app.py @@ -56,6 +56,7 @@ def create_app(): from routes.user_dashboard import user_dashboard_bp from routes.ai_summary import ai_summary_bp from routes.bid_tracker import bid_tracker_bp + from routes.internal import internal_bp app.register_blueprint(auth_bp) app.register_blueprint(admin_dashboard_bp) @@ -68,6 +69,13 @@ def create_app(): app.register_blueprint(user_dashboard_bp) app.register_blueprint(ai_summary_bp) app.register_blueprint(bid_tracker_bp) + app.register_blueprint(internal_bp) + + if not os.environ.get("CRON_SECRET"): + logger.warning( + "CRON_SECRET not set in .env — the /internal/cron/* endpoints are " + "disabled. Set CRON_SECRET to enable automated shift reminders." + ) # ── CSRF protection (Flask-WTF) ──────────────────────────────────────────── from flask_wtf.csrf import CSRFProtect diff --git a/config.py b/config.py index 5e4d8f9..0ae8264 100644 --- a/config.py +++ b/config.py @@ -329,6 +329,17 @@ def initialize_database(): updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, + """ + CREATE TABLE IF NOT EXISTS shift_reminder_log ( + user_id INT NOT NULL, + shift_id INT NOT NULL, + sent_date DATE NOT NULL, + sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, shift_id, sent_date), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (shift_id) REFERENCES shifts(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """, ] conn = None diff --git a/models.py b/models.py index dc704a1..5698eb8 100644 --- a/models.py +++ b/models.py @@ -1573,6 +1573,110 @@ def get_missed_shifts_today() -> list: conn.close() +# ─── Shift Incomplete Reminders ─────────────────────────────────────────────── + +def get_incomplete_shift_users_near_end(window_minutes: int = 40) -> list: + """ + Return users in today's active shifts whose end_time falls within the next + `window_minutes` minutes and who still have at least one unchecked website. + + Each dict has: user_id, username, full_name, email, shift_id, shift_name, + end_time, total_count, checked_count, unchecked_count, + unchecked_sites (list of {id, name, url}). + """ + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + """ + SELECT + u.id AS user_id, + u.username, + COALESCE(u.full_name, u.username) AS full_name, + u.email, + s.id AS shift_id, + s.name AS shift_name, + s.end_time, + COUNT(DISTINCT sw.website_id) AS total_count, + COUNT(DISTINCT sc.website_id) AS checked_count, + COUNT(DISTINCT sw.website_id) - COUNT(DISTINCT sc.website_id) AS unchecked_count + 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 + JOIN websites w ON w.id = sw.website_id AND w.is_active = 1 + LEFT JOIN shift_checks sc + ON sc.website_id = sw.website_id + AND sc.user_id = su.user_id + AND DATE(sc.checked_at) = CURDATE() + WHERE s.is_active = 1 + AND LOCATE(CAST(DAYOFWEEK(CURDATE()) AS CHAR), s.days_of_week) > 0 + AND s.end_time > CURTIME() + AND s.end_time <= ADDTIME(CURTIME(), SEC_TO_TIME(%s * 60)) + AND NOT EXISTS ( + SELECT 1 FROM shift_reminder_log srl + WHERE srl.user_id = su.user_id + AND srl.shift_id = s.id + AND srl.sent_date = CURDATE() + ) + GROUP BY u.id, s.id + HAVING unchecked_count > 0 + ORDER BY s.end_time, u.username + """, + (window_minutes,) + ) + rows = cur.fetchall() + if not rows: + cur.close() + return [] + + # Attach the list of unchecked site names/URLs for each user+shift pair. + result = [] + for row in rows: + cur.execute( + """ + SELECT w.id, w.name, w.url + FROM shift_websites sw + JOIN websites w ON w.id = sw.website_id AND w.is_active = 1 + WHERE sw.shift_id = %s + AND NOT EXISTS ( + SELECT 1 FROM shift_checks sc + WHERE sc.website_id = sw.website_id + AND sc.user_id = %s + AND DATE(sc.checked_at) = CURDATE() + ) + ORDER BY sw.sort_order, w.name + """, + (row["shift_id"], row["user_id"]) + ) + result.append({**row, "unchecked_sites": cur.fetchall()}) + + cur.close() + return result + finally: + if conn: + conn.close() + + +def record_shift_reminder(user_id: int, shift_id: int) -> None: + """Mark that a reminder was sent to this user for this shift today (idempotent).""" + conn = None + try: + conn = get_connection() + cur = conn.cursor() + cur.execute( + "INSERT IGNORE INTO shift_reminder_log (user_id, shift_id, sent_date) " + "VALUES (%s, %s, CURDATE())", + (user_id, shift_id), + ) + conn.commit() + cur.close() + finally: + if conn: + conn.close() + + # ─── Bid Reminders ──────────────────────────────────────────────────────────── def get_bids_due_soon(days: int = 7) -> list: diff --git a/routes/admin_shifts.py b/routes/admin_shifts.py index af93f5a..6af2748 100644 --- a/routes/admin_shifts.py +++ b/routes/admin_shifts.py @@ -8,8 +8,10 @@ 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, + get_incomplete_shift_users_near_end, record_shift_reminder, log_action, ) from utils.decorators import admin_required +from utils.email import send_email logger = logging.getLogger("routes.admin_shifts") admin_shifts_bp = Blueprint("admin_shifts", __name__, url_prefix="/admin/shifts") @@ -116,6 +118,82 @@ def edit(shift_id): return redirect(url_for("admin_shifts.shifts_list")) +REMINDER_WINDOW_MIN = 40 + + +def _fmt_time(t) -> str: + if hasattr(t, "seconds"): + h, rem = divmod(int(t.total_seconds()), 3600) + return f"{h:02d}:{rem // 60:02d}" + return str(t)[:5] + + +def do_send_incomplete_reminders(triggered_by_user_id=None) -> dict: + """ + Send reminder emails to all staff in shifts ending within REMINDER_WINDOW_MIN + minutes who still have unchecked sites (and haven't already been reminded today). + + Returns {"sent": int, "skipped": int, "total": int}. + Called by both the manual admin button and the automated cron endpoint. + """ + pending = get_incomplete_shift_users_near_end(window_minutes=REMINDER_WINDOW_MIN) + sent = skipped = 0 + + for row in pending: + if not row.get("email"): + skipped += 1 + continue + + end_str = _fmt_time(row["end_time"]) + site_lines = "\n".join( + f" • {s['name']} — {s['url']}" for s in row["unchecked_sites"] + ) + body = ( + f"Hi {row['full_name']},\n\n" + f"Your shift \"{row['shift_name']}\" ends at {end_str}.\n" + f"You still have {row['unchecked_count']} of {row['total_count']} " + f"website(s) left to check:\n\n" + f"{site_lines}\n\n" + f"Please complete your checks before the shift ends.\n" + f"Log in to Website Checker to mark them done." + ) + try: + send_email( + row["email"], + f"Action required — complete your shift checks by {end_str}", + body, + ) + record_shift_reminder(row["user_id"], row["shift_id"]) + log_action( + triggered_by_user_id, "SHIFT_INCOMPLETE_REMINDER", "shifts", row["shift_id"], + f"Reminder sent to {row['username']} ({row['email']}): " + f"{row['unchecked_count']}/{row['total_count']} unchecked in '{row['shift_name']}'.", + ) + sent += 1 + except Exception as e: + logger.error(f"do_send_incomplete_reminders: failed to email {row['email']}: {e}") + skipped += 1 + + return {"sent": sent, "skipped": skipped, "total": len(pending)} + + +@admin_shifts_bp.route("/send-incomplete-reminders", methods=["POST"]) +@admin_required +def send_incomplete_reminders(): + """Manual trigger: email staff with incomplete checks in shifts ending within 40 min.""" + result = do_send_incomplete_reminders(triggered_by_user_id=session["user"]["id"]) + + if result["total"] == 0: + flash(f"No staff in shifts ending within {REMINDER_WINDOW_MIN} minutes with incomplete checks.", "info") + else: + if result["sent"]: + flash(f"Reminder sent to {result['sent']} staff member(s) with incomplete checks.", "success") + if result["skipped"]: + flash(f"{result['skipped']} staff member(s) skipped (no email or send error).", "warning") + + return redirect(url_for("admin_dashboard.dashboard")) + + @admin_shifts_bp.route("//delete", methods=["POST"]) @admin_required def delete(shift_id): diff --git a/routes/internal.py b/routes/internal.py new file mode 100644 index 0000000..9de89f4 --- /dev/null +++ b/routes/internal.py @@ -0,0 +1,53 @@ +""" +routes/internal.py — Machine-callable endpoints for cron jobs. + +Protected by CRON_SECRET (set in .env). No session auth required. +These routes are intentionally GET so they work with a plain `curl` call +from a systemd timer or crontab without needing CSRF tokens. +""" + +import hmac +import logging +import os + +from flask import Blueprint, jsonify, request + +logger = logging.getLogger("routes.internal") + +internal_bp = Blueprint("internal", __name__, url_prefix="/internal") + + +def _check_token() -> bool: + """Return True if the request carries the correct CRON_SECRET.""" + expected = os.environ.get("CRON_SECRET", "").strip() + if not expected: + logger.warning("CRON_SECRET not set — internal endpoints are disabled.") + return False + provided = request.args.get("token", "") + return hmac.compare_digest(provided, expected) + + +@internal_bp.route("/cron/shift-reminders") +def cron_shift_reminders(): + """ + Called by a systemd timer (or any scheduler) every few minutes. + Sends reminder emails to staff with incomplete checks 40 min before shift end. + Each user+shift pair receives at most one reminder per calendar day. + + Usage: + curl "https://your-server/internal/cron/shift-reminders?token=" + """ + if not _check_token(): + return jsonify({"error": "unauthorized"}), 401 + + from routes.admin_shifts import do_send_incomplete_reminders + try: + result = do_send_incomplete_reminders(triggered_by_user_id=None) + logger.info( + f"cron/shift-reminders: sent={result['sent']} skipped={result['skipped']} " + f"total={result['total']}" + ) + return jsonify(result) + except Exception as e: + logger.error(f"cron/shift-reminders error: {e}") + return jsonify({"error": str(e)}), 500 diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index b3838b9..60a96b3 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -60,7 +60,13 @@

Today's Completion

- +
+ +
+ +
+