Spaces:
Sleeping
Sleeping
| 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 === | |
| 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! | |
| def home(): | |
| """Simple health check or API root endpoint.""" | |
| return jsonify({"message": "Rairo Guards API is running.", "status": "ok"}), 200 | |
| def log_response_info(response): | |
| """Log outgoing response details.""" | |
| logger.info(f"Outgoing response: {response.status} for {request.method} {request.url}") | |
| return response | |
| 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 | |
| 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 | |
| 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 | |
| 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) | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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))) | |