guards-api / main.py
rairo's picture
Update main.py
5e26fd0 verified
raw
history blame
42.3 kB
import os
import json
import uuid
import datetime
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
# === 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
# (This might duplicate some logs, but ensures Flask's internal messages are captured)
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", "nharingoshepherd@gmail.com"]
logger.info("Environment variables loaded.")
logger.debug(f"FIREBASE_DB_URL: {FIREBASE_DB_URL}") # Be careful logging sensitive data
# === 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",
"nharingoshepherd@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
})
logger.info(f"Email sent successfully to {to}")
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"""
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")
# Create HTML email content
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> {shift_record.get('assigned_at', 'Unknown')}</p>
<h3>Assignments:</h3>
<table border="1" style="border-collapse: collapse; width: 100%;">
<thead>
<tr>
<th>Guarding Point</th>
<th>Assigned Member</th>
<th>Special Case</th>
</tr>
</thead>
<tbody>
"""
for assignment in shift_record.get("assignments", []):
point_name = assignment.get("point", {}).get("name", "Unknown Point")
member_name = assignment.get("member", {}).get("name", "Unknown Member")
is_special = "Yes" if assignment.get("is_special_case", False) else "No"
special_reason = assignment.get("special_case_reason", "") if assignment.get("is_special_case", False) else ""
if special_reason:
member_info = f"{member_name} ({special_reason})"
else:
member_info = member_name
html_content += f"""
<tr>
<td>{point_name}</td>
<td>{member_info}</td>
<td>{is_special}</td>
</tr>
"""
html_content += """
</tbody>
</table>
<p><em>This is an automated notification from the Guard Rotation System.</em></p>
"""
# Send email to all admin emails
subject = f"Guard Rotation - {job_name} (Shift {shift_number})"
for admin_email in ADMIN_EMAILS:
send_email(admin_email, subject, html_content)
except Exception as e:
logger.error(f"Error sending rotation notification for job {job_id}: {e}")
# === Auth Middleware ===
def verify_token(req):
"""
Verify Firebase ID token and check if user's email is in ADMIN_EMAILS.
Email is expected in the 'X-User-Email' header, sent by the client.
Returns decoded token dict if valid admin, None otherwise.
"""
auth_header = req.headers.get("Authorization")
# NEW: Get email from custom header sent by client
user_email = req.headers.get("X-User-Email")
if not auth_header:
print("Authorization header missing.")
return None
# NEW: Check if email header is present and valid
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:
# Extract token
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
else:
token = auth_header.strip()
# Verify the token (ensures token is genuine and user is authenticated)
decoded = firebase_auth.verify_id_token(token)
# Note: We are not checking the UID against the database anymore.
# We are trusting the email provided by the client, verified against ADMIN_EMAILS.
# The token verification ensures the request is from a logged-in user.
logging.info(f"User with email {user_email} (UID: {decoded.get('uid')}) is authorized as admin (email in ADMIN_EMAILS).")
return decoded # Return decoded token if needed by other parts of the app
except firebase_auth.InvalidIdTokenError as e:
print(f"Invalid Firebase ID token provided: {e}")
return None
except firebase_auth.ExpiredIdTokenError as e:
print(f"Firebase ID token has expired: {e}")
return None
except firebase_auth.RevokedIdTokenError as e:
print(f"Firebase ID token has been revoked: {e}")
return None
except Exception as e:
print(f"Unexpected error during token verification: {e}")
# Consider logging the full traceback in production
# import traceback
# print(traceback.format_exc())
return None
# === Admin Setup (Legacy - kept for compatibility) ===
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 rotation algorithm"""
try:
logger.info(f"Starting roster assignment for job {job_id}")
# Get job details
job_ref = db.reference(f"jobs/{job_id}")
job_data = job_ref.get()
if not job_data: # Fixed the condition: job_data is None, not job_id
logger.error(f"Job {job_id} not found")
return
# Check if job is paused
if job_data.get("status") == "paused":
logger.info(f"Job {job_id} is paused, skipping assignment")
return
# Get guarding points and assigned members
guarding_points = job_data.get("guarding_points", [])
assigned_members = job_data.get("assigned_members", [])
special_cases = job_data.get("special_cases", [])
if not guarding_points:
logger.warning(f"No guarding points defined for job {job_id}")
return
if not assigned_members:
logger.warning(f"No members assigned to job {job_id}")
return
# Get current assignments and rotation history
current_assignments = job_data.get("assignments", [])
rotation_history = job_data.get("rotation_history", {})
# Determine next shift
shift_number = len(current_assignments) + 1
logger.debug(f"Determined shift number: {shift_number} for job {job_id}")
# Apply special cases if any match current shift
special_assignment = None
for case in special_cases:
if case.get("shift_number") == shift_number or case.get("active", False):
special_assignment = case
logger.debug(f"Found matching special case for shift {shift_number}: {case}")
break
# Generate assignments for this shift
shift_assignments = []
if special_assignment:
logger.info(f"Applying special case assignment for job {job_id}, shift {shift_number}")
# Apply special case assignment
assigned_point = next((p for p in guarding_points if p["id"] == special_assignment["point_id"]), None)
assigned_member = next((m for m in assigned_members if m["id"] == special_assignment["member_id"]), None)
if assigned_point and assigned_member:
shift_assignments.append({
"point": assigned_point,
"member": assigned_member,
"is_special_case": True,
"special_case_reason": special_assignment.get("reason", "Special Assignment")
})
else:
logger.warning(f"Special case assignment failed: Point or Member not found for job {job_id}")
else:
logger.info(f"Applying standard rotation algorithm for job {job_id}, shift {shift_number}")
# Standard rotation algorithm
# Create point-specific rotation history
# point_assignments = {} # This variable was defined but not used, removed.
for point in guarding_points:
point_id = point["id"]
point_history = rotation_history.get(point_id, [])
# Find member who hasn't been assigned to this point recently
available_members = assigned_members.copy()
# Remove recently assigned members (no immediate repetition)
recently_assigned_member_ids = set()
lookback_window = min(len(guarding_points), len(assigned_members)) # Prevent index error
for recent_assignment in point_history[-lookback_window:]: # Look back N assignments
recently_assigned_member_ids.add(recent_assignment["member_id"])
# Filter available members
available_members = [m for m in available_members if m["id"] not in recently_assigned_member_ids]
# If all members have been recently assigned, use all members
if not available_members:
logger.debug(f"All members recently assigned to point {point_id}. Using full member list.")
available_members = assigned_members
# Select next member (simple round-robin from available)
selected_member = None
if point_history and available_members:
last_assigned_member_id = point_history[-1]["member_id"]
last_member_index = next((i for i, m in enumerate(available_members) if m["id"] == last_assigned_member_id), -1)
if last_member_index != -1: # Only proceed if last member was in available list
next_member_index = (last_member_index + 1) % len(available_members)
else:
# Last assigned member is not in available list, pick first available
next_member_index = 0
selected_member = available_members[next_member_index]
elif available_members:
# No history or last member not in available list, pick first
selected_member = available_members[0]
else:
# This shouldn't happen due to the fallback, but log if it does
logger.error(f"No available members for point {point_id} in job {job_id}")
continue # Skip this point
if selected_member:
# Record assignment
shift_assignments.append({
"point": point,
"member": selected_member,
"is_special_case": False
})
# Update rotation history
point_history.append({
"member_id": selected_member["id"],
"member_name": selected_member.get("name", "Unknown"),
"assigned_at": datetime.datetime.now().isoformat(),
"shift_number": shift_number
})
rotation_history[point_id] = point_history
else:
logger.warning(f"Could not select a member for point {point_id} in job {job_id}")
# Update rotation history in database
job_ref.update({"rotation_history": rotation_history})
# Create shift record
shift_record = {
"shift_number": shift_number,
"assigned_at": datetime.datetime.now().isoformat(),
"assignments": shift_assignments,
"shift_id": str(uuid.uuid4())
}
# Add to current assignments
current_assignments.append(shift_record)
# Update job with new assignment
job_ref.update({
"assignments": current_assignments,
"last_updated": datetime.datetime.now().isoformat()
})
logger.info(f"Shift {shift_number} assigned for job {job_id} with {len(shift_assignments)} assignments")
# Send email notification to admins
send_rotation_notification(job_id, shift_record)
# Schedule next rotation based on job's rotation period
rotation_period = job_data.get("rotation_period", 28800) # Default 8 hours
next_run_time = datetime.datetime.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")
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...")
# Get all jobs
jobs_ref = db.reference("jobs")
jobs = jobs_ref.get()
if not jobs:
logger.debug("No jobs found")
return
# Check each job for rotation needs (for jobs that don't have scheduled rotations)
for job_id, job_data in jobs.items():
logger.debug(f"Evaluating job {job_id} for rotation...")
# Skip paused jobs
if job_data.get("status") == "paused":
logger.debug(f"Job {job_id} is paused, skipping.")
continue
# Skip jobs that have scheduled rotations handled by APScheduler
# Note: This logic might need refinement based on how you track scheduled jobs.
# if job_data.get("has_scheduled_rotation", False):
# logger.debug(f"Job {job_id} has scheduled rotation, skipping manual check.")
# continue
# For jobs without scheduled rotations, check manually
assignments = job_data.get("assignments", [])
if not assignments:
# If no assignments yet, this job hasn't started manually
logger.debug(f"Job {job_id} has no assignments yet, not started.")
continue
else:
# Check if current assignment is expired
last_assignment = assignments[-1]
try:
assigned_at = datetime.datetime.fromisoformat(last_assignment["assigned_at"].replace("Z", "+00:00"))
except ValueError:
logger.warning(f"Could not parse assigned_at time for job {job_id}: {last_assignment['assigned_at']}")
continue # Skip if time format is unexpected
now = datetime.datetime.now(assigned_at.tzinfo)
# Get rotation period from job or default to 8 hours
rotation_period = job_data.get("rotation_period", 28800)
time_since_last_assignment = (now - assigned_at).total_seconds()
logger.debug(f"Job {job_id}: Last assignment was {time_since_last_assignment}s ago. Period is {rotation_period}s.")
if time_since_last_assignment > rotation_period:
logger.info(f"Rotating guard for job {job_id}")
assign_roster(job_id)
else:
logger.debug(f"Job {job_id} not due for rotation yet.")
except Exception as e:
logger.error(f"Error in check_and_rotate_guards: {e}", exc_info=True)
# === Routes with Logging ===
@app.before_request
def log_request_info():
"""Log incoming request details."""
logger.info(f"Incoming request: {request.method} {request.url}")
logger.debug(f"Headers: {dict(request.headers)}")
# Be very careful logging request.data or request.json as they might contain sensitive info
# logger.debug(f"Body: {request.get_data()}") # Uncomment only for debugging, then remove!
@app.route("/", methods=["GET"])
def home():
"""Simple health check or API root endpoint."""
return jsonify({"message": "Rairo Guards API is running.", "status": "ok"}), 200
@app.after_request
def log_response_info(response):
"""Log outgoing response details."""
logger.info(f"Outgoing response: {response.status} for {request.method} {request.url}")
return response
@app.route("/upload_members", methods=["POST"])
def upload_members():
logger.info("Handling /upload_members request")
user = verify_token(request)
if not user:
logger.warning("Unauthorized access attempt to /upload_members")
return jsonify({"error": "Unauthorized"}), 401
file = request.files.get("file")
if not file:
logger.warning("No file uploaded in /upload_members request")
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")
logger.info(f"Processing {len(members)} members from uploaded file.")
for member in members:
member_id = str(uuid.uuid4())
db.reference(f"members/{member_id}").set(member)
logger.info("Members uploaded successfully.")
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():
logger.info("Handling /add_member request")
user = verify_token(request)
if not user:
logger.warning("Unauthorized access attempt to /add_member")
return jsonify({"error": "Unauthorized"}), 401
data = request.json
logger.debug(f"Adding member with data: {data}")
member_id = str(uuid.uuid4())
try:
db.reference(f"members/{member_id}").set(data)
logger.info(f"Member added successfully with ID: {member_id}")
return jsonify({"message": "Member added"}), 200
except Exception as e:
logger.error(f"Error adding member {member_id}: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
@app.route("/create_job", methods=["POST"])
def create_job():
logger.info("Handling /create_job request")
user = verify_token(request)
if not user:
logger.warning("Unauthorized access attempt to /create_job")
return jsonify({"error": "Unauthorized"}), 401
data = request.json
logger.debug(f"Creating job with data: {data}")
job_id = str(uuid.uuid4())
# Validate guarding points (5-15 points)
guarding_points = data.get("guarding_points", [])
if len(guarding_points) < 5 or len(guarding_points) > 15:
logger.warning(f"Invalid number of guarding points ({len(guarding_points)}) for job creation.")
return jsonify({"error": "Guarding points must be between 5 and 15"}), 400
# Add IDs to guarding points if not present
for i, point in enumerate(guarding_points):
if "id" not in point:
point["id"] = f"point_{i+1}"
# Add default fields
job_data = {
**data,
"guarding_points": guarding_points,
"created_at": datetime.datetime.now().isoformat(),
"status": "created", # New status: created, active, paused
"assignments": [],
"rotation_history": {},
"rotation_period": data.get("rotation_period", 28800), # Default 8 hours (28800 seconds)
"scheduled_start_time": data.get("scheduled_start_time"), # ISO format datetime string
"has_scheduled_rotation": False,
"special_cases": data.get("special_cases", [])
}
try:
db.reference(f"jobs/{job_id}").set(job_data)
logger.info(f"Job created successfully with ID: {job_id}")
return jsonify({"message": "Job created", "job_id": job_id}), 200
except Exception as e:
logger.error(f"Error creating 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):
"""Schedule a job to start at a specific time"""
logger.info(f"Handling /schedule_job/{job_id} request")
user = verify_token(request)
if not user:
logger.warning(f"Unauthorized access attempt to /schedule_job/{job_id}")
return jsonify({"error": "Unauthorized"}), 401
data = request.json
start_time_iso = data.get("start_time") # ISO format datetime string
logger.debug(f"Scheduling job {job_id} with data: {data}")
if not start_time_iso:
logger.warning("start_time is required for scheduling job.")
return jsonify({"error": "start_time is required (ISO format)"}), 400
job_ref = db.reference(f"jobs/{job_id}")
job_data = job_ref.get()
if not job_data: # Fixed condition
logger.warning(f"Job {job_id} not found for scheduling.")
return jsonify({"error": "Job not found"}), 404
# Parse the start time
try:
start_time = datetime.datetime.fromisoformat(start_time_iso.replace("Z", "+00:00"))
logger.debug(f"Parsed start time: {start_time}")
except ValueError:
logger.error(f"Invalid start_time format provided: {start_time_iso}")
return jsonify({"error": "Invalid start_time format. Use ISO format (e.g., 2023-12-01T10:00:00)"}), 400
# Update job with scheduled start time
try:
job_ref.update({
"scheduled_start_time": start_time_iso,
"status": "scheduled"
})
logger.info(f"Job {job_id} status updated to scheduled.")
except Exception as e:
logger.error(f"Error updating job {job_id} for scheduling: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
# Schedule the job to start at the specified time
try:
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]}"
)
logger.info(f"Job {job_id} scheduled to start at {start_time_iso}")
return jsonify({"message": f"Job {job_id} scheduled to start at {start_time_iso}"}), 200
except Exception as e:
logger.error(f"Error scheduling start job for {job_id}: {e}", exc_info=True)
# Consider rolling back the status update if scheduling fails
return jsonify({"error": "Failed to schedule job start"}), 500
def start_scheduled_job(job_id):
"""Function to start a scheduled job"""
try:
logger.info(f"Executing scheduled start for job {job_id}")
job_ref = db.reference(f"jobs/{job_id}")
job_data = job_ref.get()
if not job_data: # Fixed condition
logger.error(f"Job {job_id} not found for scheduled start")
return
# Update job status to active
job_ref.update({
"status": "active",
"started_at": datetime.datetime.now().isoformat()
})
logger.info(f"Job {job_id} status updated to active.")
# Start first assignment
assign_roster(job_id)
logger.info(f"Job {job_id} started via schedule")
except Exception as e:
logger.error(f"Error starting scheduled job {job_id}: {e}", exc_info=True)
@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:
logger.warning(f"Unauthorized access attempt to /start_job/{job_id}")
return jsonify({"error": "Unauthorized"}), 401
job_ref = db.reference(f"jobs/{job_id}")
job_data = job_ref.get()
if not job_data: # Fixed condition
logger.warning(f"Job {job_id} not found for starting.")
return jsonify({"error": "Job not found"}), 404
# Update job status to active
try:
job_ref.update({
"status": "active",
"started_at": datetime.datetime.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
# Schedule the first assignment immediately
try:
scheduler.add_job(
func=assign_roster,
trigger="date",
run_date=datetime.datetime.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:
logger.error(f"Error scheduling first assignment for job {job_id}: {e}", exc_info=True)
# Consider rolling back the status update if scheduling fails
return jsonify({"error": "Failed to start job assignments"}), 500
@app.route("/pause_job/<job_id>", methods=["POST"])
def pause_job(job_id):
logger.info(f"Handling /pause_job/{job_id} request")
user = verify_token(request)
if not user:
logger.warning(f"Unauthorized access attempt to /pause_job/{job_id}")
return jsonify({"error": "Unauthorized"}), 401
job_ref = db.reference(f"jobs/{job_id}")
job_data = job_ref.get()
if not job_data: # Fixed condition
logger.warning(f"Job {job_id} not found for pausing.")
return jsonify({"error": "Job not found"}), 404
try:
job_ref.update({"status": "paused"})
logger.info(f"Job {job_id} status updated to 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):
logger.info(f"Handling /delete_job/{job_id} request")
user = verify_token(request)
if not user:
logger.warning(f"Unauthorized access attempt to /delete_job/{job_id}")
return jsonify({"error": "Unauthorized"}), 401
job_ref = db.reference(f"jobs/{job_id}")
job_data = job_ref.get()
if not job_data: # Fixed condition
logger.warning(f"Job {job_id} not found for deletion.")
return jsonify({"error": "Job not found"}), 404
try:
job_ref.delete()
logger.info(f"Job {job_id} deleted successfully.")
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():
logger.info("Handling /jobs request")
user = verify_token(request)
if not user:
logger.warning("Unauthorized access attempt to /jobs")
return jsonify({"error": "Unauthorized"}), 401
try:
jobs_ref = db.reference("jobs")
jobs = jobs_ref.get()
if not jobs:
logger.debug("No jobs found in database.")
return jsonify({"jobs": []}), 200
# Convert to list format
jobs_list = []
for job_id, job_data in jobs.items():
job_data["id"] = job_id
jobs_list.append(job_data)
logger.debug(f"Retrieved {len(jobs_list)} jobs.")
return jsonify({"jobs": jobs_list}), 200
except Exception as e:
logger.error(f"Error retrieving jobs: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
@app.route("/job/<job_id>", methods=["GET"])
def get_job(job_id):
logger.info(f"Handling /job/{job_id} request")
user = verify_token(request)
if not user:
logger.warning(f"Unauthorized access attempt to /job/{job_id}")
return jsonify({"error": "Unauthorized"}), 401
try:
job_ref = db.reference(f"jobs/{job_id}")
job_data = job_ref.get()
if not job_data: # Fixed condition
logger.warning(f"Job {job_id} not found.")
return jsonify({"error": "Job not found"}), 404
job_data["id"] = job_id
logger.debug(f"Retrieved job {job_id}.")
return jsonify({"job": job_data}), 200
except Exception as e:
logger.error(f"Error retrieving job {job_id}: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
@app.route("/members", methods=["GET"])
def get_members():
logger.info("Handling /members request")
user = verify_token(request)
if not user:
logger.warning("Unauthorized access attempt to /members")
return jsonify({"error": "Unauthorized"}), 401
try:
members_ref = db.reference("members")
members = members_ref.get()
if not members:
logger.debug("No members found in database.")
return jsonify({"members": []}), 200
# Convert to list format
members_list = []
for member_id, member_data in members.items():
member_data["id"] = member_id
members_list.append(member_data)
logger.debug(f"Retrieved {len(members_list)} members.")
return jsonify({"members": members_list}), 200
except Exception as e:
logger.error(f"Error retrieving 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():
"""Bulk assign members to a job"""
logger.info("Handling /assign_members_to_job request")
user = verify_token(request)
if not user:
logger.warning("Unauthorized access attempt to /assign_members_to_job")
return jsonify({"error": "Unauthorized"}), 401
data = request.json
job_id = data.get("job_id")
member_ids = data.get("member_ids", [])
logger.debug(f"Assigning members to job {job_id} with data: {data}")
if not job_id or not member_ids:
logger.warning("job_id and member_ids are required for member assignment.")
return jsonify({"error": "job_id and member_ids are required"}), 400
job_ref = db.reference(f"jobs/{job_id}")
job_data = job_ref.get()
if not job_data: # Fixed condition
logger.warning(f"Job {job_id} not found for member assignment.")
return jsonify({"error": "Job not found"}), 404
# Get members
try:
members_ref = db.reference("members")
all_members = members_ref.get()
except Exception as e:
logger.error(f"Error retrieving members for assignment to job {job_id}: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
if not all_members:
logger.warning("No members found in database for assignment.")
return jsonify({"error": "No members found"}), 404
# Filter requested members (ensure at least 25)
selected_members = []
for member_id in member_ids:
if member_id in all_members:
member_data = all_members[member_id]
member_data["id"] = member_id
selected_members.append(member_data)
if len(selected_members) < 25:
logger.warning(f"Insufficient members ({len(selected_members)}) assigned to job {job_id}. Minimum is 25.")
return jsonify({"error": "At least 25 members must be assigned to the job"}), 400
# Update job with assigned members
try:
job_ref.update({"assigned_members": selected_members})
logger.info(f"{len(selected_members)} members assigned to job {job_id}.")
return jsonify({
"message": f"{len(selected_members)} members assigned to job {job_id}",
"assigned_members": selected_members
}), 200
except Exception as e:
logger.error(f"Error updating assigned members for 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):
"""Update job settings including rotation period"""
logger.info(f"Handling /update_job_settings/{job_id} request")
user = verify_token(request)
if not user:
logger.warning(f"Unauthorized access attempt to /update_job_settings/{job_id}")
return jsonify({"error": "Unauthorized"}), 401
data = request.json
rotation_period = data.get("rotation_period") # In seconds
logger.debug(f"Updating settings for job {job_id} with data: {data}")
if rotation_period is None:
logger.warning("rotation_period is required for job settings update.")
return jsonify({"error": "rotation_period is required (in seconds)"}), 400
job_ref = db.reference(f"jobs/{job_id}")
job_data = job_ref.get()
if not job_data: # Fixed condition
logger.warning(f"Job {job_id} not found for settings update.")
return jsonify({"error": "Job not found"}), 404
# Update rotation period
try:
job_ref.update({"rotation_period": rotation_period})
logger.info(f"Job {job_id} rotation period updated to {rotation_period} seconds.")
return jsonify({
"message": f"Job {job_id} rotation period updated to {rotation_period} seconds",
"rotation_period": rotation_period
}), 200
except Exception as e:
logger.error(f"Error updating rotation period for job {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):
"""Add a special case assignment for a job"""
logger.info(f"Handling /add_special_case/{job_id} request")
user = verify_token(request)
if not user:
logger.warning(f"Unauthorized access attempt to /add_special_case/{job_id}")
return jsonify({"error": "Unauthorized"}), 401
data = request.json
point_id = data.get("point_id")
member_id = data.get("member_id")
reason = data.get("reason", "Special Assignment")
shift_number = data.get("shift_number")
active = data.get("active", False)
logger.debug(f"Adding special case to job {job_id} with data: {data}")
if not point_id or not member_id:
logger.warning("point_id and member_id are required for special case.")
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: # Fixed condition
logger.warning(f"Job {job_id} not found for special case addition.")
return jsonify({"error": "Job not found"}), 404
# Verify point and member exist
guarding_points = job_data.get("guarding_points", [])
assigned_members = job_data.get("assigned_members", [])
point_exists = any(p["id"] == point_id for p in guarding_points)
member_exists = any(m["id"] == member_id for m in assigned_members)
if not point_exists:
logger.warning(f"Guarding point {point_id} not found in job {job_id}.")
return jsonify({"error": "Guarding point not found in job"}), 404
if not member_exists:
logger.warning(f"Member {member_id} not assigned to job {job_id}.")
return jsonify({"error": "Member not assigned to job"}), 404
# Add special case
special_case = {
"id": str(uuid.uuid4()),
"point_id": point_id,
"member_id": member_id,
"reason": reason,
"shift_number": shift_number,
"active": active,
"created_at": datetime.datetime.now().isoformat()
}
special_cases = job_data.get("special_cases", [])
special_cases.append(special_case)
try:
job_ref.update({"special_cases": special_cases})
logger.info(f"Special case added to job {job_id}.")
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):
"""Get formatted roster for a job"""
logger.info(f"Handling /get_roster/{job_id} request")
user = verify_token(request)
if not user:
logger.warning(f"Unauthorized access attempt to /get_roster/{job_id}")
return jsonify({"error": "Unauthorized"}), 401
try:
job_ref = db.reference(f"jobs/{job_id}")
job_data = job_ref.get()
if not job_data: # Fixed condition
logger.warning(f"Job {job_id} not found for roster retrieval.")
return jsonify({"error": "Job not found"}), 404
assignments = job_data.get("assignments", [])
# Format roster as table data
roster_data = []
for shift in assignments:
shift_number = shift["shift_number"]
assigned_at = shift["assigned_at"]
for assignment in shift["assignments"]:
roster_data.append({
"shift_number": shift_number,
"assigned_at": assigned_at,
"point_name": assignment["point"]["name"],
"member_name": assignment["member"].get("name", "Unknown"),
"is_special_case": assignment.get("is_special_case", False),
"special_case_reason": assignment.get("special_case_reason", "")
})
logger.debug(f"Retrieved roster for job {job_id} with {len(roster_data)} entries.")
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 retrieving roster for job {job_id}: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
# === Schedule periodic guard rotation ===
# Run every 5 minutes to check for rotations
try:
scheduler.add_job(
func=check_and_rotate_guards,
trigger="interval",
minutes=5,
id="guard_rotation_job"
)
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)))