rairo commited on
Commit
8ad084f
·
verified ·
1 Parent(s): 34abb5d

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +138 -460
main.py CHANGED
@@ -1,7 +1,7 @@
1
  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
@@ -12,9 +12,9 @@ from firebase_admin import credentials, db, auth as firebase_auth
12
  from resend import Emails
13
  import logging
14
  from logging.handlers import RotatingFileHandler
15
- import time # <-- ADDED IMPORT
16
- import random # <-- ADDED IMPORT
17
- from datetime import datetime # <-- ADDED IMPORT FOR CONVENIENCE
18
 
19
  # === Logging Configuration ===
20
  # Configure logging
@@ -110,7 +110,6 @@ def send_email(to, subject, html):
110
  "subject": subject,
111
  "html": html
112
  })
113
- # Check for None response, which might indicate an issue not raising an exception
114
  if response is None:
115
  logger.warning(f"Email API returned None for recipient {to}. Check Resend dashboard for suppression or other issues.")
116
  return None
@@ -120,7 +119,6 @@ def send_email(to, subject, html):
120
  logger.error(f"Email error to {to}: {e}")
121
  return None
122
 
123
- # === REFACTORED FUNCTION (send_rotation_notification) ===
124
  def send_rotation_notification(job_id, shift_record):
125
  """Send email notification for rotation to all admins with rate-limiting prevention"""
126
  try:
@@ -134,16 +132,13 @@ def send_rotation_notification(job_id, shift_record):
134
  job_name = job_data.get("name", "Unknown Job")
135
  shift_number = shift_record.get("shift_number", "Unknown")
136
 
137
- # --- DATE/TIME FORMATTING REFACTOR ---
138
  try:
139
- # Parse ISO string and format into a human-readable string
140
  assigned_time_iso = shift_record.get('assigned_at', '')
141
- dt_object = datetime.fromisoformat(assigned_time_iso)
142
  human_readable_time = dt_object.strftime("%B %d, %Y at %I:%M %p UTC")
143
  except (ValueError, TypeError):
144
  human_readable_time = shift_record.get('assigned_at', 'Unknown')
145
 
146
- # --- EMAIL CONTENT REFACTOR ---
147
  html_content = f"""
148
  <h2>Guard Rotation Notification</h2>
149
  <p><strong>Job:</strong> {job_name}</p>
@@ -165,12 +160,9 @@ def send_rotation_notification(job_id, shift_record):
165
 
166
  for assignment in shift_record.get("assignments", []):
167
  point_name = assignment.get("point", {}).get("name", "Unknown Point")
168
-
169
- # Create a list of member names for the point
170
  assigned_members = assignment.get("members", [])
171
  member_names = [member.get("name", "Unknown Member") for member in assigned_members]
172
  members_html = "<br>".join(member_names) if member_names else "None"
173
-
174
  is_special = "Yes" if assignment.get("is_special_case", False) else "No"
175
 
176
  html_content += f"""
@@ -187,14 +179,12 @@ def send_rotation_notification(job_id, shift_record):
187
  <p><em>This is an automated notification from the Guard Rotation System.</em></p>
