Files

214 lines
8.0 KiB
Python

"""
routes/admin_shifts.py — Shift management CRUD routes.
"""
import logging
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
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")
# MySQL DAYOFWEEK: 1=Sun 2=Mon … 7=Sat
DAY_MAP = [
("Mon", "2"), ("Tue", "3"), ("Wed", "4"),
("Thu", "5"), ("Fri", "6"), ("Sat", "7"), ("Sun", "1"),
]
@admin_shifts_bp.route("/")
@admin_required
def shifts_list():
shifts = get_all_shifts()
all_users = get_all_users()
all_sites = get_all_websites()
return render_template("admin/shifts.html",
shifts=shifts, all_users=all_users,
all_sites=all_sites, day_map=DAY_MAP)
@admin_shifts_bp.route("/<int:shift_id>/detail")
@admin_required
def detail(shift_id):
shift = get_shift_by_id(shift_id)
users = get_shift_assigned_users(shift_id)
websites = get_shift_assigned_websites(shift_id)
if not shift:
return jsonify({"error": "Not found"}), 404
# Convert time objects to HH:MM strings for form inputs
def _fmt(t):
if t is None:
return ""
if hasattr(t, "seconds"): # timedelta from MySQL
h, rem = divmod(int(t.total_seconds()), 3600)
return f"{h:02d}:{rem // 60:02d}"
return str(t)[:5]
return jsonify({
"id": shift["id"],
"name": shift["name"],
"days_of_week": shift["days_of_week"],
"start_time": _fmt(shift["start_time"]),
"end_time": _fmt(shift["end_time"]),
"note": shift["note"] or "",
"is_active": shift["is_active"],
"user_ids": [u["id"] for u in users],
"website_ids": [w["id"] for w in websites],
})
@admin_shifts_bp.route("/create", methods=["POST"])
@admin_required
def create():
admin_id = session["user"]["id"]
name = request.form.get("name", "").strip()
days_of_week = "".join(request.form.getlist("days_of_week[]"))
start_time = request.form.get("start_time", "08:00")
end_time = request.form.get("end_time", "17:00")
note = request.form.get("note", "").strip()
user_ids = [int(x) for x in request.form.getlist("user_ids[]") if x]
website_ids = [int(x) for x in request.form.getlist("website_ids[]") if x]
if not name:
flash("Shift name is required.", "danger")
return redirect(url_for("admin_shifts.shifts_list"))
try:
create_shift(admin_id, name, days_of_week, start_time, end_time,
note, user_ids, website_ids)
flash(f"Shift '{name}' created successfully.", "success")
logger.info(f"Shift '{name}' created by admin_id={admin_id}.")
except Exception as e:
logger.error(f"create_shift error: {e}")
flash(f"Error creating shift: {e}", "danger")
return redirect(url_for("admin_shifts.shifts_list"))
@admin_shifts_bp.route("/<int:shift_id>/edit", methods=["POST"])
@admin_required
def edit(shift_id):
admin_id = session["user"]["id"]
name = request.form.get("name", "").strip()
days_of_week = "".join(request.form.getlist("days_of_week[]"))
start_time = request.form.get("start_time", "08:00")
end_time = request.form.get("end_time", "17:00")
note = request.form.get("note", "").strip()
is_active = int(request.form.get("is_active", 1))
user_ids = [int(x) for x in request.form.getlist("user_ids[]") if x]
website_ids = [int(x) for x in request.form.getlist("website_ids[]") if x]
try:
update_shift(admin_id, shift_id, name, days_of_week, start_time, end_time,
note, is_active, user_ids, website_ids)
flash(f"Shift '{name}' updated successfully.", "success")
logger.info(f"Shift id={shift_id} updated by admin_id={admin_id}.")
except Exception as e:
logger.error(f"update_shift error: {e}")
flash(f"Error updating shift: {e}", "danger")
return redirect(url_for("admin_shifts.shifts_list"))
REMINDER_WINDOW_MIN = 45
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
# 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"]
)
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 Bid Checker to mark them done."
)
try:
send_email(
row["email"],
f"Action required — complete your shift checks by {end_str}",
body,
)
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 45 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):
admin_id = session["user"]["id"]
try:
delete_shift(admin_id, shift_id)
flash("Shift deactivated successfully.", "success")
logger.info(f"Shift id={shift_id} soft-deleted by admin_id={admin_id}.")
except Exception as e:
logger.error(f"delete_shift error: {e}")
flash(f"Error deleting shift: {e}", "danger")
return redirect(url_for("admin_shifts.shifts_list"))