Spaces:
Sleeping
Sleeping
Update main.py
Browse files
main.py
CHANGED
|
@@ -2,7 +2,6 @@ import os
|
|
| 2 |
import json
|
| 3 |
import uuid
|
| 4 |
import datetime
|
| 5 |
-
import pandas as pd
|
| 6 |
from flask import Flask, request, jsonify
|
| 7 |
from flask_cors import CORS
|
| 8 |
from werkzeug.utils import secure_filename
|
|
@@ -15,7 +14,9 @@ import logging
|
|
| 15 |
from logging.handlers import RotatingFileHandler
|
| 16 |
import time
|
| 17 |
import random
|
| 18 |
-
from datetime import datetime as dt_class
|
|
|
|
|
|
|
| 19 |
|
| 20 |
# === Logging Configuration ===
|
| 21 |
if not os.path.exists('logs'):
|
|
@@ -35,8 +36,13 @@ root_logger.setLevel(logging.INFO)
|
|
| 35 |
root_logger.addHandler(file_handler)
|
| 36 |
root_logger.addHandler(console_handler)
|
| 37 |
|
| 38 |
-
# === ENV Config ===
|
|
|
|
| 39 |
ADMIN_EMAILS = ["rairorr@gmail.com", "nharingosheperd@gmail.com"]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
RESEND_API_KEY = os.getenv("RESEND_API_KEY")
|
| 41 |
FIREBASE_CRED_JSON = json.loads(os.getenv("FIREBASE"))
|
| 42 |
FIREBASE_DB_URL = os.getenv("Firebase_DB")
|
|
@@ -65,17 +71,14 @@ logger.info("Flask, APScheduler, and Resend initialized.")
|
|
| 65 |
def send_email(to, subject, html):
|
| 66 |
"""Sends an email with robust error logging."""
|
| 67 |
try:
|
| 68 |
-
|
| 69 |
response = Emails.send({
|
| 70 |
"from": "Admin <admin@resend.dev>",
|
| 71 |
-
"to":
|
| 72 |
"subject": subject,
|
| 73 |
"html": html
|
| 74 |
})
|
| 75 |
-
|
| 76 |
-
logger.warning(f"Email API returned None for recipient {to}. Check Resend dashboard for suppression list.")
|
| 77 |
-
return None
|
| 78 |
-
logger.info(f"Email sent successfully to {to}. Response ID: {response.get('id')}")
|
| 79 |
return response
|
| 80 |
except ResendError as e:
|
| 81 |
logger.error(f"A Resend API error occurred for recipient '{to}'. The error is: {str(e)}", exc_info=True)
|
|
@@ -85,24 +88,26 @@ def send_email(to, subject, html):
|
|
| 85 |
return None
|
| 86 |
|
| 87 |
|
| 88 |
-
# === DEFINITIVELY FIXED FUNCTION (send_rotation_notification) ===
|
| 89 |
def send_rotation_notification(job_id, shift_record):
|
| 90 |
-
"""Builds and sends email notifications with
|
| 91 |
try:
|
| 92 |
job_ref = db.reference(f"jobs/{job_id}")
|
| 93 |
job_data = job_ref.get()
|
| 94 |
-
if not job_data:
|
| 95 |
-
logger.warning(f"Job {job_id} not found for rotation notification.")
|
| 96 |
-
return
|
| 97 |
|
| 98 |
job_name = job_data.get("name", "Unknown Job")
|
| 99 |
shift_number = shift_record.get("shift_number", "Unknown")
|
| 100 |
|
|
|
|
|
|
|
| 101 |
try:
|
| 102 |
assigned_time_iso = shift_record.get('assigned_at', '')
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
| 106 |
human_readable_time = shift_record.get('assigned_at', 'Unknown')
|
| 107 |
|
| 108 |
html_content = f"""
|
|
@@ -125,19 +130,9 @@ def send_rotation_notification(job_id, shift_record):
|
|
| 125 |
|
| 126 |
subject = f"Guard Rotation - {job_name} (Shift {shift_number})"
|
| 127 |
|
| 128 |
-
for
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
logger.debug(f"RAW email string | Length: {len(admin_email)} | Representation: {repr(admin_email)}")
|
| 132 |
-
|
| 133 |
-
# 2. Aggressively sanitize the string by re-encoding it. This removes hidden characters.
|
| 134 |
-
sanitized_email = admin_email.encode('utf-8', 'ignore').decode('utf-8').strip()
|
| 135 |
-
|
| 136 |
-
# 3. Log the sanitized state
|
| 137 |
-
logger.debug(f"SANITIZED email string | Length: {len(sanitized_email)} | Representation: {repr(sanitized_email)}")
|
| 138 |
-
|
| 139 |
-
logger.info(f"Sending rotation notification to {sanitized_email}...")
|
| 140 |
-
send_email(sanitized_email, subject, html_content) # Use the sanitized email
|
| 141 |
time.sleep(1)
|
| 142 |
|
| 143 |
except Exception as e:
|
|
@@ -145,6 +140,7 @@ def send_rotation_notification(job_id, shift_record):
|
|
| 145 |
|
| 146 |
|
| 147 |
def verify_token(req):
|
|
|
|
| 148 |
try:
|
| 149 |
auth_header = req.headers.get("Authorization", "").split("Bearer ")[-1]
|
| 150 |
user_email = req.headers.get("X-User-Email")
|
|
@@ -155,6 +151,7 @@ def verify_token(req):
|
|
| 155 |
return None
|
| 156 |
|
| 157 |
def setup_admins():
|
|
|
|
| 158 |
ref = db.reference("admins")
|
| 159 |
for email in ADMIN_EMAILS:
|
| 160 |
uid = f"admin_{email.replace('@', '_').replace('.', '_')}"
|
|
@@ -162,15 +159,13 @@ def setup_admins():
|
|
| 162 |
setup_admins()
|
| 163 |
|
| 164 |
def assign_roster(job_id):
|
| 165 |
-
"""Assigns roster for a job
|
| 166 |
try:
|
| 167 |
logger.info(f"Starting roster assignment for job {job_id}")
|
| 168 |
job_ref = db.reference(f"jobs/{job_id}")
|
| 169 |
job_data = job_ref.get()
|
| 170 |
|
| 171 |
-
if not job_data or job_data.get("status") != "active":
|
| 172 |
-
logger.info(f"Job {job_id} not found or not active. Skipping assignment.")
|
| 173 |
-
return
|
| 174 |
|
| 175 |
guarding_points = job_data.get("guarding_points", [])
|
| 176 |
assigned_members = job_data.get("assigned_members", [])
|
|
@@ -180,43 +175,24 @@ def assign_roster(job_id):
|
|
| 180 |
return
|
| 181 |
|
| 182 |
shift_number = len(job_data.get("assignments", [])) + 1
|
| 183 |
-
shift_assignments = []
|
| 184 |
available_for_shift = random.sample(assigned_members, len(assigned_members))
|
| 185 |
|
| 186 |
total_guards_required = sum(int(point.get('guards', 1)) for point in guarding_points)
|
| 187 |
if len(available_for_shift) < total_guards_required:
|
| 188 |
-
logger.error(f"INSUFFICIENT MEMBERS: Job {job_id} requires {total_guards_required}
|
| 189 |
return
|
| 190 |
|
| 191 |
-
|
| 192 |
-
|
| 193 |
for point in guarding_points:
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
num_guards_required = int(point.get('guards', 1))
|
| 197 |
-
except (ValueError, TypeError):
|
| 198 |
-
num_guards_required = 1
|
| 199 |
-
|
| 200 |
-
logger.debug(f"Assigning to point '{point.get('name')}'. Need {num_guards_required} guard(s).")
|
| 201 |
-
|
| 202 |
-
for _ in range(num_guards_required):
|
| 203 |
-
if not available_for_shift:
|
| 204 |
-
logger.warning(f"Ran out of available members while assigning to point {point.get('name')}")
|
| 205 |
-
break
|
| 206 |
-
selected_member = available_for_shift.pop(0)
|
| 207 |
-
guards_for_point.append(selected_member)
|
| 208 |
-
|
| 209 |
if guards_for_point:
|
| 210 |
-
shift_assignments.append({
|
| 211 |
-
"point": point,
|
| 212 |
-
"members": guards_for_point,
|
| 213 |
-
"is_special_case": False
|
| 214 |
-
})
|
| 215 |
|
| 216 |
if shift_assignments:
|
| 217 |
shift_record = {
|
| 218 |
"shift_number": shift_number,
|
| 219 |
-
"assigned_at": dt_class.now().isoformat(),
|
| 220 |
"assignments": shift_assignments,
|
| 221 |
"shift_id": str(uuid.uuid4())
|
| 222 |
}
|
|
@@ -224,14 +200,14 @@ def assign_roster(job_id):
|
|
| 224 |
current_assignments.append(shift_record)
|
| 225 |
job_ref.update({
|
| 226 |
"assignments": current_assignments,
|
| 227 |
-
"last_updated": dt_class.now().isoformat()
|
| 228 |
})
|
| 229 |
|
| 230 |
logger.info(f"Shift {shift_number} assigned for job {job_id}.")
|
| 231 |
send_rotation_notification(job_id, shift_record)
|
| 232 |
|
| 233 |
rotation_period = job_data.get("rotation_period", 28800)
|
| 234 |
-
next_run_time = dt_class.now() + datetime.timedelta(seconds=rotation_period)
|
| 235 |
|
| 236 |
scheduler.add_job(
|
| 237 |
func=assign_roster, trigger="date", run_date=next_run_time,
|
|
@@ -239,9 +215,6 @@ def assign_roster(job_id):
|
|
| 239 |
replace_existing=True
|
| 240 |
)
|
| 241 |
logger.info(f"Scheduled next rotation for job {job_id} in {rotation_period} seconds.")
|
| 242 |
-
else:
|
| 243 |
-
logger.error(f"No assignments were created for job {job_id}, shift {shift_number}.")
|
| 244 |
-
|
| 245 |
except Exception:
|
| 246 |
logger.error(f"CRITICAL ERROR in assign_roster for job {job_id}", exc_info=True)
|
| 247 |
|
|
@@ -259,7 +232,6 @@ def log_response_info(response):
|
|
| 259 |
def home():
|
| 260 |
return jsonify({"message": "Rairo Guards API is running.", "status": "ok"}), 200
|
| 261 |
|
| 262 |
-
# (All your other routes like /upload_members, /create_job, etc. remain here unchanged)
|
| 263 |
@app.route("/upload_members", methods=["POST"])
|
| 264 |
def upload_members():
|
| 265 |
if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
|
|
@@ -323,12 +295,12 @@ def create_job():
|
|
| 323 |
return jsonify({"error": "Guarding points must be between 5 and 15"}), 400
|
| 324 |
for i, point in enumerate(guarding_points):
|
| 325 |
point.setdefault("id", f"point_{i+1}")
|
| 326 |
-
job_data = {**data, "guarding_points": guarding_points, "created_at": dt_class.now().isoformat(), "status": "created", "assignments": []}
|
| 327 |
try:
|
| 328 |
db.reference(f"jobs/{job_id}").set(job_data)
|
| 329 |
return jsonify({"message": "Job created", "job_id": job_id}), 200
|
| 330 |
except Exception as e:
|
| 331 |
-
logger.error("Error creating job: {e}", exc_info=True)
|
| 332 |
return jsonify({"error": "Internal server error"}), 500
|
| 333 |
|
| 334 |
@app.route("/update_job/<job_id>", methods=["POST"])
|
|
@@ -370,7 +342,7 @@ def start_scheduled_job(job_id):
|
|
| 370 |
logger.info(f"Executing scheduled start for job {job_id}")
|
| 371 |
job_ref = db.reference(f"jobs/{job_id}")
|
| 372 |
if job_ref.get():
|
| 373 |
-
job_ref.update({"status": "active", "started_at": dt_class.now().isoformat()})
|
| 374 |
assign_roster(job_id)
|
| 375 |
except Exception as e:
|
| 376 |
logger.error(f"Error in start_scheduled_job for {job_id}: {e}", exc_info=True)
|
|
@@ -381,14 +353,13 @@ def start_job(job_id):
|
|
| 381 |
job_ref = db.reference(f"jobs/{job_id}")
|
| 382 |
if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
|
| 383 |
try:
|
| 384 |
-
job_ref.update({"status": "active", "started_at": dt_class.now().isoformat()})
|
| 385 |
logger.info(f"Job {job_id} status updated to active.")
|
| 386 |
scheduler.add_job(
|
| 387 |
func=assign_roster, trigger="date",
|
| 388 |
-
run_date=dt_class.now() + datetime.timedelta(seconds=5),
|
| 389 |
args=[job_id], id=f"start_{job_id}", replace_existing=True
|
| 390 |
)
|
| 391 |
-
logger.info(f"First assignment for job {job_id} scheduled.")
|
| 392 |
return jsonify({"message": f"Job {job_id} started"}), 202
|
| 393 |
except Exception as e:
|
| 394 |
logger.error(f"Error starting job {job_id}: {e}", exc_info=True)
|
|
@@ -497,7 +468,7 @@ def add_special_case(job_id):
|
|
| 497 |
return jsonify({"error": "Guarding point not found in job"}), 404
|
| 498 |
if not any(m["id"] == member_id for m in job_data.get("assigned_members", [])):
|
| 499 |
return jsonify({"error": "Member not assigned to job"}), 404
|
| 500 |
-
special_case = {"id": str(uuid.uuid4()), **data, "created_at": dt_class.now().isoformat()}
|
| 501 |
special_cases = job_data.get("special_cases", [])
|
| 502 |
special_cases.append(special_case)
|
| 503 |
try:
|
|
@@ -529,41 +500,7 @@ def get_roster(job_id):
|
|
| 529 |
except Exception as e:
|
| 530 |
logger.error(f"Error getting roster for {job_id}: {e}", exc_info=True)
|
| 531 |
return jsonify({"error": "Internal server error"}), 500
|
| 532 |
-
|
| 533 |
-
@app.route("/test_email", methods=["GET"])
|
| 534 |
-
def test_email():
|
| 535 |
-
"""A simple endpoint to test sending emails to specific addresses."""
|
| 536 |
-
# Ensure you have access to your logger and send_email function
|
| 537 |
-
logger.info("--- STARTING EMAIL TEST ---")
|
| 538 |
-
|
| 539 |
-
# Test case 1: The email that works
|
| 540 |
-
logger.info("TEST 1: Sending to the known working address...")
|
| 541 |
-
res1 = send_email("rairorr@gmail.com", "API Test 1 (Working)", "<p>This is a test.</p>")
|
| 542 |
-
status1 = "Success" if res1 else "Failed"
|
| 543 |
-
|
| 544 |
-
# Test case 2: The email that fails
|
| 545 |
-
logger.info("TEST 2: Sending to the known failing address...")
|
| 546 |
-
res2 = send_email("nharingosheperd@gmail.com", "API Test 2 (Failing)", "<p>This is a test.</p>")
|
| 547 |
-
status2 = "Success" if res2 else "Failed"
|
| 548 |
-
|
| 549 |
-
# Test case 3: The modified email to bypass a simple string filter
|
| 550 |
-
logger.info("TEST 3: Sending to a modified version of the failing address...")
|
| 551 |
-
res3 = send_email("nharingosheperd+test@gmail.com", "API Test 3 (Modified)", "<p>This is a test.</p>")
|
| 552 |
-
status3 = "Success" if res3 else "Failed"
|
| 553 |
-
|
| 554 |
-
# Test case 4: The modified email to bypass a simple string filter
|
| 555 |
-
logger.info("TEST 4: Sending to a modified version of the failing address...")
|
| 556 |
-
res4 = send_email("initiumzim@gmail.com", "API Test 4 (Modified)", "<p>This is a test.</p>")
|
| 557 |
-
status4 = "Success" if res4 else "Failed"
|
| 558 |
-
|
| 559 |
-
logger.info("--- FINISHED EMAIL TEST ---")
|
| 560 |
-
|
| 561 |
-
return jsonify({
|
| 562 |
-
"test_1_working_email": status1,
|
| 563 |
-
"test_2_failing_email": status2,
|
| 564 |
-
"test_3_modified_email": status3,
|
| 565 |
-
"test_4_modified_email": status4,
|
| 566 |
-
}), 200
|
| 567 |
# === Run Server ===
|
| 568 |
if __name__ == "__main__":
|
| 569 |
logger.info("Starting Flask application...")
|
|
|
|
| 2 |
import json
|
| 3 |
import uuid
|
| 4 |
import datetime
|
|
|
|
| 5 |
from flask import Flask, request, jsonify
|
| 6 |
from flask_cors import CORS
|
| 7 |
from werkzeug.utils import secure_filename
|
|
|
|
| 14 |
from logging.handlers import RotatingFileHandler
|
| 15 |
import time
|
| 16 |
import random
|
| 17 |
+
from datetime import datetime as dt_class, timezone
|
| 18 |
+
from zoneinfo import ZoneInfo
|
| 19 |
+
import pandas as pd
|
| 20 |
|
| 21 |
# === Logging Configuration ===
|
| 22 |
if not os.path.exists('logs'):
|
|
|
|
| 36 |
root_logger.addHandler(file_handler)
|
| 37 |
root_logger.addHandler(console_handler)
|
| 38 |
|
| 39 |
+
# === ENV Config & Admin Setup ===
|
| 40 |
+
# For authenticating API requests. This should match the users' actual login emails.
|
| 41 |
ADMIN_EMAILS = ["rairorr@gmail.com", "nharingosheperd@gmail.com"]
|
| 42 |
+
|
| 43 |
+
# NEW: For sending email notifications. This list works around the Resend bug.
|
| 44 |
+
SEND_TO_EMAILS = ["rairorr@gmail.com", "nharingosheperd+guard@gmail.com"]
|
| 45 |
+
|
| 46 |
RESEND_API_KEY = os.getenv("RESEND_API_KEY")
|
| 47 |
FIREBASE_CRED_JSON = json.loads(os.getenv("FIREBASE"))
|
| 48 |
FIREBASE_DB_URL = os.getenv("Firebase_DB")
|
|
|
|
| 71 |
def send_email(to, subject, html):
|
| 72 |
"""Sends an email with robust error logging."""
|
| 73 |
try:
|
| 74 |
+
clean_to = to.strip()
|
| 75 |
response = Emails.send({
|
| 76 |
"from": "Admin <admin@resend.dev>",
|
| 77 |
+
"to": clean_to,
|
| 78 |
"subject": subject,
|
| 79 |
"html": html
|
| 80 |
})
|
| 81 |
+
logger.info(f"Email sent successfully to {clean_to}. Response ID: {response.get('id')}")
|
|
|
|
|
|
|
|
|
|
| 82 |
return response
|
| 83 |
except ResendError as e:
|
| 84 |
logger.error(f"A Resend API error occurred for recipient '{to}'. The error is: {str(e)}", exc_info=True)
|
|
|
|
| 88 |
return None
|
| 89 |
|
| 90 |
|
|
|
|
| 91 |
def send_rotation_notification(job_id, shift_record):
|
| 92 |
+
"""Builds and sends email notifications with CAT timezone."""
|
| 93 |
try:
|
| 94 |
job_ref = db.reference(f"jobs/{job_id}")
|
| 95 |
job_data = job_ref.get()
|
| 96 |
+
if not job_data: return
|
|
|
|
|
|
|
| 97 |
|
| 98 |
job_name = job_data.get("name", "Unknown Job")
|
| 99 |
shift_number = shift_record.get("shift_number", "Unknown")
|
| 100 |
|
| 101 |
+
# --- TIMEZONE CONVERSION ---
|
| 102 |
+
human_readable_time = 'Unknown'
|
| 103 |
try:
|
| 104 |
assigned_time_iso = shift_record.get('assigned_at', '')
|
| 105 |
+
utc_time = dt_class.fromisoformat(assigned_time_iso)
|
| 106 |
+
cat_tz = ZoneInfo("Africa/Harare") # CAT is UTC+2
|
| 107 |
+
cat_time = utc_time.astimezone(cat_tz)
|
| 108 |
+
human_readable_time = cat_time.strftime("%B %d, %Y at %I:%M %p %Z")
|
| 109 |
+
except (ValueError, TypeError) as e:
|
| 110 |
+
logger.warning(f"Could not parse or convert timestamp for email: {e}")
|
| 111 |
human_readable_time = shift_record.get('assigned_at', 'Unknown')
|
| 112 |
|
| 113 |
html_content = f"""
|
|
|
|
| 130 |
|
| 131 |
subject = f"Guard Rotation - {job_name} (Shift {shift_number})"
|
| 132 |
|
| 133 |
+
for send_to_email in SEND_TO_EMAILS:
|
| 134 |
+
logger.info(f"Sending rotation notification to {send_to_email}...")
|
| 135 |
+
send_email(send_to_email, subject, html_content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
time.sleep(1)
|
| 137 |
|
| 138 |
except Exception as e:
|
|
|
|
| 140 |
|
| 141 |
|
| 142 |
def verify_token(req):
|
| 143 |
+
"""Verifies Firebase token and checks against the ADMIN_EMAILS list."""
|
| 144 |
try:
|
| 145 |
auth_header = req.headers.get("Authorization", "").split("Bearer ")[-1]
|
| 146 |
user_email = req.headers.get("X-User-Email")
|
|
|
|
| 151 |
return None
|
| 152 |
|
| 153 |
def setup_admins():
|
| 154 |
+
"""Sets up admin users in Firebase based on the ADMIN_EMAILS list."""
|
| 155 |
ref = db.reference("admins")
|
| 156 |
for email in ADMIN_EMAILS:
|
| 157 |
uid = f"admin_{email.replace('@', '_').replace('.', '_')}"
|
|
|
|
| 159 |
setup_admins()
|
| 160 |
|
| 161 |
def assign_roster(job_id):
|
| 162 |
+
"""Assigns roster for a job."""
|
| 163 |
try:
|
| 164 |
logger.info(f"Starting roster assignment for job {job_id}")
|
| 165 |
job_ref = db.reference(f"jobs/{job_id}")
|
| 166 |
job_data = job_ref.get()
|
| 167 |
|
| 168 |
+
if not job_data or job_data.get("status") != "active": return
|
|
|
|
|
|
|
| 169 |
|
| 170 |
guarding_points = job_data.get("guarding_points", [])
|
| 171 |
assigned_members = job_data.get("assigned_members", [])
|
|
|
|
| 175 |
return
|
| 176 |
|
| 177 |
shift_number = len(job_data.get("assignments", [])) + 1
|
|
|
|
| 178 |
available_for_shift = random.sample(assigned_members, len(assigned_members))
|
| 179 |
|
| 180 |
total_guards_required = sum(int(point.get('guards', 1)) for point in guarding_points)
|
| 181 |
if len(available_for_shift) < total_guards_required:
|
| 182 |
+
logger.error(f"INSUFFICIENT MEMBERS: Job {job_id} requires {total_guards_required} but only {len(available_for_shift)} are available. Aborting.")
|
| 183 |
return
|
| 184 |
|
| 185 |
+
shift_assignments = []
|
|
|
|
| 186 |
for point in guarding_points:
|
| 187 |
+
num_guards_required = int(point.get('guards', 1))
|
| 188 |
+
guards_for_point = [available_for_shift.pop(0) for _ in range(num_guards_required) if available_for_shift]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
if guards_for_point:
|
| 190 |
+
shift_assignments.append({"point": point, "members": guards_for_point, "is_special_case": False})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
if shift_assignments:
|
| 193 |
shift_record = {
|
| 194 |
"shift_number": shift_number,
|
| 195 |
+
"assigned_at": dt_class.now(timezone.utc).isoformat(),
|
| 196 |
"assignments": shift_assignments,
|
| 197 |
"shift_id": str(uuid.uuid4())
|
| 198 |
}
|
|
|
|
| 200 |
current_assignments.append(shift_record)
|
| 201 |
job_ref.update({
|
| 202 |
"assignments": current_assignments,
|
| 203 |
+
"last_updated": dt_class.now(timezone.utc).isoformat()
|
| 204 |
})
|
| 205 |
|
| 206 |
logger.info(f"Shift {shift_number} assigned for job {job_id}.")
|
| 207 |
send_rotation_notification(job_id, shift_record)
|
| 208 |
|
| 209 |
rotation_period = job_data.get("rotation_period", 28800)
|
| 210 |
+
next_run_time = dt_class.now(timezone.utc) + datetime.timedelta(seconds=rotation_period)
|
| 211 |
|
| 212 |
scheduler.add_job(
|
| 213 |
func=assign_roster, trigger="date", run_date=next_run_time,
|
|
|
|
| 215 |
replace_existing=True
|
| 216 |
)
|
| 217 |
logger.info(f"Scheduled next rotation for job {job_id} in {rotation_period} seconds.")
|
|
|
|
|
|
|
|
|
|
| 218 |
except Exception:
|
| 219 |
logger.error(f"CRITICAL ERROR in assign_roster for job {job_id}", exc_info=True)
|
| 220 |
|
|
|
|
| 232 |
def home():
|
| 233 |
return jsonify({"message": "Rairo Guards API is running.", "status": "ok"}), 200
|
| 234 |
|
|
|
|
| 235 |
@app.route("/upload_members", methods=["POST"])
|
| 236 |
def upload_members():
|
| 237 |
if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
|
|
|
|
| 295 |
return jsonify({"error": "Guarding points must be between 5 and 15"}), 400
|
| 296 |
for i, point in enumerate(guarding_points):
|
| 297 |
point.setdefault("id", f"point_{i+1}")
|
| 298 |
+
job_data = {**data, "guarding_points": guarding_points, "created_at": dt_class.now(timezone.utc).isoformat(), "status": "created", "assignments": []}
|
| 299 |
try:
|
| 300 |
db.reference(f"jobs/{job_id}").set(job_data)
|
| 301 |
return jsonify({"message": "Job created", "job_id": job_id}), 200
|
| 302 |
except Exception as e:
|
| 303 |
+
logger.error(f"Error creating job: {e}", exc_info=True)
|
| 304 |
return jsonify({"error": "Internal server error"}), 500
|
| 305 |
|
| 306 |
@app.route("/update_job/<job_id>", methods=["POST"])
|
|
|
|
| 342 |
logger.info(f"Executing scheduled start for job {job_id}")
|
| 343 |
job_ref = db.reference(f"jobs/{job_id}")
|
| 344 |
if job_ref.get():
|
| 345 |
+
job_ref.update({"status": "active", "started_at": dt_class.now(timezone.utc).isoformat()})
|
| 346 |
assign_roster(job_id)
|
| 347 |
except Exception as e:
|
| 348 |
logger.error(f"Error in start_scheduled_job for {job_id}: {e}", exc_info=True)
|
|
|
|
| 353 |
job_ref = db.reference(f"jobs/{job_id}")
|
| 354 |
if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
|
| 355 |
try:
|
| 356 |
+
job_ref.update({"status": "active", "started_at": dt_class.now(timezone.utc).isoformat()})
|
| 357 |
logger.info(f"Job {job_id} status updated to active.")
|
| 358 |
scheduler.add_job(
|
| 359 |
func=assign_roster, trigger="date",
|
| 360 |
+
run_date=dt_class.now(timezone.utc) + datetime.timedelta(seconds=5),
|
| 361 |
args=[job_id], id=f"start_{job_id}", replace_existing=True
|
| 362 |
)
|
|
|
|
| 363 |
return jsonify({"message": f"Job {job_id} started"}), 202
|
| 364 |
except Exception as e:
|
| 365 |
logger.error(f"Error starting job {job_id}: {e}", exc_info=True)
|
|
|
|
| 468 |
return jsonify({"error": "Guarding point not found in job"}), 404
|
| 469 |
if not any(m["id"] == member_id for m in job_data.get("assigned_members", [])):
|
| 470 |
return jsonify({"error": "Member not assigned to job"}), 404
|
| 471 |
+
special_case = {"id": str(uuid.uuid4()), **data, "created_at": dt_class.now(timezone.utc).isoformat()}
|
| 472 |
special_cases = job_data.get("special_cases", [])
|
| 473 |
special_cases.append(special_case)
|
| 474 |
try:
|
|
|
|
| 500 |
except Exception as e:
|
| 501 |
logger.error(f"Error getting roster for {job_id}: {e}", exc_info=True)
|
| 502 |
return jsonify({"error": "Internal server error"}), 500
|
| 503 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
# === Run Server ===
|
| 505 |
if __name__ == "__main__":
|
| 506 |
logger.info("Starting Flask application...")
|