188
  """
189
 
190
- # Send email to all admin emails
191
  subject = f"Guard Rotation - {job_name} (Shift {shift_number})"
192
 
193
  for admin_email in ADMIN_EMAILS:
194
  logger.info(f"Sending rotation notification to {admin_email}...")
195
  send_email(admin_email, subject, html_content)
196
- # --- RATE LIMIT PREVENTION ---
197
- time.sleep(1) # Add a 1-second delay between sending emails
198
 
199
  except Exception as e:
200
  logger.error(f"Error sending rotation notification for job {job_id}: {e}")
@@ -231,7 +221,7 @@ def verify_token(req):
231
  print(f"Unexpected error during token verification: {e}")
232
  return None
233
 
234
- # === Admin Setup (Legacy - kept for compatibility) ===
235
  def setup_admins():
236
  ref = db.reference("admins")
237
  for email in ADMIN_EMAILS:
@@ -240,8 +230,6 @@ def setup_admins():
240
  setup_admins()
241
 
242
  # === Core Functions ===
243
-
244
- # === REFACTORED FUNCTION (assign_roster) ===
245
  def assign_roster(job_id):
246
  """Assign roster for a job - implements the multi-guard rotation algorithm"""
247
  try:
@@ -260,57 +248,43 @@ def assign_roster(job_id):
260
  guarding_points = job_data.get("guarding_points", [])
261
  assigned_members = job_data.get("assigned_members", [])
262
 
263
- if not guarding_points:
264
- logger.warning(f"No guarding points defined for job {job_id}")
265
- return
266
- if not assigned_members:
267
- logger.warning(f"No members assigned to job {job_id}")
268
  return
269
 
270
- # --- ALGORITHM REFACTOR ---
271
-
272
- # 1. Prepare for the new shift
273
  shift_number = len(job_data.get("assignments", [])) + 1
274
  shift_assignments = []
275
-
276
- # Create a shuffled, available list of members for this specific shift.
277
- # This ensures fairness and prevents a guard from being assigned to multiple points in one shift.
278
  available_for_shift_members = random.sample(assigned_members, len(assigned_members))
279
 
280
  total_guards_required = sum(point.get('guards', 1) for point in guarding_points)
281
  if len(available_for_shift_members) < total_guards_required:
282
- logger.error(f"Not enough members ({len(available_for_shift_members)}) to fill all points ({total_guards_required}) for job {job_id}. Aborting shift creation.")
283
- # OPTIONAL: Send an alert email to admins here about guard shortage.
284
  return
285
 
286
  logger.info(f"Applying standard rotation for job {job_id}, shift {shift_number}")
287
 
288
- # 2. Loop through each point and assign the required number of unique guards
289
  for point in guarding_points:
290
  guards_for_point = []
291
- num_guards_required = point.get('guards', 1) # Get required guards from point data, default to 1
292
 
293
- # Assign the required number of guards to this point
294
  for _ in range(num_guards_required):
295
  if not available_for_shift_members:
296
  logger.warning(f"Ran out of available members while assigning to point {point.get('name')}")
297
  break
298
-
299
  selected_member = available_for_shift_members.pop(0)
300
  guards_for_point.append(selected_member)
301
 
302
  if guards_for_point:
303
  shift_assignments.append({
304
  "point": point,
305
- "members": guards_for_point, # Key changed from "member" to "members"
306
  "is_special_case": False
307
  })
308
 
309
- # 3. Update database and send notification
310
  if shift_assignments:
311
  shift_record = {
312
  "shift_number": shift_number,
313
- "assigned_at": datetime.now().isoformat(),
314
  "assignments": shift_assignments,
315
  "shift_id": str(uuid.uuid4())
316
  }
@@ -320,14 +294,15 @@ def assign_roster(job_id):
320
 
321
  job_ref.update({
322
  "assignments": current_assignments,
323
- "last_updated": datetime.now().isoformat()
324
  })
325
 
326
- logger.info(f"Shift {shift_number} assigned for job {job_id} with {len(shift_assignments)} point assignments.")
327
  send_rotation_notification(job_id, shift_record)
328
 
329
  rotation_period = job_data.get("rotation_period", 28800)
330
- next_run_time = datetime.now() + datetime.timedelta(seconds=rotation_period)
 
331
 
332
  scheduler.add_job(
333
  func=assign_roster,
@@ -352,84 +327,98 @@ def check_and_rotate_guards():
352
  jobs = jobs_ref.get()
353
 
354
  if not jobs:
355
- logger.debug("No jobs found")
356
  return
357
 
358
  for job_id, job_data in jobs.items():
359
- logger.debug(f"Evaluating job {job_id} for rotation...")
360
  if job_data.get("status") != "active":
361
- logger.debug(f"Job {job_id} is not active, skipping.")
362
  continue
363
 
364
  assignments = job_data.get("assignments", [])
365
  if not assignments:
366
- logger.debug(f"Job {job_id} has no assignments yet, not started.")
367
  continue
368
 
369
  last_assignment = assignments[-1]
370
  try:
371
  assigned_at_str = last_assignment.get("assigned_at", "")
372
- assigned_at = datetime.fromisoformat(assigned_at_str.replace("Z", "+00:00"))
373
  except (ValueError, TypeError):
374
  logger.warning(f"Could not parse assigned_at time for job {job_id}: {last_assignment.get('assigned_at')}")
375
  continue
376
 
377
- now = datetime.now(assigned_at.tzinfo)
378
  rotation_period = job_data.get("rotation_period", 28800)
379
  time_since_last_assignment = (now - assigned_at).total_seconds()
380
 
381
- logger.debug(f"Job {job_id}: Last assignment was {time_since_last_assignment:.0f}s ago. Period is {rotation_period}s.")
382
-
383
  if time_since_last_assignment > rotation_period:
384
  logger.info(f"Rotating guards for job {job_id} based on interval check.")
385
  assign_roster(job_id)
386
- else:
387
- logger.debug(f"Job {job_id} not due for rotation yet.")
388
 
389
  except Exception as e:
390
  logger.error(f"Error in check_and_rotate_guards: {e}", exc_info=True)
391
 
392
- # === Routes with Logging ===
393
  @app.before_request
394
  def log_request_info():
395
- """Log incoming request details."""
396
  logger.info(f"Incoming request: {request.method} {request.url}")
397
- logger.debug(f"Headers: {dict(request.headers)}")
398
 
399
  @app.route("/", methods=["GET"])
400
  def home():
401
- """Simple health check or API root endpoint."""
402
  return jsonify({"message": "Rairo Guards API is running.", "status": "ok"}), 200
403
 
404
  @app.after_request
405
  def log_response_info(response):
406
- """Log outgoing response details."""
407
  logger.info(f"Outgoing response: {response.status} for {request.method} {request.url}")
408
  return response
409
 
410
- @app.route("/upload_members", methods=["POST"])
411
- def upload_members():
412
- logger.info("Handling /upload_members request")
413
  user = verify_token(request)
414
  if not user:
415
- logger.warning("Unauthorized access attempt to /upload_members")
416
  return jsonify({"error": "Unauthorized"}), 401
417
 
418
- file = request.files.get("file")
419
- if not file:
420
- logger.warning("No file uploaded in /upload_members request")
421
- return jsonify({"error": "No file uploaded"}), 400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
 
 
 
 
 
 
 
 
423
  try:
424
  df = pd.read_csv(file) if file.filename.endswith(".csv") else pd.read_excel(file)
425
  members = df.to_dict(orient="records")
426
- logger.info(f"Processing {len(members)} members from uploaded file.")
427
-
428
  for member in members:
429
- member_id = str(uuid.uuid4())
430
- db.reference(f"members/{member_id}").set(member)
431
-
432
- logger.info("Members uploaded successfully.")
433
  return jsonify({"message": "Members uploaded successfully"}), 200
434
  except Exception as e:
435
  logger.error(f"Error processing uploaded members file: {e}", exc_info=True)
@@ -437,69 +426,40 @@ def upload_members():
437
 
438
  @app.route("/add_member", methods=["POST"])
439
  def add_member():
440
- logger.info("Handling /add_member request")
441
  user = verify_token(request)
442
- if not user:
443
- logger.warning("Unauthorized access attempt to /add_member")
444
- return jsonify({"error": "Unauthorized"}), 401
445
-
446
  data = request.json
447
- logger.debug(f"Adding member with data: {data}")
448
  member_id = str(uuid.uuid4())
449
  try:
450
  db.reference(f"members/{member_id}").set(data)
451
- logger.info(f"Member added successfully with ID: {member_id}")
452
  return jsonify({"message": "Member added", "member_id": member_id}), 200
453
  except Exception as e:
454
- logger.error(f"Error adding member {member_id}: {e}", exc_info=True)
455
  return jsonify({"error": "Internal server error"}), 500
456
 
457
  @app.route("/update_member/<member_id>", methods=["POST"])
458
  def update_member(member_id):
459
- logger.info(f"Handling /update_member/{member_id} request")
460
  user = verify_token(request)
461
- if not user:
462
- logger.warning(f"Unauthorized access attempt to /update_member/{member_id}")
463
- return jsonify({"error": "Unauthorized"}), 401
464
-
465
  data = request.json
466
- if not data:
467
- logger.warning(f"Update member request for {member_id} has no JSON body.")
468
- return jsonify({"error": "Request body cannot be empty"}), 400
469
-
470
- logger.debug(f"Updating member {member_id} with data: {data}")
471
  member_ref = db.reference(f"members/{member_id}")
472
-
473
- if not member_ref.get():
474
- logger.warning(f"Attempted to update non-existent member {member_id}")
475
- return jsonify({"error": "Member not found"}), 404
476
-
477
  try:
478
  member_ref.update(data)
479
- logger.info(f"Member {member_id} updated successfully.")
480
- updated_data = {**data, "id": member_id}
481
- return jsonify({"message": "Member updated", "member": updated_data}), 200
482
  except Exception as e:
483
  logger.error(f"Error updating member {member_id}: {e}", exc_info=True)
484
  return jsonify({"error": "Internal server error"}), 500
485
 
486
  @app.route("/delete_member/<member_id>", methods=["DELETE"])
487
  def delete_member(member_id):
488
- logger.info(f"Handling /delete_member/{member_id} request")
489
  user = verify_token(request)
490
- if not user:
491
- logger.warning(f"Unauthorized access attempt to /delete_member/{member_id}")
492
- return jsonify({"error": "Unauthorized"}), 401
493
-
494
  member_ref = db.reference(f"members/{member_id}")
495
-
496
- if not member_ref.get():
497
- logger.warning(f"Attempted to delete non-existent member {member_id}")
498
- return jsonify({"error": "Member not found"}), 404
499
-
500
  try:
501
  member_ref.delete()
502
- logger.info(f"Member {member_id} deleted successfully.")
503
  return jsonify({"message": f"Member {member_id} deleted"}), 200
504
  except Exception as e:
505
  logger.error(f"Error deleting member {member_id}: {e}", exc_info=True)
@@ -507,72 +467,33 @@ def delete_member(member_id):
507
 
508
  @app.route("/create_job", methods=["POST"])
509
  def create_job():
510
- logger.info("Handling /create_job request")
511
  user = verify_token(request)
512
- if not user:
513
- logger.warning("Unauthorized access attempt to /create_job")
514
- return jsonify({"error": "Unauthorized"}), 401
515
-
516
  data = request.json
517
- logger.debug(f"Creating job with data: {data}")
518
  job_id = str(uuid.uuid4())
519
-
520
  guarding_points = data.get("guarding_points", [])
521
- if len(guarding_points) < 5 or len(guarding_points) > 15:
522
- logger.warning(f"Invalid number of guarding points ({len(guarding_points)}) for job creation.")
523
  return jsonify({"error": "Guarding points must be between 5 and 15"}), 400
524
-
525
- for i, point in enumerate(guarding_points):
526
- if "id" not in point:
527
- point["id"] = f"point_{i+1}"
528
-
529
- job_data = {
530
- **data,
531
- "guarding_points": guarding_points,
532
- "created_at": datetime.now().isoformat(),
533
- "status": "created",
534
- "assignments": [],
535
- "rotation_history": {},
536
- "rotation_period": data.get("rotation_period", 28800),
537
- "scheduled_start_time": data.get("scheduled_start_time"),
538
- "has_scheduled_rotation": False,
539
- "special_cases": data.get("special_cases", [])
540
- }
541
  try:
542
  db.reference(f"jobs/{job_id}").set(job_data)
543
- logger.info(f"Job created successfully with ID: {job_id}")
544
  return jsonify({"message": "Job created", "job_id": job_id}), 200
545
  except Exception as e:
546
- logger.error(f"Error creating job {job_id}: {e}", exc_info=True)
547
  return jsonify({"error": "Internal server error"}), 500
548
 
549
  @app.route("/update_job/<job_id>", methods=["POST"])
550
  def update_job(job_id):
551
- logger.info(f"Handling /update_job/{job_id} request")
552
  user = verify_token(request)
553
- if not user:
554
- logger.warning(f"Unauthorized access attempt to /update_job/{job_id}")
555
- return jsonify({"error": "Unauthorized"}), 401
556
-
557
  data = request.json
558
- if not data:
559
- logger.warning(f"Update job request for {job_id} has no JSON body.")
560
- return jsonify({"error": "Request body cannot be empty"}), 400
561
-
562
- logger.debug(f"Updating job {job_id} with data: {data}")
563
  job_ref = db.reference(f"jobs/{job_id}")
564
-
565
- if not job_ref.get():
566
- logger.warning(f"Attempted to update non-existent job {job_id}")
567
- return jsonify({"error": "Job not found"}), 404
568
-
569
  try:
570
- data.pop('assignments', None)
571
- data.pop('rotation_history', None)
572
- data.pop('status', None)
573
-
574
  job_ref.update(data)
575
- logger.info(f"Job {job_id} updated successfully.")
576
  return jsonify({"message": f"Job {job_id} updated"}), 200
577
  except Exception as e:
578
  logger.error(f"Error updating job {job_id}: {e}", exc_info=True)
@@ -580,130 +501,43 @@ def update_job(job_id):
580
 
581
  @app.route("/schedule_job/<job_id>", methods=["POST"])
582
  def schedule_job(job_id):
583
- logger.info(f"Handling /schedule_job/{job_id} request")
584
  user = verify_token(request)
585
- if not user:
586
- logger.warning(f"Unauthorized access attempt to /schedule_job/{job_id}")
587
- return jsonify({"error": "Unauthorized"}), 401
588
-
589
  data = request.json
590
  start_time_iso = data.get("start_time")
591
- logger.debug(f"Scheduling job {job_id} with data: {data}")
592
-
593
- if not start_time_iso:
594
- logger.warning("start_time is required for scheduling job.")
595
- return jsonify({"error": "start_time is required (ISO format)"}), 400
596
-
597
  job_ref = db.reference(f"jobs/{job_id}")
598
- if not job_ref.get():
599
- logger.warning(f"Job {job_id} not found for scheduling.")
600
- return jsonify({"error": "Job not found"}), 404
601
-
602
  try:
603
- start_time = datetime.fromisoformat(start_time_iso.replace("Z", "+00:00"))
604
- logger.debug(f"Parsed start time: {start_time}")
605
  except ValueError:
606
- logger.error(f"Invalid start_time format provided: {start_time_iso}")
607
  return jsonify({"error": "Invalid start_time format."}), 400
608
-
609
  try:
610
- job_ref.update({
611
- "scheduled_start_time": start_time_iso,
612
- "status": "scheduled"
613
- })
614
- logger.info(f"Job {job_id} status updated to scheduled.")
615
- except Exception as e:
616
- logger.error(f"Error updating job {job_id} for scheduling: {e}", exc_info=True)
617
- return jsonify({"error": "Internal server error"}), 500
618
-
619
- try:
620
- scheduler.add_job(
621
- func=start_scheduled_job,
622
- trigger="date",
623
- run_date=start_time,
624
- args=[job_id],
625
- id=f"start_{job_id}_{uuid.uuid4().hex[:8]}"
626
- )
627
- logger.info(f"Job {job_id} scheduled to start at {start_time_iso}")
628
  return jsonify({"message": f"Job {job_id} scheduled to start at {start_time_iso}"}), 200
629
  except Exception as e:
630
- logger.error(f"Error scheduling start job for {job_id}: {e}", exc_info=True)
631
- return jsonify({"error": "Failed to schedule job start"}), 500
632
 
633
  def start_scheduled_job(job_id):
634
- """Function to start a scheduled job"""
635
  try:
636
  logger.info(f"Executing scheduled start for job {job_id}")
637
  job_ref = db.reference(f"jobs/{job_id}")
638
- if not job_ref.get():
639
- logger.error(f"Job {job_id} not found for scheduled start")
640
- return
641
-
642
- job_ref.update({
643
- "status": "active",
644
- "started_at": datetime.now().isoformat()
645
- })
646
- logger.info(f"Job {job_id} status updated to active.")
647
-
648
  assign_roster(job_id)
649
- logger.info(f"Job {job_id} started via schedule")
650
-
651
  except Exception as e:
652
- logger.error(f"Error starting scheduled job {job_id}: {e}", exc_info=True)
653
-
654
- @app.route("/start_job/<job_id>", methods=["POST"])
655
- def start_job(job_id):
656
- logger.info(f"Handling /start_job/{job_id} request")
657
- user = verify_token(request)
658
- if not user:
659
- logger.warning(f"Unauthorized access attempt to /start_job/{job_id}")
660
- return jsonify({"error": "Unauthorized"}), 401
661
-
662
- job_ref = db.reference(f"jobs/{job_id}")
663
- if not job_ref.get():
664
- logger.warning(f"Job {job_id} not found for starting.")
665
- return jsonify({"error": "Job not found"}), 404
666
-
667
- try:
668
- job_ref.update({
669
- "status": "active",
670
- "started_at": datetime.now().isoformat()
671
- })
672
- logger.info(f"Job {job_id} status updated to active.")
673
- except Exception as e:
674
- logger.error(f"Error updating job {job_id} to active: {e}", exc_info=True)
675
- return jsonify({"error": "Internal server error"}), 500
676
-
677
- try:
678
- scheduler.add_job(
679
- func=assign_roster,
680
- trigger="date",
681
- run_date=datetime.now() + datetime.timedelta(seconds=5),
682
- args=[job_id],
683
- id=f"start_{job_id}_{uuid.uuid4().hex[:8]}"
684
- )
685
- logger.info(f"First assignment for job {job_id} scheduled.")
686
- return jsonify({"message": f"Job {job_id} started"}), 202
687
- except Exception as e:
688
- logger.error(f"Error scheduling first assignment for job {job_id}: {e}", exc_info=True)
689
- return jsonify({"error": "Failed to start job assignments"}), 500
690
 
691
  @app.route("/pause_job/<job_id>", methods=["POST"])
692
  def pause_job(job_id):
693
- logger.info(f"Handling /pause_job/{job_id} request")
694
  user = verify_token(request)
695
- if not user:
696
- logger.warning(f"Unauthorized access attempt to /pause_job/{job_id}")
697
- return jsonify({"error": "Unauthorized"}), 401
698
-
699
  job_ref = db.reference(f"jobs/{job_id}")
700
- if not job_ref.get():
701
- logger.warning(f"Job {job_id} not found for pausing.")
702
- return jsonify({"error": "Job not found"}), 404
703
-
704
  try:
705
  job_ref.update({"status": "paused"})
706
- logger.info(f"Job {job_id} status updated to paused.")
707
  return jsonify({"message": f"Job {job_id} paused"}), 200
708
  except Exception as e:
709
  logger.error(f"Error pausing job {job_id}: {e}", exc_info=True)
@@ -711,20 +545,12 @@ def pause_job(job_id):
711
 
712
  @app.route("/delete_job/<job_id>", methods=["DELETE"])
713
  def delete_job(job_id):
714
- logger.info(f"Handling /delete_job/{job_id} request")
715
  user = verify_token(request)
716
- if not user:
717
- logger.warning(f"Unauthorized access attempt to /delete_job/{job_id}")
718
- return jsonify({"error": "Unauthorized"}), 401
719
-
720
  job_ref = db.reference(f"jobs/{job_id}")
721
- if not job_ref.get():
722
- logger.warning(f"Job {job_id} not found for deletion.")
723
- return jsonify({"error": "Job not found"}), 404
724
-
725
  try:
726
  job_ref.delete()
727
- logger.info(f"Job {job_id} deleted successfully.")
728
  return jsonify({"message": f"Job {job_id} deleted"}), 200
729
  except Exception as e:
730
  logger.error(f"Error deleting job {job_id}: {e}", exc_info=True)
@@ -732,277 +558,129 @@ def delete_job(job_id):
732
 
733
  @app.route("/jobs", methods=["GET"])
734
  def get_jobs():
735
- logger.info("Handling /jobs request")
736
  user = verify_token(request)
737
- if not user:
738
- logger.warning("Unauthorized access attempt to /jobs")
739
- return jsonify({"error": "Unauthorized"}), 401
740
-
741
  try:
742
- jobs_ref = db.reference("jobs")
743
- jobs = jobs_ref.get()
744
-
745
- if not jobs:
746
- logger.debug("No jobs found in database.")
747
- return jsonify({"jobs": []}), 200
748
-
749
- jobs_list = []
750
- for job_id, job_data in jobs.items():
751
- job_data["id"] = job_id
752
- jobs_list.append(job_data)
753
-
754
- logger.debug(f"Retrieved {len(jobs_list)} jobs.")
755
  return jsonify({"jobs": jobs_list}), 200
756
  except Exception as e:
757
- logger.error(f"Error retrieving jobs: {e}", exc_info=True)
758
  return jsonify({"error": "Internal server error"}), 500
759
 
760
  @app.route("/job/<job_id>", methods=["GET"])
761
  def get_job(job_id):
762
- logger.info(f"Handling /job/{job_id} request")
763
  user = verify_token(request)
764
- if not user:
765
- logger.warning(f"Unauthorized access attempt to /job/{job_id}")
766
- return jsonify({"error": "Unauthorized"}), 401
767
-
768
  try:
769
- job_ref = db.reference(f"jobs/{job_id}")
770
- job_data = job_ref.get()
771
-
772
- if not job_data:
773
- logger.warning(f"Job {job_id} not found.")
774
- return jsonify({"error": "Job not found"}), 404
775
-
776
- job_data["id"] = job_id
777
- logger.debug(f"Retrieved job {job_id}.")
778
- return jsonify({"job": job_data}), 200
779
  except Exception as e:
780
- logger.error(f"Error retrieving job {job_id}: {e}", exc_info=True)
781
  return jsonify({"error": "Internal server error"}), 500
782
 
783
  @app.route("/members", methods=["GET"])
784
  def get_members():
785
- logger.info("Handling /members request")
786
  user = verify_token(request)
787
- if not user:
788
- logger.warning("Unauthorized access attempt to /members")
789
- return jsonify({"error": "Unauthorized"}), 401
790
-
791
  try:
792
- members_ref = db.reference("members")
793
- members = members_ref.get()
794
-
795
- if not members:
796
- logger.debug("No members found in database.")
797
- return jsonify({"members": []}), 200
798
-
799
- members_list = []
800
- for member_id, member_data in members.items():
801
- member_data["id"] = member_id
802
- members_list.append(member_data)
803
-
804
- logger.debug(f"Retrieved {len(members_list)} members.")
805
  return jsonify({"members": members_list}), 200
806
  except Exception as e:
807
- logger.error(f"Error retrieving members: {e}", exc_info=True)
808
  return jsonify({"error": "Internal server error"}), 500
809
 
810
  @app.route("/assign_members_to_job", methods=["POST"])
811
  def assign_members_to_job():
812
- logger.info("Handling /assign_members_to_job request")
813
  user = verify_token(request)
814
- if not user:
815
- logger.warning("Unauthorized access attempt to /assign_members_to_job")
816
- return jsonify({"error": "Unauthorized"}), 401
817
-
818
  data = request.json
819
  job_id = data.get("job_id")
820
  member_ids = data.get("member_ids", [])
821
- logger.debug(f"Assigning members to job {job_id} with data: {data}")
822
-
823
- if not job_id or not member_ids:
824
- logger.warning("job_id and member_ids are required for member assignment.")
825
- return jsonify({"error": "job_id and member_ids are required"}), 400
826
-
827
  job_ref = db.reference(f"jobs/{job_id}")
828
- if not job_ref.get():
829
- logger.warning(f"Job {job_id} not found for member assignment.")
830
- return jsonify({"error": "Job not found"}), 404
831
-
832
- try:
833
- members_ref = db.reference("members")
834
- all_members = members_ref.get()
835
- except Exception as e:
836
- logger.error(f"Error retrieving members for assignment to job {job_id}: {e}", exc_info=True)
837
- return jsonify({"error": "Internal server error"}), 500
838
-
839
- if not all_members:
840
- logger.warning("No members found in database for assignment.")
841
- return jsonify({"error": "No members found"}), 404
842
-
843
- selected_members = []
844
- for member_id in member_ids:
845
- if member_id in all_members:
846
- member_data = all_members[member_id]
847
- member_data["id"] = member_id
848
- selected_members.append(member_data)
849
-
850
- if len(selected_members) < 25:
851
- logger.warning(f"Insufficient members ({len(selected_members)}) assigned to job {job_id}. Minimum is 25.")
852
- return jsonify({"error": "At least 25 members must be assigned to the job"}), 400
853
-
854
  try:
 
 
 
 
855
  job_ref.update({"assigned_members": selected_members})
856
- logger.info(f"{len(selected_members)} members assigned to job {job_id}.")
857
- return jsonify({
858
- "message": f"{len(selected_members)} members assigned to job {job_id}",
859
- "assigned_members": selected_members
860
- }), 200
861
  except Exception as e:
862
- logger.error(f"Error updating assigned members for job {job_id}: {e}", exc_info=True)
863
  return jsonify({"error": "Internal server error"}), 500
864
 
865
  @app.route("/update_job_settings/<job_id>", methods=["POST"])
866
  def update_job_settings(job_id):
867
- logger.info(f"Handling /update_job_settings/{job_id} request")
868
  user = verify_token(request)
869
- if not user:
870
- logger.warning(f"Unauthorized access attempt to /update_job_settings/{job_id}")
871
- return jsonify({"error": "Unauthorized"}), 401
872
-
873
  data = request.json
874
  rotation_period = data.get("rotation_period")
875
- logger.debug(f"Updating settings for job {job_id} with data: {data}")
876
-
877
- if rotation_period is None:
878
- logger.warning("rotation_period is required for job settings update.")
879
- return jsonify({"error": "rotation_period is required (in seconds)"}), 400
880
-
881
  job_ref = db.reference(f"jobs/{job_id}")
882
- if not job_ref.get():
883
- logger.warning(f"Job {job_id} not found for settings update.")
884
- return jsonify({"error": "Job not found"}), 404
885
-
886
  try:
887
  job_ref.update({"rotation_period": rotation_period})
888
- logger.info(f"Job {job_id} rotation period updated to {rotation_period} seconds.")
889
- return jsonify({
890
- "message": f"Job {job_id} rotation period updated to {rotation_period} seconds",
891
- "rotation_period": rotation_period
892
- }), 200
893
  except Exception as e:
894
- logger.error(f"Error updating rotation period for job {job_id}: {e}", exc_info=True)
895
  return jsonify({"error": "Internal server error"}), 500
896
 
897
  @app.route("/add_special_case/<job_id>", methods=["POST"])
898
  def add_special_case(job_id):
899
- logger.info(f"Handling /add_special_case/{job_id} request")
900
  user = verify_token(request)
901
- if not user:
902
- logger.warning(f"Unauthorized access attempt to /add_special_case/{job_id}")
903
- return jsonify({"error": "Unauthorized"}), 401
904
-
905
  data = request.json
906
- point_id = data.get("point_id")
907
- member_id = data.get("member_id")
908
- reason = data.get("reason", "Special Assignment")
909
- shift_number = data.get("shift_number")
910
- active = data.get("active", False)
911
- logger.debug(f"Adding special case to job {job_id} with data: {data}")
912
-
913
- if not point_id or not member_id:
914
- logger.warning("point_id and member_id are required for special case.")
915
- return jsonify({"error": "point_id and member_id are required"}), 400
916
-
917
  job_ref = db.reference(f"jobs/{job_id}")
918
  job_data = job_ref.get()
919
-
920
- if not job_data:
921
- logger.warning(f"Job {job_id} not found for special case addition.")
922
- return jsonify({"error": "Job not found"}), 404
923
-
924
- guarding_points = job_data.get("guarding_points", [])
925
- assigned_members = job_data.get("assigned_members", [])
926
-
927
- point_exists = any(p["id"] == point_id for p in guarding_points)
928
- member_exists = any(m["id"] == member_id for m in assigned_members)
929
-
930
- if not point_exists:
931
- logger.warning(f"Guarding point {point_id} not found in job {job_id}.")
932
  return jsonify({"error": "Guarding point not found in job"}), 404
933
- if not member_exists:
934
- logger.warning(f"Member {member_id} not assigned to job {job_id}.")
935
  return jsonify({"error": "Member not assigned to job"}), 404
936
-
937
- special_case = {
938
- "id": str(uuid.uuid4()),
939
- "point_id": point_id,
940
- "member_id": member_id,
941
- "reason": reason,
942
- "shift_number": shift_number,
943
- "active": active,
944
- "created_at": datetime.now().isoformat()
945
- }
946
-
947
  special_cases = job_data.get("special_cases", [])
948
  special_cases.append(special_case)
949
-
950
  try:
951
  job_ref.update({"special_cases": special_cases})
952
- logger.info(f"Special case added to job {job_id}.")
953
- return jsonify({
954
- "message": "Special case added",
955
- "special_case": special_case
956
- }), 200
957
  except Exception as e:
958
  logger.error(f"Error adding special case to job {job_id}: {e}", exc_info=True)
959
  return jsonify({"error": "Internal server error"}), 500
960
 
961
  @app.route("/get_roster/<job_id>", methods=["GET"])
962
  def get_roster(job_id):
963
- logger.info(f"Handling /get_roster/{job_id} request")
964
  user = verify_token(request)
965
- if not user:
966
- logger.warning(f"Unauthorized access attempt to /get_roster/{job_id}")
967
- return jsonify({"error": "Unauthorized"}), 401
968
-
969
  try:
970
- job_ref = db.reference(f"jobs/{job_id}")
971
- job_data = job_ref.get()
972
-
973
- if not job_data:
974
- logger.warning(f"Job {job_id} not found for roster retrieval.")
975
- return jsonify({"error": "Job not found"}), 404
976
-
977
  assignments = job_data.get("assignments", [])
978
  roster_data = []
979
  for shift in assignments:
980
- shift_number = shift["shift_number"]
981
- assigned_at = shift["assigned_at"]
982
-
983
  for assignment in shift.get("assignments", []):
984
  member_names = [m.get("name", "Unknown") for m in assignment.get("members", [])]
985
  roster_data.append({
986
- "shift_number": shift_number,
987
- "assigned_at": assigned_at,
988
  "point_name": assignment.get("point", {}).get("name", "Unknown"),
989
- "member_names": ", ".join(member_names), # Join names for display
990
  "is_special_case": assignment.get("is_special_case", False),
991
  })
992
-
993
- logger.debug(f"Retrieved roster for job {job_id} with {len(roster_data)} entries.")
994
- return jsonify({
995
- "roster": roster_data,
996
- "job_name": job_data.get("name", "Unknown Job"),
997
- "total_shifts": len(assignments)
998
- }), 200
999
  except Exception as e:
1000
- logger.error(f"Error retrieving roster for job {job_id}: {e}", exc_info=True)
1001
  return jsonify({"error": "Internal server error"}), 500
1002
 
1003
  # === Schedule periodic guard rotation ===
1004
  try:
1005
- # This job checks for any rotations that might have been missed if the server restarted
1006
  scheduler.add_job(
1007
  func=check_and_rotate_guards,
1008
  trigger="interval",
@@ -1017,4 +695,4 @@ except Exception as e:
1017
  # === Run Server ===
1018
  if __name__ == "__main__":
1019
  logger.info("Starting Flask application...")
1020
- app.run(debug=True, host="0.0.0.0", port=int(os.getenv("PORT", 7860)))
 
1
  import os
2
  import json
3
  import uuid
4
+ import datetime # <-- This is the main module
5
  import pandas as pd
6
  from flask import Flask, request, jsonify
7
  from flask_cors import CORS
 
12
  from resend import Emails
13
  import logging
14
  from logging.handlers import RotatingFileHandler
15
+ import time
16
+ import random
17
+ from datetime import datetime as dt_class # Using an alias for the class to avoid confusion
18
 
19
  # === Logging Configuration ===
20
  # Configure logging
 
110
  "subject": subject,
111
  "html": html
112
  })
 
113
  if response is None:
114
  logger.warning(f"Email API returned None for recipient {to}. Check Resend dashboard for suppression or other issues.")
115
  return None
 
119
  logger.error(f"Email error to {to}: {e}")
120
  return None
121
 
 
122
  def send_rotation_notification(job_id, shift_record):
123
  """Send email notification for rotation to all admins with rate-limiting prevention"""
124
  try:
 
132
  job_name = job_data.get("name", "Unknown Job")
133
  shift_number = shift_record.get("shift_number", "Unknown")
134
 
 
135
  try:
 
136
  assigned_time_iso = shift_record.get('assigned_at', '')
137
+ dt_object = dt_class.fromisoformat(assigned_time_iso)
138
  human_readable_time = dt_object.strftime("%B %d, %Y at %I:%M %p UTC")
139
  except (ValueError, TypeError):
140
  human_readable_time = shift_record.get('assigned_at', 'Unknown')
141
 
 
142
  html_content = f"""
