June 25 - Implement reminder
This commit is contained in:
@@ -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] 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] 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=<CRON_SECRET>`; `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
|
### Pending
|
||||||
- No known pending items. Add new items here as they are identified.
|
- No known pending items. Add new items here as they are identified.
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ def create_app():
|
|||||||
from routes.user_dashboard import user_dashboard_bp
|
from routes.user_dashboard import user_dashboard_bp
|
||||||
from routes.ai_summary import ai_summary_bp
|
from routes.ai_summary import ai_summary_bp
|
||||||
from routes.bid_tracker import bid_tracker_bp
|
from routes.bid_tracker import bid_tracker_bp
|
||||||
|
from routes.internal import internal_bp
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(admin_dashboard_bp)
|
app.register_blueprint(admin_dashboard_bp)
|
||||||
@@ -68,6 +69,13 @@ def create_app():
|
|||||||
app.register_blueprint(user_dashboard_bp)
|
app.register_blueprint(user_dashboard_bp)
|
||||||
app.register_blueprint(ai_summary_bp)
|
app.register_blueprint(ai_summary_bp)
|
||||||
app.register_blueprint(bid_tracker_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) ────────────────────────────────────────────
|
# ── CSRF protection (Flask-WTF) ────────────────────────────────────────────
|
||||||
from flask_wtf.csrf import CSRFProtect
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
|||||||
@@ -329,6 +329,17 @@ def initialize_database():
|
|||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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
|
conn = None
|
||||||
|
|||||||
@@ -1573,6 +1573,110 @@ def get_missed_shifts_today() -> list:
|
|||||||
conn.close()
|
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 ────────────────────────────────────────────────────────────
|
# ─── Bid Reminders ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_bids_due_soon(days: int = 7) -> list:
|
def get_bids_due_soon(days: int = 7) -> list:
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ from models import (
|
|||||||
get_all_shifts, get_shift_by_id, get_shift_assigned_users,
|
get_all_shifts, get_shift_by_id, get_shift_assigned_users,
|
||||||
get_shift_assigned_websites, create_shift, update_shift, delete_shift,
|
get_shift_assigned_websites, create_shift, update_shift, delete_shift,
|
||||||
get_all_users, get_all_websites,
|
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.decorators import admin_required
|
||||||
|
from utils.email import send_email
|
||||||
|
|
||||||
logger = logging.getLogger("routes.admin_shifts")
|
logger = logging.getLogger("routes.admin_shifts")
|
||||||
admin_shifts_bp = Blueprint("admin_shifts", __name__, url_prefix="/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"))
|
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_shifts_bp.route("/<int:shift_id>/delete", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def delete(shift_id):
|
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
|
||||||
@@ -60,7 +60,13 @@
|
|||||||
<div class="card mt-4">
|
<div class="card mt-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2 class="card-title">Today's Completion</h2>
|
<h2 class="card-title">Today's Completion</h2>
|
||||||
|
<div class="flex gap-1">
|
||||||
<button class="btn btn-ghost btn-sm" onclick="location.reload()">↻ Refresh</button>
|
<button class="btn btn-ghost btn-sm" onclick="location.reload()">↻ Refresh</button>
|
||||||
|
<form method="post" action="{{ url_for('admin_shifts.send_incomplete_reminders') }}" style="display:inline"
|
||||||
|
onsubmit="return confirm('Send reminder emails to all staff with incomplete checks in shifts ending within 40 minutes?')">
|
||||||
|
<button class="btn btn-secondary btn-sm" type="submit">📧 Remind Incomplete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
Reference in New Issue
Block a user