guards-api / main.py
rairo's picture
Update main.py
a6d4695 verified
raw
history blame
28.7 kB
import os
import json
import uuid
import datetime # <-- This is the main module
import pandas as pd
from flask import Flask, request, jsonify
from flask_cors import CORS
from werkzeug.utils import secure_filename
from flask_apscheduler import APScheduler
import firebase_admin
from firebase_admin import credentials, db, auth as firebase_auth
from resend import Emails
import logging
from logging.handlers import RotatingFileHandler
import time
import random
from datetime import datetime as dt_class # Using an alias for the class to avoid confusion
# === Logging Configuration ===
# Configure logging
if not os.path.exists('logs'):
os.mkdir('logs')
# Create a custom logger
logger = logging.getLogger('guards_api')
logger.setLevel(logging.DEBUG) # Capture DEBUG and above
# Create handlers
file_handler = RotatingFileHandler('logs/guards_api.log', maxBytes=10240, backupCount=10)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
))
file_handler.setLevel(logging.INFO) # File logs INFO and above
console_handler = logging.StreamHandler()
console_handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
console_handler.setLevel(logging.DEBUG) # Console logs DEBUG and above
# Add handlers to the logger
logger.addHandler(file_handler)
logger.addHandler(console_handler)
# Also configure the root logger for Flask's default messages
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)
# === ENV Config ===
RESEND_API_KEY = os.getenv("RESEND_API_KEY")
FIREBASE_CRED_JSON = json.loads(os.getenv("FIREBASE"))
FIREBASE_DB_URL = os.getenv("Firebase_DB")
ADMIN_EMAILS = ["rairorr@gmail.com", "nharingosheperd@gmail.com"]
logger.info("Environment variables loaded.")
logger.debug(f"FIREBASE_DB_URL: {FIREBASE_DB_URL}")
# === Firebase Init ===
try:
cred = credentials.Certificate(FIREBASE_CRED_JSON)
firebase_admin.initialize_app(cred, {
"databaseURL": FIREBASE_DB_URL
})
logger.info("Firebase Admin initialized successfully.")
except Exception as e:
logger.error(f"Failed to initialize Firebase Admin: {e}")
raise
admin_emails = [
"rairorr@gmail.com",
"nharingosheperd@gmail.com"
]
for email in admin_emails:
key = email.replace("@", "_").replace(".", "_")
try:
db.reference(f"admins/{key}").set({
"email": email,
"is_admin": True
})
logger.info(f"✅ Admin {email} added/updated in database.")
except Exception as e:
logger.error(f"Failed to add admin {email} to database: {e}")
# === Flask App Setup ===
app = Flask(__name__)
CORS(app)
logger.info("Flask app initialized.")
# === APScheduler Setup ===
class Config(object):
SCHEDULER_API_ENABLED = True
app.config.from_object(Config())
scheduler = APScheduler()
scheduler.init_app(app)
scheduler.start()
logger.info("APScheduler initialized and started.")
# === Resend Init ===
Emails.api_key = RESEND_API_KEY
logger.info("Resend API key configured.")
def send_email(to, subject, html):
try:
response = Emails.send({
"from": "Admin <admin@resend.dev>",
"to": to,
"subject": subject,
"html": html
})
if response is None:
logger.warning(f"Email API returned None for recipient {to}. Check Resend dashboard for suppression or other issues.")
return None
logger.info(f"Email sent successfully to {to}. Response: {response}")
return response
except Exception as e:
logger.error(f"Email error to {to}: {e}")
return None
def send_rotation_notification(job_id, shift_record):
"""Send email notification for rotation to all admins with rate-limiting prevention"""
try:
job_ref = db.reference(f"jobs/{job_id}")
job_data = job_ref.get()
if not job_data:
logger.warning(f"Job {job_id} not found for rotation notification.")
return
job_name = job_data.get("name", "Unknown Job")
shift_number = shift_record.get("shift_number", "Unknown")
try:
assigned_time_iso = shift_record.get('assigned_at', '')
dt_object = dt_class.fromisoformat(assigned_time_iso)
human_readable_time = dt_object.strftime("%B %d, %Y at %I:%M %p UTC")
except (ValueError, TypeError):
human_readable_time = shift_record.get('assigned_at', 'Unknown')
html_content = f"""
<h2>Guard Rotation Notification</h2>
<p><strong>Job:</strong> {job_name}</p>
<p><strong>Job ID:</strong> {job_id}</p>
<p><strong>Shift Number:</strong> {shift_number}</p>
<p><strong>Rotation Time:</strong> {human_readable_time}</p>
<h3>Assignments:</h3>
<table border="1" style="border-collapse: collapse; width: 100%;">
<thead>
<tr>
<th>Guarding Point</th>
<th>Assigned Members</th>
<th>Special Case</th>
</tr>
</thead>
<tbody>
"""
for assignment in shift_record.get("assignments", []):
point_name = assignment.get("point", {}).get("name", "Unknown Point")
assigned_members = assignment.get("members", [])
member_names = [member.get("name", "Unknown Member") for member in assigned_members]
members_html = "<br>".join(member_names) if member_names else "None"
is_special = "Yes" if assignment.get("is_special_case", False) else "No"
html_content += f"""
<tr>
<td>{point_name}</td>
<td>{members_html}</td>
<td>{is_special}</td>
</tr>
"""
html_content += """
</tbody>
</table>
<p><em>This is an automated notification from the Guard Rotation System.</em></p>
"""
subject = f"Guard Rotation - {job_name} (Shift {shift_number})"
for admin_email in ADMIN_EMAILS:
logger.info(f"Sending rotation notification to {admin_email}...")
send_email(admin_email, subject, html_content)
time.sleep(1)
except Exception as e:
logger.error(f"Error sending rotation notification for job {job_id}: {e}")
# === Auth Middleware ===
def verify_token(req):
auth_header = req.headers.get("Authorization")
user_email = req.headers.get("X-User-Email")
if not auth_header:
print("Authorization header missing.")
return None
if not user_email:
logging.info("X-User-Email header missing from request.")
return None
if user_email not in ADMIN_EMAILS:
logging.info(f"Email {user_email} from X-User-Email header not found in ADMIN_EMAILS list.")
return None
try:
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
else:
token = auth_header.strip()
decoded = firebase_auth.verify_id_token(token)
logging.info(f"User with email {user_email} (UID: {decoded.get('uid')}) is authorized as admin (email in ADMIN_EMAILS).")
return decoded
except Exception as e:
print(f"Unexpected error during token verification: {e}")
return None
# === Admin Setup ===
def setup_admins():
ref = db.reference("admins")
for email in ADMIN_EMAILS:
uid = f"admin_{email.replace('@', '_').replace('.', '_')}"
ref.child(uid).set({"email": email, "is_admin": True})
setup_admins()
# === Core Functions ===
def assign_roster(job_id):
"""Assign roster for a job - implements the multi-guard rotation algorithm"""
try:
logger.info(f"Starting roster assignment for job {job_id}")
job_ref = db.reference(f"jobs/{job_id}")
job_data = job_ref.get()
if not job_data:
logger.error(f"Job {job_id} not found")
return
if job_data.get("status") != "active":
logger.info(f"Job {job_id} is not active (status: {job_data.get('status')}), skipping assignment.")
return
guarding_points = job_data.get("guarding_points", [])
assigned_members = job_data.get("assigned_members", [])
if not guarding_points or not assigned_members:
logger.warning(f"Job {job_id} is missing guarding points or assigned members.")
return
shift_number = len(job_data.get("assignments", [])) + 1
shift_assignments = []
available_for_shift_members = random.sample(assigned_members, len(assigned_members))
total_guards_required = sum(point.get('guards', 1) for point in guarding_points)
if len(available_for_shift_members) < total_guards_required:
logger.error(f"Not enough members ({len(available_for_shift_members)}) to fill all points ({total_guards_required}) for job {job_id}. Aborting.")
return
logger.info(f"Applying standard rotation for job {job_id}, shift {shift_number}")
for point in guarding_points:
guards_for_point = []
num_guards_required = point.get('guards', 1)
for _ in range(num_guards_required):
if not available_for_shift_members:
logger.warning(f"Ran out of available members while assigning to point {point.get('name')}")
break
selected_member = available_for_shift_members.pop(0)
guards_for_point.append(selected_member)
if guards_for_point:
shift_assignments.append({
"point": point,
"members": guards_for_point,
"is_special_case": False
})
if shift_assignments:
shift_record = {
"shift_number": shift_number,
"assigned_at": dt_class.now().isoformat(),
"assignments": shift_assignments,
"shift_id": str(uuid.uuid4())
}
current_assignments = job_data.get("assignments", [])
current_assignments.append(shift_record)
job_ref.update({
"assignments": current_assignments,
"last_updated": dt_class.now().isoformat()
})
logger.info(f"Shift {shift_number} assigned for job {job_id}.")
send_rotation_notification(job_id, shift_record)
rotation_period = job_data.get("rotation_period", 28800)
# --- FIX #2 HERE ---
next_run_time = dt_class.now() + datetime.timedelta(seconds=rotation_period)
scheduler.add_job(
func=assign_roster,
trigger="date",
run_date=next_run_time,
args=[job_id],
id=f"rotate_{job_id}_{uuid.uuid4().hex[:8]}"
)
logger.info(f"Scheduled next rotation for job {job_id} in {rotation_period} seconds.")
else:
logger.error(f"No assignments were created for job {job_id}, shift {shift_number}.")
except Exception as e:
logger.error(f"Error in assign_roster for job {job_id}: {e}", exc_info=True)
def check_and_rotate_guards():
"""Periodic function to check jobs and rotate guards"""
try:
logger.debug("🔍 Checking for guard rotations...")
jobs_ref = db.reference("jobs")
jobs = jobs_ref.get()
if not jobs:
return
for job_id, job_data in jobs.items():
if job_data.get("status") != "active":
continue
assignments = job_data.get("assignments", [])
if not assignments:
continue
last_assignment = assignments[-1]
try:
assigned_at_str = last_assignment.get("assigned_at", "")
assigned_at = dt_class.fromisoformat(assigned_at_str.replace("Z", "+00:00"))
except (ValueError, TypeError):
logger.warning(f"Could not parse assigned_at time for job {job_id}: {last_assignment.get('assigned_at')}")
continue
now = dt_class.now(assigned_at.tzinfo)
rotation_period = job_data.get("rotation_period", 28800)
time_since_last_assignment = (now - assigned_at).total_seconds()
if time_since_last_assignment > rotation_period:
logger.info(f"Rotating guards for job {job_id} based on interval check.")
assign_roster(job_id)
except Exception as e:
logger.error(f"Error in check_and_rotate_guards: {e}", exc_info=True)
# === Routes ===
@app.before_request
def log_request_info():
logger.info(f"Incoming request: {request.method} {request.url}")
@app.route("/", methods=["GET"])
def home():
return jsonify({"message": "Rairo Guards API is running.", "status": "ok"}), 200
@app.after_request
def log_response_info(response):
logger.info(f"Outgoing response: {response.status} for {request.method} {request.url}")
return response
@app.route("/start_job/<job_id>", methods=["POST"])
def start_job(job_id):
logger.info(f"Handling /start_job/{job_id} request")
user = verify_token(request)
if not user:
return jsonify({"error": "Unauthorized"}), 401
job_ref = db.reference(f"jobs/{job_id}")
if not job_ref.get():
return jsonify({"error": "Job not found"}), 404
try:
job_ref.update({
"status": "active",
"started_at": dt_class.now().isoformat()
})
logger.info(f"Job {job_id} status updated to active.")
except Exception as e:
logger.error(f"Error updating job {job_id} to active: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
try:
scheduler.add_job(
func=assign_roster,
trigger="date",
# --- FIX #1 HERE ---
run_date=dt_class.now() + datetime.timedelta(seconds=5),
args=[job_id],
id=f"start_{job_id}_{uuid.uuid4().hex[:8]}"
)
logger.info(f"First assignment for job {job_id} scheduled.")
return jsonify({"message": f"Job {job_id} started"}), 202
except Exception as e:
# This is where the original error was caught
logger.error(f"Error scheduling first assignment for job {job_id}: {e}", exc_info=True)
return jsonify({"error": "Failed to start job assignments"}), 500
# (Keep all your other routes: /upload_members, /add_member, /create_job, etc. They are unchanged and correct)
@app.route("/upload_members", methods=["POST"])
def upload_members():
user = verify_token(request)
if not user: return jsonify({"error": "Unauthorized"}), 401
file = request.files.get("file")
if not file: return jsonify({"error": "No file uploaded"}), 400
try:
df = pd.read_csv(file) if file.filename.endswith(".csv") else pd.read_excel(file)
members = df.to_dict(orient="records")
for member in members:
db.reference(f"members/{str(uuid.uuid4())}").set(member)
return jsonify({"message": "Members uploaded successfully"}), 200
except Exception as e:
logger.error(f"Error processing uploaded members file: {e}", exc_info=True)
return jsonify({"error": "Error processing file"}), 500
@app.route("/add_member", methods=["POST"])
def add_member():
user = verify_token(request)
if not user: return jsonify({"error": "Unauthorized"}), 401
data = request.json
member_id = str(uuid.uuid4())
try:
db.reference(f"members/{member_id}").set(data)
return jsonify({"message": "Member added", "member_id": member_id}), 200
except Exception as e:
logger.error(f"Error adding member: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
@app.route("/update_member/<member_id>", methods=["POST"])
def update_member(member_id):
user = verify_token(request)
if not user: return jsonify({"error": "Unauthorized"}), 401
data = request.json
if not data: return jsonify({"error": "Request body cannot be empty"}), 400
member_ref = db.reference(f"members/{member_id}")
if not member_ref.get(): return jsonify({"error": "Member not found"}), 404
try:
member_ref.update(data)
return jsonify({"message": "Member updated", "member": {**data, "id": member_id}}), 200
except Exception as e:
logger.error(f"Error updating member {member_id}: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
@app.route("/delete_member/<member_id>", methods=["DELETE"])
def delete_member(member_id):
user = verify_token(request)
if not user: return jsonify({"error": "Unauthorized"}), 401
member_ref = db.reference(f"members/{member_id}")
if not member_ref.get(): return jsonify({"error": "Member not found"}), 404
try:
member_ref.delete()
return jsonify({"message": f"Member {member_id} deleted"}), 200
except Exception as e:
logger.error(f"Error deleting member {member_id}: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
@app.route("/create_job", methods=["POST"])
def create_job():
user = verify_token(request)
if not user: return jsonify({"error": "Unauthorized"}), 401
data = request.json
job_id = str(uuid.uuid4())
guarding_points = data.get("guarding_points", [])
if not 5 <= len(guarding_points) <= 15:
return jsonify({"error": "Guarding points must be between 5 and 15"}), 400
for i, point in enumerate(guarding_points): point.setdefault("id", f"point_{i+1}")
job_data = {**data, "guarding_points": guarding_points, "created_at": dt_class.now().isoformat(), "status": "created", "assignments": [], "rotation_history": {}, "rotation_period": data.get("rotation_period", 28800)}
try:
db.reference(f"jobs/{job_id}").set(job_data)
return jsonify({"message": "Job created", "job_id": job_id}), 200
except Exception as e:
logger.error(f"Error creating job: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
@app.route("/update_job/<job_id>", methods=["POST"])
def update_job(job_id):
user = verify_token(request)
if not user: return jsonify({"error": "Unauthorized"}), 401
data = request.json
if not data: return jsonify({"error": "Request body cannot be empty"}), 400
job_ref = db.reference(f"jobs/{job_id}")
if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
try:
for key in ['assignments', 'rotation_history', 'status']: data.pop(key, None)
job_ref.update(data)
return jsonify({"message": f"Job {job_id} updated"}), 200
except Exception as e:
logger.error(f"Error updating job {job_id}: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
@app.route("/schedule_job/<job_id>", methods=["POST"])
def schedule_job(job_id):
user = verify_token(request)
if not user: return jsonify({"error": "Unauthorized"}), 401
data = request.json
start_time_iso = data.get("start_time")
if not start_time_iso: return jsonify({"error": "start_time is required"}), 400
job_ref = db.reference(f"jobs/{job_id}")
if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
try:
start_time = dt_class.fromisoformat(start_time_iso.replace("Z", "+00:00"))
except ValueError:
return jsonify({"error": "Invalid start_time format."}), 400
try:
job_ref.update({"scheduled_start_time": start_time_iso, "status": "scheduled"})
scheduler.add_job(func=start_scheduled_job, trigger="date", run_date=start_time, args=[job_id], id=f"start_{job_id}_{uuid.uuid4().hex[:8]}")
return jsonify({"message": f"Job {job_id} scheduled to start at {start_time_iso}"}), 200
except Exception as e:
logger.error(f"Error scheduling job {job_id}: {e}", exc_info=True)
return jsonify({"error": "Failed to schedule job"}), 500
def start_scheduled_job(job_id):
try:
logger.info(f"Executing scheduled start for job {job_id}")
job_ref = db.reference(f"jobs/{job_id}")
if not job_ref.get(): return
job_ref.update({"status": "active", "started_at": dt_class.now().isoformat()})
assign_roster(job_id)
except Exception as e:
logger.error(f"Error in start_scheduled_job for {job_id}: {e}", exc_info=True)
@app.route("/pause_job/<job_id>", methods=["POST"])
def pause_job(job_id):
user = verify_token(request)
if not user: return jsonify({"error": "Unauthorized"}), 401
job_ref = db.reference(f"jobs/{job_id}")
if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
try:
job_ref.update({"status": "paused"})
return jsonify({"message": f"Job {job_id} paused"}), 200
except Exception as e:
logger.error(f"Error pausing job {job_id}: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
@app.route("/delete_job/<job_id>", methods=["DELETE"])
def delete_job(job_id):
user = verify_token(request)
if not user: return jsonify({"error": "Unauthorized"}), 401
job_ref = db.reference(f"jobs/{job_id}")
if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
try:
job_ref.delete()
return jsonify({"message": f"Job {job_id} deleted"}), 200
except Exception as e:
logger.error(f"Error deleting job {job_id}: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
@app.route("/jobs", methods=["GET"])
def get_jobs():
user = verify_token(request)
if not user: return jsonify({"error": "Unauthorized"}), 401
try:
jobs = db.reference("jobs").get()
if not jobs: return jsonify({"jobs": []}), 200
jobs_list = [{"id": j_id, **j_data} for j_id, j_data in jobs.items()]
return jsonify({"jobs": jobs_list}), 200
except Exception as e:
logger.error(f"Error getting jobs: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
@app.route("/job/<job_id>", methods=["GET"])
def get_job(job_id):
user = verify_token(request)
if not user: return jsonify({"error": "Unauthorized"}), 401
try:
job_data = db.reference(f"jobs/{job_id}").get()
if not job_data: return jsonify({"error": "Job not found"}), 404
return jsonify({"job": {"id": job_id, **job_data}}), 200
except Exception as e:
logger.error(f"Error getting job {job_id}: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
@app.route("/members", methods=["GET"])
def get_members():
user = verify_token(request)
if not user: return jsonify({"error": "Unauthorized"}), 401
try:
members = db.reference("members").get()
if not members: return jsonify({"members": []}), 200
members_list = [{"id": m_id, **m_data} for m_id, m_data in members.items()]
return jsonify({"members": members_list}), 200
except Exception as e:
logger.error(f"Error getting members: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
@app.route("/assign_members_to_job", methods=["POST"])
def assign_members_to_job():
user = verify_token(request)
if not user: return jsonify({"error": "Unauthorized"}), 401
data = request.json
job_id = data.get("job_id")
member_ids = data.get("member_ids", [])
if not job_id or not member_ids: return jsonify({"error": "job_id and member_ids are required"}), 400
job_ref = db.reference(f"jobs/{job_id}")
if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
try:
all_members = db.reference("members").get()
if not all_members: return jsonify({"error": "No members found"}), 404
selected_members = [{"id": m_id, **m_data} for m_id, m_data in all_members.items() if m_id in member_ids]
if len(selected_members) < 25: return jsonify({"error": "At least 25 members must be assigned"}), 400
job_ref.update({"assigned_members": selected_members})
return jsonify({"message": f"{len(selected_members)} members assigned"}), 200
except Exception as e:
logger.error(f"Error assigning members to job {job_id}: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
@app.route("/update_job_settings/<job_id>", methods=["POST"])
def update_job_settings(job_id):
user = verify_token(request)
if not user: return jsonify({"error": "Unauthorized"}), 401
data = request.json
rotation_period = data.get("rotation_period")
if rotation_period is None: return jsonify({"error": "rotation_period is required"}), 400
job_ref = db.reference(f"jobs/{job_id}")
if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
try:
job_ref.update({"rotation_period": rotation_period})
return jsonify({"message": f"Rotation period updated to {rotation_period}s"}), 200
except Exception as e:
logger.error(f"Error updating job settings for {job_id}: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
@app.route("/add_special_case/<job_id>", methods=["POST"])
def add_special_case(job_id):
user = verify_token(request)
if not user: return jsonify({"error": "Unauthorized"}), 401
data = request.json
point_id, member_id = data.get("point_id"), data.get("member_id")
if not point_id or not member_id: return jsonify({"error": "point_id and member_id are required"}), 400
job_ref = db.reference(f"jobs/{job_id}")
job_data = job_ref.get()
if not job_data: return jsonify({"error": "Job not found"}), 404
if not any(p["id"] == point_id for p in job_data.get("guarding_points", [])):
return jsonify({"error": "Guarding point not found in job"}), 404
if not any(m["id"] == member_id for m in job_data.get("assigned_members", [])):
return jsonify({"error": "Member not assigned to job"}), 404
special_case = {"id": str(uuid.uuid4()), **data, "created_at": dt_class.now().isoformat()}
special_cases = job_data.get("special_cases", [])
special_cases.append(special_case)
try:
job_ref.update({"special_cases": special_cases})
return jsonify({"message": "Special case added", "special_case": special_case}), 200
except Exception as e:
logger.error(f"Error adding special case to job {job_id}: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
@app.route("/get_roster/<job_id>", methods=["GET"])
def get_roster(job_id):
user = verify_token(request)
if not user: return jsonify({"error": "Unauthorized"}), 401
try:
job_data = db.reference(f"jobs/{job_id}").get()
if not job_data: return jsonify({"error": "Job not found"}), 404
assignments = job_data.get("assignments", [])
roster_data = []
for shift in assignments:
for assignment in shift.get("assignments", []):
member_names = [m.get("name", "Unknown") for m in assignment.get("members", [])]
roster_data.append({
"shift_number": shift["shift_number"],
"assigned_at": shift["assigned_at"],
"point_name": assignment.get("point", {}).get("name", "Unknown"),
"member_names": ", ".join(member_names),
"is_special_case": assignment.get("is_special_case", False),
})
return jsonify({"roster": roster_data, "job_name": job_data.get("name", "Unknown Job"), "total_shifts": len(assignments)}), 200
except Exception as e:
logger.error(f"Error getting roster for {job_id}: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
# === Schedule periodic guard rotation ===
try:
scheduler.add_job(
func=check_and_rotate_guards,
trigger="interval",
minutes=5,
id="guard_rotation_check_job",
replace_existing=True
)
logger.info("Scheduled periodic guard rotation check job.")
except Exception as e:
logger.error(f"Failed to schedule periodic guard rotation check: {e}", exc_info=True)
# === Run Server ===
if __name__ == "__main__":
logger.info("Starting Flask application...")
app.run(debug=True, host="0.0.0.0", port=int(os.getenv("PORT", 7860)))