143
  <h2>Guard Rotation Notification</h2>
144
  <p><strong>Job:</strong> {job_name}</p>
 
160
 
161
  for assignment in shift_record.get("assignments", []):
162
  point_name = assignment.get("point", {}).get("name", "Unknown Point")
 
 
163
  assigned_members = assignment.get("members", [])
164
  member_names = [member.get("name", "Unknown Member") for member in assigned_members]
165
  members_html = "<br>".join(member_names) if member_names else "None"
 
166
  is_special = "Yes" if assignment.get("is_special_case", False) else "No"
167
 
168
  html_content += f"""
 
179
  <p><em>This is an automated notification from the Guard Rotation System.</em></p>
180
  """
181
 
 
182
  subject = f"Guard Rotation - {job_name} (Shift {shift_number})"
183
 
184
  for admin_email in ADMIN_EMAILS:
185
  logger.info(f"Sending rotation notification to {admin_email}...")
186
  send_email(admin_email, subject, html_content)
187
+ time.sleep(1)
 
188
 
189
  except Exception as e:
190
  logger.error(f"Error sending rotation notification for job {job_id}: {e}")
 
221
  print(f"Unexpected error during token verification: {e}")
222
  return None
223
 
224
+ # === Admin Setup ===
225
  def setup_admins():
