From 56a256633b93bf313d54231665eb6130a9431389 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 29 Jun 2026 17:03:07 -0400 Subject: [PATCH] Jun 29 - Implement auto reminder 45 min before user's shift end if not finish checking websites --- app.py | 46 ++++++++++++++++++++++++++++++++++++++++-- models.py | 9 +++++++-- requirements.txt | 1 + routes/admin_shifts.py | 10 ++++++--- 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/app.py b/app.py index ae1e03c..dbfe18e 100644 --- a/app.py +++ b/app.py @@ -17,6 +17,46 @@ logging.basicConfig( logger = logging.getLogger("app") +def _run_reminder_job(): + """Background job: send shift-incomplete reminders if within the reminder window.""" + from routes.admin_shifts import do_send_incomplete_reminders + try: + result = do_send_incomplete_reminders(triggered_by_user_id=None) + if result["sent"] > 0: + logger.info(f"[scheduler] Shift reminders sent: {result}") + except Exception as e: + logger.error(f"[scheduler] Shift reminder job error: {e}") + + +def _start_scheduler(): + """ + Start a background scheduler that fires shift-incomplete reminder emails + every 5 minutes. Guards against double-start in Flask's debug reloader. + Uses daemon threads so the process exits cleanly even if the scheduler is running. + """ + if os.environ.get("WERKZEUG_RUN_MAIN") == "false": + # Flask reloader: skip in the parent monitor process; the child sets WERKZEUG_RUN_MAIN=true + return + try: + from apscheduler.schedulers.background import BackgroundScheduler + import atexit + + scheduler = BackgroundScheduler(daemon=True) + scheduler.add_job( + func=_run_reminder_job, + trigger="interval", + minutes=5, + id="shift_incomplete_reminders", + max_instances=1, + misfire_grace_time=120, + ) + scheduler.start() + atexit.register(lambda: scheduler.shutdown(wait=False)) + logger.info("[scheduler] Background scheduler started — shift reminders every 5 min.") + except Exception as e: + logger.error(f"[scheduler] Failed to start background scheduler: {e}") + + def create_app(): app = Flask(__name__) @@ -73,10 +113,12 @@ def create_app(): 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." + "CRON_SECRET not set in .env — the /internal/cron/* endpoints are disabled." ) + # ── Background scheduler: automatic shift-incomplete reminders ──────────── + _start_scheduler() + # ── CSRF protection (Flask-WTF) ──────────────────────────────────────────── from flask_wtf.csrf import CSRFProtect CSRFProtect(app) diff --git a/models.py b/models.py index 814cd63..968af23 100644 --- a/models.py +++ b/models.py @@ -1771,8 +1771,11 @@ def get_incomplete_shift_users_near_end(window_minutes: int = 40) -> list: 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).""" +def record_shift_reminder(user_id: int, shift_id: int) -> int: + """ + Atomically claim a reminder slot for this user+shift today via INSERT IGNORE. + Returns 1 if the slot was claimed (caller should send), 0 if already claimed. + """ conn = None try: conn = get_connection() @@ -1783,7 +1786,9 @@ def record_shift_reminder(user_id: int, shift_id: int) -> None: (user_id, shift_id), ) conn.commit() + rows = cur.rowcount cur.close() + return rows finally: if conn: conn.close() diff --git a/requirements.txt b/requirements.txt index 5d5655e..aa1a16c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ bcrypt==4.1.3 pypdf==4.3.1 python-docx==1.1.2 openpyxl==3.1.5 +APScheduler>=3.10,<4 diff --git a/routes/admin_shifts.py b/routes/admin_shifts.py index 846d439..8475595 100644 --- a/routes/admin_shifts.py +++ b/routes/admin_shifts.py @@ -118,7 +118,7 @@ def edit(shift_id): return redirect(url_for("admin_shifts.shifts_list")) -REMINDER_WINDOW_MIN = 40 +REMINDER_WINDOW_MIN = 45 def _fmt_time(t) -> str: @@ -144,6 +144,11 @@ def do_send_incomplete_reminders(triggered_by_user_id=None) -> dict: skipped += 1 continue + # Atomically claim this reminder slot before sending. + # If another worker already claimed it (rowcount == 0), skip to avoid duplicates. + if not record_shift_reminder(row["user_id"], row["shift_id"]): + continue + end_str = _fmt_time(row["end_time"]) site_lines = "\n".join( f" • {s['name']} — {s['url']}" for s in row["unchecked_sites"] @@ -163,7 +168,6 @@ def do_send_incomplete_reminders(triggered_by_user_id=None) -> dict: 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']}): " @@ -180,7 +184,7 @@ def do_send_incomplete_reminders(triggered_by_user_id=None) -> dict: @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.""" + """Manual trigger: email staff with incomplete checks in shifts ending within 45 min.""" result = do_send_incomplete_reminders(triggered_by_user_id=session["user"]["id"]) if result["total"] == 0: