Jun 29 - Implement auto reminder 45 min before user's shift end if not finish checking websites
This commit is contained in:
@@ -17,6 +17,46 @@ logging.basicConfig(
|
|||||||
logger = logging.getLogger("app")
|
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():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
@@ -73,10 +113,12 @@ def create_app():
|
|||||||
|
|
||||||
if not os.environ.get("CRON_SECRET"):
|
if not os.environ.get("CRON_SECRET"):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"CRON_SECRET not set in .env — the /internal/cron/* endpoints are "
|
"CRON_SECRET not set in .env — the /internal/cron/* endpoints are disabled."
|
||||||
"disabled. Set CRON_SECRET to enable automated shift reminders."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Background scheduler: automatic shift-incomplete reminders ────────────
|
||||||
|
_start_scheduler()
|
||||||
|
|
||||||
# ── CSRF protection (Flask-WTF) ────────────────────────────────────────────
|
# ── CSRF protection (Flask-WTF) ────────────────────────────────────────────
|
||||||
from flask_wtf.csrf import CSRFProtect
|
from flask_wtf.csrf import CSRFProtect
|
||||||
CSRFProtect(app)
|
CSRFProtect(app)
|
||||||
|
|||||||
@@ -1771,8 +1771,11 @@ def get_incomplete_shift_users_near_end(window_minutes: int = 40) -> list:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def record_shift_reminder(user_id: int, shift_id: int) -> None:
|
def record_shift_reminder(user_id: int, shift_id: int) -> int:
|
||||||
"""Mark that a reminder was sent to this user for this shift today (idempotent)."""
|
"""
|
||||||
|
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
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
@@ -1783,7 +1786,9 @@ def record_shift_reminder(user_id: int, shift_id: int) -> None:
|
|||||||
(user_id, shift_id),
|
(user_id, shift_id),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
rows = cur.rowcount
|
||||||
cur.close()
|
cur.close()
|
||||||
|
return rows
|
||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ bcrypt==4.1.3
|
|||||||
pypdf==4.3.1
|
pypdf==4.3.1
|
||||||
python-docx==1.1.2
|
python-docx==1.1.2
|
||||||
openpyxl==3.1.5
|
openpyxl==3.1.5
|
||||||
|
APScheduler>=3.10,<4
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ def edit(shift_id):
|
|||||||
return redirect(url_for("admin_shifts.shifts_list"))
|
return redirect(url_for("admin_shifts.shifts_list"))
|
||||||
|
|
||||||
|
|
||||||
REMINDER_WINDOW_MIN = 40
|
REMINDER_WINDOW_MIN = 45
|
||||||
|
|
||||||
|
|
||||||
def _fmt_time(t) -> str:
|
def _fmt_time(t) -> str:
|
||||||
@@ -144,6 +144,11 @@ def do_send_incomplete_reminders(triggered_by_user_id=None) -> dict:
|
|||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
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"])
|
end_str = _fmt_time(row["end_time"])
|
||||||
site_lines = "\n".join(
|
site_lines = "\n".join(
|
||||||
f" • {s['name']} — {s['url']}" for s in row["unchecked_sites"]
|
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}",
|
f"Action required — complete your shift checks by {end_str}",
|
||||||
body,
|
body,
|
||||||
)
|
)
|
||||||
record_shift_reminder(row["user_id"], row["shift_id"])
|
|
||||||
log_action(
|
log_action(
|
||||||
triggered_by_user_id, "SHIFT_INCOMPLETE_REMINDER", "shifts", row["shift_id"],
|
triggered_by_user_id, "SHIFT_INCOMPLETE_REMINDER", "shifts", row["shift_id"],
|
||||||
f"Reminder sent to {row['username']} ({row['email']}): "
|
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_shifts_bp.route("/send-incomplete-reminders", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def send_incomplete_reminders():
|
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"])
|
result = do_send_incomplete_reminders(triggered_by_user_id=session["user"]["id"])
|
||||||
|
|
||||||
if result["total"] == 0:
|
if result["total"] == 0:
|
||||||
|
|||||||
Reference in New Issue
Block a user