226
  ref = db.reference("admins")
227
  for email in ADMIN_EMAILS:
 
230
  setup_admins()
231
 
232
  # === Core Functions ===
 
 
233
  def assign_roster(job_id):
234
  """Assign roster for a job - implements the multi-guard rotation algorithm"""
235
  try:
 
248
  guarding_points = job_data.get("guarding_points", [])
249
  assigned_members = job_data.get("assigned_members", [])
250
 
251
+ if not guarding_points or not assigned_members:
252
+ logger.warning(f"Job {job_id} is missing guarding points or assigned members.")
 
 
 
253
  return
254
 
 
 
 
255
  shift_number = len(job_data.get("assignments", [])) + 1
256
  shift_assignments = []
 
 
 
257
  available_for_shift_members = random.sample(assigned_members, len(assigned_members))
258
 
259
  total_guards_required = sum(point.get('guards', 1) for point in guarding_points)
260
  if len(available_for_shift_members) < total_guards_required:
261
+ logger.error(f"Not enough members ({len(available_for_shift_members)}) to fill all points ({total_guards_required}) for job {job_id}. Aborting.")
 
262
  return
263
 
264
  logger.info(f"Applying standard rotation for job {job_id}, shift {shift_number}")
265
 
 
266
  for point in guarding_points:
