""" 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("//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("//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 = 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("//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"))