June 25 - Implement reminder
This commit is contained in:
@@ -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("/<int:shift_id>/delete", methods=["POST"])
|
||||
@admin_required
|
||||
def delete(shift_id):
|
||||
|
||||
@@ -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=<CRON_SECRET>"
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user