267
  guards_for_point = []
268
+ num_guards_required = point.get('guards', 1)
269
 
 
270
  for _ in range(num_guards_required):
271
  if not available_for_shift_members:
272
  logger.warning(f"Ran out of available members while assigning to point {point.get('name')}")
273
  break
 
274
  selected_member = available_for_shift_members.pop(0)
275
  guards_for_point.append(selected_member)
276
 
277
  if guards_for_point:
278
  shift_assignments.append({
279
  "point": point,
280
+ "members": guards_for_point,
281
  "is_special_case": False
282
  })
283
 
 
284
  if shift_assignments:
285
  shift_record = {
286
  "shift_number": shift_number,
287
+ "assigned_at": dt_class.now().isoformat(),
288
  "assignments": shift_assignments,
289
  "shift_id": str(uuid.uuid4())
290
  }
 
294
 
295
  job_ref.update({
296
  "assignments": current_assignments,
297
+ "last_updated": dt_class.now().isoformat()
298
  })
299
 
300
+ logger.info(f"Shift {shift_number} assigned for job {job_id}.")
301
  send_rotation_notification(job_id, shift_record)
302
 
303
  rotation_period = job_data.get("rotation_period", 28800)
304
+ # --- FIX #2 HERE ---
305
+ next_run_time = dt_class.now() + datetime.timedelta(seconds=rotation_period)
306
 
307
  scheduler.add_job(
308
  func=assign_roster,
 
327
  jobs = jobs_ref.get()
328
 
329
  if not jobs:
 
330
  return
331
 
332
  for job_id, job_data in jobs.items():
 
333
  if job_data.get("status") != "active":
 
334
  continue
335
 
336
  assignments = job_data.get("assignments", [])
337
  if not assignments:
 
338
  continue
339
 
340
  last_assignment = assignments[-1]
341
  try:
342
  assigned_at_str = last_assignment.get("assigned_at", "")
343
+ assigned_at = dt_class.fromisoformat(assigned_at_str.replace("Z", "+00:00"))
344
  except (ValueError, TypeError):
345
  logger.warning(f"Could not parse assigned_at time for job {job_id}: {last_assignment.get('assigned_at')}")
346
  continue
347
 
348
+ now = dt_class.now(assigned_at.tzinfo)
349
  rotation_period = job_data.get("rotation_period", 28800)
350
  time_since_last_assignment = (now - assigned_at).total_seconds()
351
 
 
 
352
  if time_since_last_assignment > rotation_period:
353
  logger.info(f"Rotating guards for job {job_id} based on interval check.")
354
  assign_roster(job_id)
 
 
355
 
356
  except Exception as e:
357
  logger.error(f"Error in check_and_rotate_guards: {e}", exc_info=True)
358
 
359
+ # === Routes ===
360
  @app.before_request
361
  def log_request_info():
 
362
  logger.info(f"Incoming request: {request.method} {request.url}")
 
363
 
364
  @app.route("/", methods=["GET"])
365
  def home():
 
366
  return jsonify({"message": "Rairo Guards API is running.", "status": "ok"}), 200
367
 
368
  @app.after_request
369
  def log_response_info(response):
 
370
  logger.info(f"Outgoing response: {response.status} for {request.method} {request.url}")
371
  return response
372
 
373
+ @app.route("/start_job/<job_id>", methods=["POST"])
374
+ def start_job(job_id):
375
+ logger.info(f"Handling /start_job/{job_id} request")
376
  user = verify_token(request)
377
  if not user:
 
378
  return jsonify({"error": "Unauthorized"}), 401
379
 
380
+ job_ref = db.reference(f"jobs/{job_id}")
381
+ if not job_ref.get():
382
+ return jsonify({"error": "Job not found"}), 404
383
+
384
+ try:
385
+ job_ref.update({
386
+ "status": "active",
387
+ "started_at": dt_class.now().isoformat()
388
+ })
389
+ logger.info(f"Job {job_id} status updated to active.")
390
+ except Exception as e:
391
+ logger.error(f"Error updating job {job_id} to active: {e}", exc_info=True)
392
+ return jsonify({"error": "Internal server error"}), 500
393
+
394
+ try:
395
+ scheduler.add_job(
396
+ func=assign_roster,
397
+ trigger="date",
398
+ # --- FIX #1 HERE ---
399
+ run_date=dt_class.now() + datetime.timedelta(seconds=5),
400
+ args=[job_id],
401
+ id=f"start_{job_id}_{uuid.uuid4().hex[:8]}"
402
+ )
403
+ logger.info(f"First assignment for job {job_id} scheduled.")
404
+ return jsonify({"message": f"Job {job_id} started"}), 202
405
+ except Exception as e:
406
+ # This is where the original error was caught
407
+ logger.error(f"Error scheduling first assignment for job {job_id}: {e}", exc_info=True)
408
+ return jsonify({"error": "Failed to start job assignments"}), 500
409
 
410
+ # (Keep all your other routes: /upload_members, /add_member, /create_job, etc. They are unchanged and correct)
411
+ @app.route("/upload_members", methods=["POST"])
412
+ def upload_members():
413
+ user = verify_token(request)
414
+ if not user: return jsonify({"error": "Unauthorized"}), 401
415
+ file = request.files.get("file")
416
+ if not file: return jsonify({"error": "No file uploaded"}), 400
417
  try:
418
  df = pd.read_csv(file) if file.filename.endswith(".csv") else pd.read_excel(file)
419
  members = df.to_dict(orient="records")
 
 
420
  for member in members:
421
+ db.reference(f"members/{str(uuid.uuid4())}").set(member)
 
 
 
422
  return jsonify({"message": "Members uploaded successfully"}), 200
423
  except Exception as e:
424
  logger.error(f"Error processing uploaded members file: {e}", exc_info=True)
 
426
 
427
  @app.route("/add_member", methods=["POST"])
428
  def add_member():
 
429
  user = verify_token(request)
430
+ if not user: return jsonify({"error": "Unauthorized"}), 401
 
 
 
431
  data = request.json
 
432
  member_id = str(uuid.uuid4())
433
  try:
434
  db.reference(f"members/{member_id}").set(data)
 
435
  return jsonify({"message": "Member added", "member_id": member_id}), 200
436
  except Exception as e:
437
+ logger.error(f"Error adding member: {e}", exc_info=True)
438
  return jsonify({"error": "Internal server error"}), 500
439
 
440
  @app.route("/update_member/<member_id>", methods=["POST"])
441
  def update_member(member_id):
 
442
  user = verify_token(request)
443
+ if not user: return jsonify({"error": "Unauthorized"}), 401
 
 
 
444
  data = request.json
445
+ if not data: return jsonify({"error": "Request body cannot be empty"}), 400
 
 
 
 
446
  member_ref = db.reference(f"members/{member_id}")
447
+ if not member_ref.get(): return jsonify({"error": "Member not found"}), 404
 
 
 
 
448
  try:
449
  member_ref.update(data)
450
+ return jsonify({"message": "Member updated", "member": {**data, "id": member_id}}), 200
 
 
451
  except Exception as e:
452
  logger.error(f"Error updating member {member_id}: {e}", exc_info=True)
453
  return jsonify({"error": "Internal server error"}), 500
454
 
455
  @app.route("/delete_member/<member_id>", methods=["DELETE"])
456
  def delete_member(member_id):
 
457
  user = verify_token(request)
458
+ if not user: return jsonify({"error": "Unauthorized"}), 401
 
 
 
459
  member_ref = db.reference(f"members/{member_id}")
460
+ if not member_ref.get(): return jsonify({"error": "Member not found"}), 404
 
 
 
 
461
  try:
462
  member_ref.delete()
 
463
  return jsonify({"message": f"Member {member_id} deleted"}), 200
464
  except Exception as e:
465
  logger.error(f"Error deleting member {member_id}: {e}", exc_info=True)
 
467
 
468
  @app.route("/create_job", methods=["POST"])
469
  def create_job():
 
470
  user = verify_token(request)
471
+ if not user: return jsonify({"error": "Unauthorized"}), 401
 
 
 
472
  data = request.json
 
473
  job_id = str(uuid.uuid4())
 
474
  guarding_points = data.get("guarding_points", [])
475
+ if not 5 <= len(guarding_points) <= 15:
 
476
  return jsonify({"error": "Guarding points must be between 5 and 15"}), 400
477
+ for i, point in enumerate(guarding_points): point.setdefault("id", f"point_{i+1}")
478
+ job_data = {**data, "guarding_points": guarding_points, "created_at": dt_class.now().isoformat(), "status": "created", "assignments": [], "rotation_history": {}, "rotation_period": data.get("rotation_period", 28800)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
479
  try:
480
  db.reference(f"jobs/{job_id}").set(job_data)
 
481
  return jsonify({"message": "Job created", "job_id": job_id}), 200
482
  except Exception as e:
483
+ logger.error(f"Error creating job: {e}", exc_info=True)
484
  return jsonify({"error": "Internal server error"}), 500
485
 
486
  @app.route("/update_job/<job_id>", methods=["POST"])
487
  def update_job(job_id):
 
488
  user = verify_token(request)
489
+ if not user: return jsonify({"error": "Unauthorized"}), 401
 
 
 
490
  data = request.json
491
+ if not data: return jsonify({"error": "Request body cannot be empty"}), 400
 
 
 
 
492
  job_ref = db.reference(f"jobs/{job_id}")
493
+ if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
 
 
 
 
494
  try:
495
+ for key in ['assignments', 'rotation_history', 'status']: data.pop(key, None)
 
 
 
496
  job_ref.update(data)
 
497
  return jsonify({"message": f"Job {job_id} updated"}), 200
498
  except Exception as e:
499
  logger.error(f"Error updating job {job_id}: {e}", exc_info=True)
 
501
 
502
  @app.route("/schedule_job/<job_id>", methods=["POST"])
503
  def schedule_job(job_id):
 
504
  user = verify_token(request)
505
+ if not user: return jsonify({"error": "Unauthorized"}), 401
 
 
 
506
  data = request.json
507
  start_time_iso = data.get("start_time")
508
+ if not start_time_iso: return jsonify({"error": "start_time is required"}), 400
 
 
 
 
 
509
  job_ref = db.reference(f"jobs/{job_id}")
510
+ if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
 
 
 
511
  try:
512
+ start_time = dt_class.fromisoformat(start_time_iso.replace("Z", "+00:00"))
 
513
  except ValueError:
 
514
  return jsonify({"error": "Invalid start_time format."}), 400
 
515
  try:
516
+ job_ref.update({"scheduled_start_time": start_time_iso, "status": "scheduled"})
517
+ 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]}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
518
  return jsonify({"message": f"Job {job_id} scheduled to start at {start_time_iso}"}), 200
519
  except Exception as e:
520
+ logger.error(f"Error scheduling job {job_id}: {e}", exc_info=True)
521
+ return jsonify({"error": "Failed to schedule job"}), 500
522
 
523
  def start_scheduled_job(job_id):
 
524
  try:
525
  logger.info(f"Executing scheduled start for job {job_id}")
526
  job_ref = db.reference(f"jobs/{job_id}")
527
+ if not job_ref.get(): return
528
+ job_ref.update({"status": "active", "started_at": dt_class.now().isoformat()})
 
 
 
 
 
 
 
 
529
  assign_roster(job_id)
 
 
530
  except Exception as e:
531
+ logger.error(f"Error in start_scheduled_job for {job_id}: {e}", exc_info=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
 
533
  @app.route("/pause_job/<job_id>", methods=["POST"])
534
  def pause_job(job_id):
 
535
  user = verify_token(request)
536
+ if not user: return jsonify({"error": "Unauthorized"}), 401
 
 
 
537
  job_ref = db.reference(f"jobs/{job_id}")
538
+ if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
 
 
 
539
  try:
540
  job_ref.update({"status": "paused"})
 
541
  return jsonify({"message": f"Job {job_id} paused"}), 200
542
  except Exception as e:
543
  logger.error(f"Error pausing job {job_id}: {e}", exc_info=True)
 
545
 
546
  @app.route("/delete_job/<job_id>", methods=["DELETE"])
547
  def delete_job(job_id):
 
548
  user = verify_token(request)
549
+ if not user: return jsonify({"error": "Unauthorized"}), 401
 
 
 
550
  job_ref = db.reference(f"jobs/{job_id}")
551
+ if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
 
 
 
552
  try:
553
  job_ref.delete()
 
554
  return jsonify({"message": f"Job {job_id} deleted"}), 200
555
  except Exception as e:
556
  logger.error(f"Error deleting job {job_id}: {e}", exc_info=True)
 
558
 
559
  @app.route("/jobs", methods=["GET"])
560
  def get_jobs():
 
561
  user = verify_token(request)
562
+ if not user: return jsonify({"error": "Unauthorized"}), 401
 
 
 
563
  try:
564
+ jobs = db.reference("jobs").get()
565
+ if not jobs: return jsonify({"jobs": []}), 200
566
+ jobs_list = [{"id": j_id, **j_data} for j_id, j_data in jobs.items()]
 
 
 
 
 
 
 
 
 
 
567
  return jsonify({"jobs": jobs_list}), 200
568
  except Exception as e:
569
+ logger.error(f"Error getting jobs: {e}", exc_info=True)
570
  return jsonify({"error": "Internal server error"}), 500
571
 
572
  @app.route("/job/<job_id>", methods=["GET"])
573
  def get_job(job_id):
 
574
  user = verify_token(request)
575
+ if not user: return jsonify({"error": "Unauthorized"}), 401
 
 
 
576
  try:
577
+ job_data = db.reference(f"jobs/{job_id}").get()
578
+ if not job_data: return jsonify({"error": "Job not found"}), 404
579
+ return jsonify({"job": {"id": job_id, **job_data}}), 200
 
 
 
 
 
 
 
580
  except Exception as e:
581
+ logger.error(f"Error getting job {job_id}: {e}", exc_info=True)
582
  return jsonify({"error": "Internal server error"}), 500
583
 
584
  @app.route("/members", methods=["GET"])
585
  def get_members():
 
586
  user = verify_token(request)
587
+ if not user: return jsonify({"error": "Unauthorized"}), 401
 
 
 
588
  try:
589
+ members = db.reference("members").get()
590
+ if not members: return jsonify({"members": []}), 200
591
+ members_list = [{"id": m_id, **m_data} for m_id, m_data in members.items()]
 
 
 
 
 
 
 
 
 
 
592
  return jsonify({"members": members_list}), 200
593
  except Exception as e:
594
+ logger.error(f"Error getting members: {e}", exc_info=True)
595
  return jsonify({"error": "Internal server error"}), 500
596
 
597
  @app.route("/assign_members_to_job", methods=["POST"])
598
  def assign_members_to_job():
 
599
  user = verify_token(request)
600
+ if not user: return jsonify({"error": "Unauthorized"}), 401
 
 
 
601
  data = request.json
602
  job_id = data.get("job_id")
603
  member_ids = data.get("member_ids", [])
604
+ if not job_id or not member_ids: return jsonify({"error": "job_id and member_ids are required"}), 400
 
 
 
 
 
605
  job_ref = db.reference(f"jobs/{job_id}")
606
+ if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
607
  try:
608
+ all_members = db.reference("members").get()
609
+ if not all_members: return jsonify({"error": "No members found"}), 404
610
+ selected_members = [{"id": m_id, **m_data} for m_id, m_data in all_members.items() if m_id in member_ids]
611
+ if len(selected_members) < 25: return jsonify({"error": "At least 25 members must be assigned"}), 400
612
  job_ref.update({"assigned_members": selected_members})
613
+ return jsonify({"message": f"{len(selected_members)} members assigned"}), 200
 
 
 
 
614
  except Exception as e:
615
+ logger.error(f"Error assigning members to job {job_id}: {e}", exc_info=True)
616
  return jsonify({"error": "Internal server error"}), 500
617
 
618
  @app.route("/update_job_settings/<job_id>", methods=["POST"])
619
  def update_job_settings(job_id):
 
620
  user = verify_token(request)
621
+ if not user: return jsonify({"error": "Unauthorized"}), 401
 
 
 
622
  data = request.json
623
  rotation_period = data.get("rotation_period")
624
+ if rotation_period is None: return jsonify({"error": "rotation_period is required"}), 400
 
 
 
 
 
625
  job_ref = db.reference(f"jobs/{job_id}")
626
+ if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
 
 
 
627
  try:
628
  job_ref.update({"rotation_period": rotation_period})
629
+ return jsonify({"message": f"Rotation period updated to {rotation_period}s"}), 200
 
 
 
 
630
  except Exception as e:
631
+ logger.error(f"Error updating job settings for {job_id}: {e}", exc_info=True)
632
  return jsonify({"error": "Internal server error"}), 500
633
 
634
  @app.route("/add_special_case/<job_id>", methods=["POST"])
635
  def add_special_case(job_id):
 
636
  user = verify_token(request)
637
+ if not user: return jsonify({"error": "Unauthorized"}), 401
 
 
 
638
  data = request.json
639
+ point_id, member_id = data.get("point_id"), data.get("member_id")
640
+ if not point_id or not member_id: return jsonify({"error": "point_id and member_id are required"}), 400
 
 
 
 
 
 
 
 
 
641
  job_ref = db.reference(f"jobs/{job_id}")
642
  job_data = job_ref.get()
643
+ if not job_data: return jsonify({"error": "Job not found"}), 404
644
+ if not any(p["id"] == point_id for p in job_data.get("guarding_points", [])):
 
 
 
 
 
 
 
 
 
 
 
645
  return jsonify({"error": "Guarding point not found in job"}), 404
646
+ if not any(m["id"] == member_id for m in job_data.get("assigned_members", [])):
 
647
  return jsonify({"error": "Member not assigned to job"}), 404
648
+ special_case = {"id": str(uuid.uuid4()), **data, "created_at": dt_class.now().isoformat()}
 
 
 
 
 
 
 
 
 
 
649
  special_cases = job_data.get("special_cases", [])
650
  special_cases.append(special_case)
 
651
  try:
652
  job_ref.update({"special_cases": special_cases})
653
+ return jsonify({"message": "Special case added", "special_case": special_case}), 200
 
 
 
 
654
  except Exception as e:
655
  logger.error(f"Error adding special case to job {job_id}: {e}", exc_info=True)
656
  return jsonify({"error": "Internal server error"}), 500
657
 
658
  @app.route("/get_roster/<job_id>", methods=["GET"])
659
  def get_roster(job_id):
 
660
  user = verify_token(request)
661
+ if not user: return jsonify({"error": "Unauthorized"}), 401
 
 
 
662
  try:
663
+ job_data = db.reference(f"jobs/{job_id}").get()
664
+ if not job_data: return jsonify({"error": "Job not found"}), 404
 
 
 
 
 
665
  assignments = job_data.get("assignments", [])
666
  roster_data = []
667
  for shift in assignments:
 
 
 
668
  for assignment in shift.get("assignments", []):
669
  member_names = [m.get("name", "Unknown") for m in assignment.get("members", [])]
670
  roster_data.append({
671
+ "shift_number": shift["shift_number"],
672
+ "assigned_at": shift["assigned_at"],
673
  "point_name": assignment.get("point", {}).get("name", "Unknown"),
674
+ "member_names": ", ".join(member_names),
675
  "is_special_case": assignment.get("is_special_case", False),
676
  })
677
+ return jsonify({"roster": roster_data, "job_name": job_data.get("name", "Unknown Job"), "total_shifts": len(assignments)}), 200
 
 
 
 
 
 
678
  except Exception as e:
679
+ logger.error(f"Error getting roster for {job_id}: {e}", exc_info=True)
680
  return jsonify({"error": "Internal server error"}), 500
681
 
682
  # === Schedule periodic guard rotation ===
683
  try:
 
684
  scheduler.add_job(
685
  func=check_and_rotate_guards,
686
  trigger="interval",
 
695
  # === Run Server ===
696
  if __name__ == "__main__":
697
  logger.info("Starting Flask application...")
698
+ app.run(debug=True, host="0.0.0.0", port=int(os.getenv("PORT", 7860)))```