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

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +135 -299
main.py CHANGED
@@ -12,6 +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
 
16
  # === Logging Configuration ===
17
  # Configure logging
@@ -38,7 +41,6 @@ logger.addHandler(file_handler)
38
  logger.addHandler(console_handler)
39
 
40
  # Also configure the root logger for Flask's default messages
41
- # (This might duplicate some logs, but ensures Flask's internal messages are captured)
42
  root_logger = logging.getLogger()
43
  root_logger.setLevel(logging.INFO)
44
  root_logger.addHandler(file_handler)
@@ -51,7 +53,7 @@ FIREBASE_DB_URL = os.getenv("Firebase_DB")
51
  ADMIN_EMAILS = ["rairorr@gmail.com", "nharingosheperd@gmail.com"]
52
 
53
  logger.info("Environment variables loaded.")
54
- logger.debug(f"FIREBASE_DB_URL: {FIREBASE_DB_URL}") # Be careful logging sensitive data
55
 
56
  # === Firebase Init ===
57
  try:
@@ -108,14 +110,19 @@ def send_email(to, subject, html):
108
  "subject": subject,
109
  "html": html
110
  })
111
- logger.info(f"Email sent successfully to {to}")
 
 
 
 
112
  return response
113
  except Exception as e:
114
  logger.error(f"Email error to {to}: {e}")
115
  return None
116
 
 
117
  def send_rotation_notification(job_id, shift_record):
118
- """Send email notification for rotation to all admins"""
119
  try:
120
  job_ref = db.reference(f"jobs/{job_id}")
121
  job_data = job_ref.get()
@@ -127,20 +134,29 @@ def send_rotation_notification(job_id, shift_record):
127
  job_name = job_data.get("name", "Unknown Job")
128
  shift_number = shift_record.get("shift_number", "Unknown")
129
 
130
- # Create HTML email content
 
 
 
 
 
 
 
 
 
131
  html_content = f"""
132
  <h2>Guard Rotation Notification</h2>
133
  <p><strong>Job:</strong> {job_name}</p>
134
  <p><strong>Job ID:</strong> {job_id}</p>
135
  <p><strong>Shift Number:</strong> {shift_number}</p>
136
- <p><strong>Rotation Time:</strong> {shift_record.get('assigned_at', 'Unknown')}</p>
137
 
138
  <h3>Assignments:</h3>
139
  <table border="1" style="border-collapse: collapse; width: 100%;">
140
  <thead>
141
  <tr>
142
  <th>Guarding Point</th>
143
- <th>Assigned Member</th>
144
  <th>Special Case</th>
145
  </tr>
146
  </thead>
@@ -149,19 +165,18 @@ def send_rotation_notification(job_id, shift_record):
149
 
150
  for assignment in shift_record.get("assignments", []):
151
  point_name = assignment.get("point", {}).get("name", "Unknown Point")
152
- member_name = assignment.get("member", {}).get("name", "Unknown Member")
153
- is_special = "Yes" if assignment.get("is_special_case", False) else "No"
154
- special_reason = assignment.get("special_case_reason", "") if assignment.get("is_special_case", False) else ""
155
-
156
- if special_reason:
157
- member_info = f"{member_name} ({special_reason})"
158
- else:
159
- member_info = member_name
160
 
 
 
161
  html_content += f"""
162
  <tr>
163
  <td>{point_name}</td>
164
- <td>{member_info}</td>
165
  <td>{is_special}</td>
166
  </tr>
167
  """
@@ -176,27 +191,24 @@ def send_rotation_notification(job_id, shift_record):
176
  subject = f"Guard Rotation - {job_name} (Shift {shift_number})"
177
 
178
  for admin_email in ADMIN_EMAILS:
 
179
  send_email(admin_email, subject, html_content)
 
 
180
 
181
  except Exception as e:
182
  logger.error(f"Error sending rotation notification for job {job_id}: {e}")
183
 
 
184
  # === Auth Middleware ===
185
  def verify_token(req):
186
- """
187
- Verify Firebase ID token and check if user's email is in ADMIN_EMAILS.
188
- Email is expected in the 'X-User-Email' header, sent by the client.
189
- Returns decoded token dict if valid admin, None otherwise.
190
- """
191
  auth_header = req.headers.get("Authorization")
192
- # NEW: Get email from custom header sent by client
193
  user_email = req.headers.get("X-User-Email")
194
 
195
  if not auth_header:
196
  print("Authorization header missing.")
197
  return None
198
 
199
- # NEW: Check if email header is present and valid
200
  if not user_email:
201
  logging.info("X-User-Email header missing from request.")
202
  return None
@@ -206,35 +218,17 @@ def verify_token(req):
206
  return None
207
 
208
  try:
209
- # Extract token
210
  if auth_header.startswith("Bearer "):
211
  token = auth_header[7:].strip()
212
  else:
213
  token = auth_header.strip()
214
 
215
- # Verify the token (ensures token is genuine and user is authenticated)
216
  decoded = firebase_auth.verify_id_token(token)
217
- # Note: We are not checking the UID against the database anymore.
218
- # We are trusting the email provided by the client, verified against ADMIN_EMAILS.
219
- # The token verification ensures the request is from a logged-in user.
220
-
221
  logging.info(f"User with email {user_email} (UID: {decoded.get('uid')}) is authorized as admin (email in ADMIN_EMAILS).")
222
- return decoded # Return decoded token if needed by other parts of the app
223
 
224
- except firebase_auth.InvalidIdTokenError as e:
225
- print(f"Invalid Firebase ID token provided: {e}")
226
- return None
227
- except firebase_auth.ExpiredIdTokenError as e:
228
- print(f"Firebase ID token has expired: {e}")
229
- return None
230
- except firebase_auth.RevokedIdTokenError as e:
231
- print(f"Firebase ID token has been revoked: {e}")
232
- return None
233
  except Exception as e:
234
  print(f"Unexpected error during token verification: {e}")
235
- # Consider logging the full traceback in production
236
- # import traceback
237
- # print(traceback.format_exc())
238
  return None
239
 
240
  # === Admin Setup (Legacy - kept for compatibility) ===
@@ -246,11 +240,12 @@ def setup_admins():
246
  setup_admins()
247
 
248
  # === Core Functions ===
 
 
249
  def assign_roster(job_id):
250
- """Assign roster for a job - implements the rotation algorithm"""
251
  try:
252
  logger.info(f"Starting roster assignment for job {job_id}")
253
- # Get job details
254
  job_ref = db.reference(f"jobs/{job_id}")
255
  job_data = job_ref.get()
256
 
@@ -258,181 +253,82 @@ def assign_roster(job_id):
258
  logger.error(f"Job {job_id} not found")
259
  return
260
 
261
- # Check if job is paused
262
- if job_data.get("status") == "paused":
263
- logger.info(f"Job {job_id} is paused, skipping assignment")
264
  return
265
 
266
- # Get guarding points and assigned members
267
  guarding_points = job_data.get("guarding_points", [])
268
  assigned_members = job_data.get("assigned_members", [])
269
- special_cases = job_data.get("special_cases", [])
270
-
271
  if not guarding_points:
272
  logger.warning(f"No guarding points defined for job {job_id}")
273
  return
274
-
275
  if not assigned_members:
276
  logger.warning(f"No members assigned to job {job_id}")
277
  return
278
 
279
- # CRITICAL FIX: Ensure all guarding points have IDs
280
- points_updated = False
281
- for i, point in enumerate(guarding_points):
282
- if "id" not in point or not point["id"]:
283
- point["id"] = f"point_{i+1}"
284
- points_updated = True
285
- logger.warning(f"Point missing ID in job {job_id}, assigned: point_{i+1}")
286
-
287
- # Update database if points were missing IDs
288
- if points_updated:
289
- job_ref.update({"guarding_points": guarding_points})
290
- logger.info(f"Updated guarding points with missing IDs for job {job_id}")
291
-
292
- # CRITICAL FIX: Ensure all assigned members have IDs
293
- members_updated = False
294
- for i, member in enumerate(assigned_members):
295
- if "id" not in member or not member["id"]:
296
- member["id"] = f"member_{i+1}_{uuid.uuid4().hex[:6]}"
297
- members_updated = True
298
- logger.warning(f"Member missing ID in job {job_id}, assigned: {member['id']}")
299
-
300
- # Update database if members were missing IDs
301
- if members_updated:
302
- job_ref.update({"assigned_members": assigned_members})
303
- logger.info(f"Updated assigned members with missing IDs for job {job_id}")
304
-
305
- # Get current assignments and rotation history
306
- current_assignments = job_data.get("assignments", [])
307
- rotation_history = job_data.get("rotation_history", {})
308
-
309
- # Determine next shift
310
- shift_number = len(current_assignments) + 1
311
- logger.debug(f"Determined shift number: {shift_number} for job {job_id}")
312
-
313
- # Apply special cases if any match current shift
314
- special_assignment = None
315
- for case in special_cases:
316
- if case.get("shift_number") == shift_number or case.get("active", False):
317
- special_assignment = case
318
- logger.debug(f"Found matching special case for shift {shift_number}: {case}")
319
- break
320
-
321
- # Generate assignments for this shift
322
  shift_assignments = []
 
 
 
 
 
 
 
 
 
 
 
 
323
 
324
- if special_assignment:
325
- logger.info(f"Applying special case assignment for job {job_id}, shift {shift_number}")
326
- # Apply special case assignment
327
- assigned_point = next((p for p in guarding_points if p.get("id") == special_assignment.get("point_id")), None)
328
- assigned_member = next((m for m in assigned_members if m.get("id") == special_assignment.get("member_id")), None)
329
 
330
- if assigned_point and assigned_member:
 
 
 
 
 
 
 
 
 
331
  shift_assignments.append({
332
- "point": assigned_point,
333
- "member": assigned_member,
334
- "is_special_case": True,
335
- "special_case_reason": special_assignment.get("reason", "Special Assignment")
336
  })
337
- else:
338
- logger.warning(f"Special case assignment failed: Point or Member not found for job {job_id}")
339
- logger.debug(f"Looking for point_id: {special_assignment.get('point_id')}, member_id: {special_assignment.get('member_id')}")
340
- else:
341
- logger.info(f"Applying standard rotation algorithm for job {job_id}, shift {shift_number}")
342
- # Standard rotation algorithm
343
- for point in guarding_points:
344
- point_id = point.get("id")
345
- if not point_id:
346
- logger.error(f"Point still missing ID after fix attempt: {point}")
347
- continue
348
-
349
- point_history = rotation_history.get(point_id, [])
350
-
351
- # Find member who hasn't been assigned to this point recently
352
- available_members = assigned_members.copy()
353
-
354
- # Remove recently assigned members (no immediate repetition)
355
- recently_assigned_member_ids = set()
356
- lookback_window = min(len(guarding_points), len(assigned_members))
357
- for recent_assignment in point_history[-lookback_window:]:
358
- recently_assigned_member_ids.add(recent_assignment.get("member_id"))
359
-
360
- # Filter available members
361
- available_members = [m for m in available_members if m.get("id") not in recently_assigned_member_ids]
362
-
363
- # If all members have been recently assigned, use all members
364
- if not available_members:
365
- logger.debug(f"All members recently assigned to point {point_id}. Using full member list.")
366
- available_members = assigned_members
367
-
368
- # Select next member (simple round-robin from available)
369
- selected_member = None
370
- if point_history and available_members:
371
- last_assigned_member_id = point_history[-1].get("member_id")
372
- last_member_index = next((i for i, m in enumerate(available_members) if m.get("id") == last_assigned_member_id), -1)
373
- if last_member_index != -1:
374
- next_member_index = (last_member_index + 1) % len(available_members)
375
- else:
376
- # Last assigned member is not in available list, pick first available
377
- next_member_index = 0
378
- selected_member = available_members[next_member_index]
379
- elif available_members:
380
- # No history or last member not in available list, pick first
381
- selected_member = available_members[0]
382
- else:
383
- # This shouldn't happen due to the fallback, but log if it does
384
- logger.error(f"No available members for point {point_id} in job {job_id}")
385
- continue
386
-
387
- if selected_member:
388
- # Record assignment
389
- shift_assignments.append({
390
- "point": point,
391
- "member": selected_member,
392
- "is_special_case": False
393
- })
394
-
395
- # Update rotation history
396
- point_history.append({
397
- "member_id": selected_member.get("id"),
398
- "member_name": selected_member.get("name", "Unknown"),
399
- "assigned_at": datetime.datetime.now().isoformat(),
400
- "shift_number": shift_number
401
- })
402
- rotation_history[point_id] = point_history
403
- else:
404
- logger.warning(f"Could not select a member for point {point_id} in job {job_id}")
405
-
406
- # Update rotation history in database only if we have assignments
407
  if shift_assignments:
408
- job_ref.update({"rotation_history": rotation_history})
409
-
410
- # Create shift record
411
  shift_record = {
412
  "shift_number": shift_number,
413
- "assigned_at": datetime.datetime.now().isoformat(),
414
  "assignments": shift_assignments,
415
  "shift_id": str(uuid.uuid4())
416
  }
417
 
418
- # Add to current assignments
419
  current_assignments.append(shift_record)
420
 
421
- # Update job with new assignment
422
  job_ref.update({
423
  "assignments": current_assignments,
424
- "last_updated": datetime.datetime.now().isoformat()
425
  })
426
 
427
- logger.info(f"Shift {shift_number} assigned for job {job_id} with {len(shift_assignments)} assignments")
428
-
429
- # Send email notification to admins
430
  send_rotation_notification(job_id, shift_record)
431
 
432
- # Schedule next rotation based on job's rotation period
433
- rotation_period = job_data.get("rotation_period", 28800) # Default 8 hours
434
- next_run_time = datetime.datetime.now() + datetime.timedelta(seconds=rotation_period)
435
-
436
  scheduler.add_job(
437
  func=assign_roster,
438
  trigger="date",
@@ -440,19 +336,18 @@ def assign_roster(job_id):
440
  args=[job_id],
441
  id=f"rotate_{job_id}_{uuid.uuid4().hex[:8]}"
442
  )
443
-
444
- logger.info(f"Scheduled next rotation for job {job_id} in {rotation_period} seconds")
445
  else:
446
- logger.error(f"No assignments created for job {job_id}, shift {shift_number}")
447
 
448
  except Exception as e:
449
  logger.error(f"Error in assign_roster for job {job_id}: {e}", exc_info=True)
450
 
 
451
  def check_and_rotate_guards():
452
  """Periodic function to check jobs and rotate guards"""
453
  try:
454
  logger.debug("🔍 Checking for guard rotations...")
455
- # Get all jobs
456
  jobs_ref = db.reference("jobs")
457
  jobs = jobs_ref.get()
458
 
@@ -460,49 +355,36 @@ def check_and_rotate_guards():
460
  logger.debug("No jobs found")
461
  return
462
 
463
- # Check each job for rotation needs (for jobs that don't have scheduled rotations)
464
  for job_id, job_data in jobs.items():
465
  logger.debug(f"Evaluating job {job_id} for rotation...")
466
- # Skip paused jobs
467
- if job_data.get("status") == "paused":
468
- logger.debug(f"Job {job_id} is paused, skipping.")
469
  continue
470
 
471
- # Skip jobs that have scheduled rotations handled by APScheduler
472
- # Note: This logic might need refinement based on how you track scheduled jobs.
473
- # if job_data.get("has_scheduled_rotation", False):
474
- # logger.debug(f"Job {job_id} has scheduled rotation, skipping manual check.")
475
- # continue
476
-
477
- # For jobs without scheduled rotations, check manually
478
  assignments = job_data.get("assignments", [])
479
-
480
  if not assignments:
481
- # If no assignments yet, this job hasn't started manually
482
  logger.debug(f"Job {job_id} has no assignments yet, not started.")
483
  continue
484
- else:
485
- # Check if current assignment is expired
486
- last_assignment = assignments[-1]
487
- try:
488
- assigned_at = datetime.datetime.fromisoformat(last_assignment["assigned_at"].replace("Z", "+00:00"))
489
- except ValueError:
490
- logger.warning(f"Could not parse assigned_at time for job {job_id}: {last_assignment['assigned_at']}")
491
- continue # Skip if time format is unexpected
492
 
493
- now = datetime.datetime.now(assigned_at.tzinfo)
494
-
495
- # Get rotation period from job or default to 8 hours
496
- rotation_period = job_data.get("rotation_period", 28800)
 
 
 
497
 
498
- time_since_last_assignment = (now - assigned_at).total_seconds()
499
- logger.debug(f"Job {job_id}: Last assignment was {time_since_last_assignment}s ago. Period is {rotation_period}s.")
 
 
 
500
 
501
- if time_since_last_assignment > rotation_period:
502
- logger.info(f"Rotating guard for job {job_id}")
503
- assign_roster(job_id)
504
- else:
505
- logger.debug(f"Job {job_id} not due for rotation yet.")
506
 
507
  except Exception as e:
508
  logger.error(f"Error in check_and_rotate_guards: {e}", exc_info=True)
@@ -513,8 +395,6 @@ def log_request_info():
513
  """Log incoming request details."""
514
  logger.info(f"Incoming request: {request.method} {request.url}")
515
  logger.debug(f"Headers: {dict(request.headers)}")
516
- # Be very careful logging request.data or request.json as they might contain sensitive info
517
- # logger.debug(f"Body: {request.get_data()}") # Uncomment only for debugging, then remove!
518
 
519
  @app.route("/", methods=["GET"])
520
  def home():
@@ -590,7 +470,6 @@ def update_member(member_id):
590
  logger.debug(f"Updating member {member_id} with data: {data}")
591
  member_ref = db.reference(f"members/{member_id}")
592
 
593
- # Check if member exists before updating
594
  if not member_ref.get():
595
  logger.warning(f"Attempted to update non-existent member {member_id}")
596
  return jsonify({"error": "Member not found"}), 404
@@ -614,7 +493,6 @@ def delete_member(member_id):
614
 
615
  member_ref = db.reference(f"members/{member_id}")
616
 
617
- # Check if member exists before deleting
618
  if not member_ref.get():
619
  logger.warning(f"Attempted to delete non-existent member {member_id}")
620
  return jsonify({"error": "Member not found"}), 404
@@ -639,27 +517,24 @@ def create_job():
639
  logger.debug(f"Creating job with data: {data}")
640
  job_id = str(uuid.uuid4())
641
 
642
- # Validate guarding points (5-15 points)
643
  guarding_points = data.get("guarding_points", [])
644
  if len(guarding_points) < 5 or len(guarding_points) > 15:
645
  logger.warning(f"Invalid number of guarding points ({len(guarding_points)}) for job creation.")
646
  return jsonify({"error": "Guarding points must be between 5 and 15"}), 400
647
 
648
- # Add IDs to guarding points if not present
649
  for i, point in enumerate(guarding_points):
650
  if "id" not in point:
651
  point["id"] = f"point_{i+1}"
652
 
653
- # Add default fields
654
  job_data = {
655
  **data,
656
  "guarding_points": guarding_points,
657
- "created_at": datetime.datetime.now().isoformat(),
658
- "status": "created", # New status: created, active, paused
659
  "assignments": [],
660
  "rotation_history": {},
661
- "rotation_period": data.get("rotation_period", 28800), # Default 8 hours (28800 seconds)
662
- "scheduled_start_time": data.get("scheduled_start_time"), # ISO format datetime string
663
  "has_scheduled_rotation": False,
664
  "special_cases": data.get("special_cases", [])
665
  }
@@ -687,13 +562,11 @@ def update_job(job_id):
687
  logger.debug(f"Updating job {job_id} with data: {data}")
688
  job_ref = db.reference(f"jobs/{job_id}")
689
 
690
- # Check if job exists
691
  if not job_ref.get():
692
  logger.warning(f"Attempted to update non-existent job {job_id}")
693
  return jsonify({"error": "Job not found"}), 404
694
 
695
  try:
696
- # Prevent overwriting critical, managed fields from a general update endpoint
697
  data.pop('assignments', None)
698
  data.pop('rotation_history', None)
699
  data.pop('status', None)
@@ -707,7 +580,6 @@ def update_job(job_id):
707
 
708
  @app.route("/schedule_job/<job_id>", methods=["POST"])
709
  def schedule_job(job_id):
710
- """Schedule a job to start at a specific time"""
711
  logger.info(f"Handling /schedule_job/{job_id} request")
712
  user = verify_token(request)
713
  if not user:
@@ -715,7 +587,7 @@ def schedule_job(job_id):
715
  return jsonify({"error": "Unauthorized"}), 401
716
 
717
  data = request.json
718
- start_time_iso = data.get("start_time") # ISO format datetime string
719
  logger.debug(f"Scheduling job {job_id} with data: {data}")
720
 
721
  if not start_time_iso:
@@ -723,21 +595,17 @@ def schedule_job(job_id):
723
  return jsonify({"error": "start_time is required (ISO format)"}), 400
724
 
725
  job_ref = db.reference(f"jobs/{job_id}")
726
- job_data = job_ref.get()
727
-
728
- if not job_data: # Fixed condition
729
  logger.warning(f"Job {job_id} not found for scheduling.")
730
  return jsonify({"error": "Job not found"}), 404
731
 
732
- # Parse the start time
733
  try:
734
- start_time = datetime.datetime.fromisoformat(start_time_iso.replace("Z", "+00:00"))
735
  logger.debug(f"Parsed start time: {start_time}")
736
  except ValueError:
737
  logger.error(f"Invalid start_time format provided: {start_time_iso}")
738
- return jsonify({"error": "Invalid start_time format. Use ISO format (e.g., 2023-12-01T10:00:00)"}), 400
739
 
740
- # Update job with scheduled start time
741
  try:
742
  job_ref.update({
743
  "scheduled_start_time": start_time_iso,
@@ -748,7 +616,6 @@ def schedule_job(job_id):
748
  logger.error(f"Error updating job {job_id} for scheduling: {e}", exc_info=True)
749
  return jsonify({"error": "Internal server error"}), 500
750
 
751
- # Schedule the job to start at the specified time
752
  try:
753
  scheduler.add_job(
754
  func=start_scheduled_job,
@@ -761,7 +628,6 @@ def schedule_job(job_id):
761
  return jsonify({"message": f"Job {job_id} scheduled to start at {start_time_iso}"}), 200
762
  except Exception as e:
763
  logger.error(f"Error scheduling start job for {job_id}: {e}", exc_info=True)
764
- # Consider rolling back the status update if scheduling fails
765
  return jsonify({"error": "Failed to schedule job start"}), 500
766
 
767
  def start_scheduled_job(job_id):
@@ -769,20 +635,16 @@ def start_scheduled_job(job_id):
769
  try:
770
  logger.info(f"Executing scheduled start for job {job_id}")
771
  job_ref = db.reference(f"jobs/{job_id}")
772
- job_data = job_ref.get()
773
-
774
- if not job_data: # Fixed condition
775
  logger.error(f"Job {job_id} not found for scheduled start")
776
  return
777
 
778
- # Update job status to active
779
  job_ref.update({
780
  "status": "active",
781
- "started_at": datetime.datetime.now().isoformat()
782
  })
783
  logger.info(f"Job {job_id} status updated to active.")
784
 
785
- # Start first assignment
786
  assign_roster(job_id)
787
  logger.info(f"Job {job_id} started via schedule")
788
 
@@ -798,29 +660,25 @@ def start_job(job_id):
798
  return jsonify({"error": "Unauthorized"}), 401
799
 
800
  job_ref = db.reference(f"jobs/{job_id}")
801
- job_data = job_ref.get()
802
-
803
- if not job_data: # Fixed condition
804
  logger.warning(f"Job {job_id} not found for starting.")
805
  return jsonify({"error": "Job not found"}), 404
806
 
807
- # Update job status to active
808
  try:
809
  job_ref.update({
810
  "status": "active",
811
- "started_at": datetime.datetime.now().isoformat()
812
  })
813
  logger.info(f"Job {job_id} status updated to active.")
814
  except Exception as e:
815
  logger.error(f"Error updating job {job_id} to active: {e}", exc_info=True)
816
  return jsonify({"error": "Internal server error"}), 500
817
 
818
- # Schedule the first assignment immediately
819
  try:
820
  scheduler.add_job(
821
  func=assign_roster,
822
  trigger="date",
823
- run_date=datetime.datetime.now() + datetime.timedelta(seconds=5),
824
  args=[job_id],
825
  id=f"start_{job_id}_{uuid.uuid4().hex[:8]}"
826
  )
@@ -828,7 +686,6 @@ def start_job(job_id):
828
  return jsonify({"message": f"Job {job_id} started"}), 202
829
  except Exception as e:
830
  logger.error(f"Error scheduling first assignment for job {job_id}: {e}", exc_info=True)
831
- # Consider rolling back the status update if scheduling fails
832
  return jsonify({"error": "Failed to start job assignments"}), 500
833
 
834
  @app.route("/pause_job/<job_id>", methods=["POST"])
@@ -840,9 +697,7 @@ def pause_job(job_id):
840
  return jsonify({"error": "Unauthorized"}), 401
841
 
842
  job_ref = db.reference(f"jobs/{job_id}")
843
- job_data = job_ref.get()
844
-
845
- if not job_data: # Fixed condition
846
  logger.warning(f"Job {job_id} not found for pausing.")
847
  return jsonify({"error": "Job not found"}), 404
848
 
@@ -863,9 +718,7 @@ def delete_job(job_id):
863
  return jsonify({"error": "Unauthorized"}), 401
864
 
865
  job_ref = db.reference(f"jobs/{job_id}")
866
- job_data = job_ref.get()
867
-
868
- if not job_data: # Fixed condition
869
  logger.warning(f"Job {job_id} not found for deletion.")
870
  return jsonify({"error": "Job not found"}), 404
871
 
@@ -893,7 +746,6 @@ def get_jobs():
893
  logger.debug("No jobs found in database.")
894
  return jsonify({"jobs": []}), 200
895
 
896
- # Convert to list format
897
  jobs_list = []
898
  for job_id, job_data in jobs.items():
899
  job_data["id"] = job_id
@@ -917,7 +769,7 @@ def get_job(job_id):
917
  job_ref = db.reference(f"jobs/{job_id}")
918
  job_data = job_ref.get()
919
 
920
- if not job_data: # Fixed condition
921
  logger.warning(f"Job {job_id} not found.")
922
  return jsonify({"error": "Job not found"}), 404
923
 
@@ -944,7 +796,6 @@ def get_members():
944
  logger.debug("No members found in database.")
945
  return jsonify({"members": []}), 200
946
 
947
- # Convert to list format
948
  members_list = []
949
  for member_id, member_data in members.items():
950
  member_data["id"] = member_id
@@ -958,7 +809,6 @@ def get_members():
958
 
959
  @app.route("/assign_members_to_job", methods=["POST"])
960
  def assign_members_to_job():
961
- """Bulk assign members to a job"""
962
  logger.info("Handling /assign_members_to_job request")
963
  user = verify_token(request)
964
  if not user:
@@ -975,13 +825,10 @@ def assign_members_to_job():
975
  return jsonify({"error": "job_id and member_ids are required"}), 400
976
 
977
  job_ref = db.reference(f"jobs/{job_id}")
978
- job_data = job_ref.get()
979
-
980
- if not job_data: # Fixed condition
981
  logger.warning(f"Job {job_id} not found for member assignment.")
982
  return jsonify({"error": "Job not found"}), 404
983
 
984
- # Get members
985
  try:
986
  members_ref = db.reference("members")
987
  all_members = members_ref.get()
@@ -993,7 +840,6 @@ def assign_members_to_job():
993
  logger.warning("No members found in database for assignment.")
994
  return jsonify({"error": "No members found"}), 404
995
 
996
- # Filter requested members (ensure at least 25)
997
  selected_members = []
998
  for member_id in member_ids:
999
  if member_id in all_members:
@@ -1005,7 +851,6 @@ def assign_members_to_job():
1005
  logger.warning(f"Insufficient members ({len(selected_members)}) assigned to job {job_id}. Minimum is 25.")
1006
  return jsonify({"error": "At least 25 members must be assigned to the job"}), 400
1007
 
1008
- # Update job with assigned members
1009
  try:
1010
  job_ref.update({"assigned_members": selected_members})
1011
  logger.info(f"{len(selected_members)} members assigned to job {job_id}.")
@@ -1019,7 +864,6 @@ def assign_members_to_job():
1019
 
1020
  @app.route("/update_job_settings/<job_id>", methods=["POST"])
1021
  def update_job_settings(job_id):
1022
- """Update job settings including rotation period"""
1023
  logger.info(f"Handling /update_job_settings/{job_id} request")
1024
  user = verify_token(request)
1025
  if not user:
@@ -1027,7 +871,7 @@ def update_job_settings(job_id):
1027
  return jsonify({"error": "Unauthorized"}), 401
1028
 
1029
  data = request.json
1030
- rotation_period = data.get("rotation_period") # In seconds
1031
  logger.debug(f"Updating settings for job {job_id} with data: {data}")
1032
 
1033
  if rotation_period is None:
@@ -1035,13 +879,10 @@ def update_job_settings(job_id):
1035
  return jsonify({"error": "rotation_period is required (in seconds)"}), 400
1036
 
1037
  job_ref = db.reference(f"jobs/{job_id}")
1038
- job_data = job_ref.get()
1039
-
1040
- if not job_data: # Fixed condition
1041
  logger.warning(f"Job {job_id} not found for settings update.")
1042
  return jsonify({"error": "Job not found"}), 404
1043
 
1044
- # Update rotation period
1045
  try:
1046
  job_ref.update({"rotation_period": rotation_period})
1047
  logger.info(f"Job {job_id} rotation period updated to {rotation_period} seconds.")
@@ -1055,7 +896,6 @@ def update_job_settings(job_id):
1055
 
1056
  @app.route("/add_special_case/<job_id>", methods=["POST"])
1057
  def add_special_case(job_id):
1058
- """Add a special case assignment for a job"""
1059
  logger.info(f"Handling /add_special_case/{job_id} request")
1060
  user = verify_token(request)
1061
  if not user:
@@ -1077,11 +917,10 @@ def add_special_case(job_id):
1077
  job_ref = db.reference(f"jobs/{job_id}")
1078
  job_data = job_ref.get()
1079
 
1080
- if not job_data: # Fixed condition
1081
  logger.warning(f"Job {job_id} not found for special case addition.")
1082
  return jsonify({"error": "Job not found"}), 404
1083
 
1084
- # Verify point and member exist
1085
  guarding_points = job_data.get("guarding_points", [])
1086
  assigned_members = job_data.get("assigned_members", [])
1087
 
@@ -1095,7 +934,6 @@ def add_special_case(job_id):
1095
  logger.warning(f"Member {member_id} not assigned to job {job_id}.")
1096
  return jsonify({"error": "Member not assigned to job"}), 404
1097
 
1098
- # Add special case
1099
  special_case = {
1100
  "id": str(uuid.uuid4()),
1101
  "point_id": point_id,
@@ -1103,7 +941,7 @@ def add_special_case(job_id):
1103
  "reason": reason,
1104
  "shift_number": shift_number,
1105
  "active": active,
1106
- "created_at": datetime.datetime.now().isoformat()
1107
  }
1108
 
1109
  special_cases = job_data.get("special_cases", [])
@@ -1122,7 +960,6 @@ def add_special_case(job_id):
1122
 
1123
  @app.route("/get_roster/<job_id>", methods=["GET"])
1124
  def get_roster(job_id):
1125
- """Get formatted roster for a job"""
1126
  logger.info(f"Handling /get_roster/{job_id} request")
1127
  user = verify_token(request)
1128
  if not user:
@@ -1133,26 +970,24 @@ def get_roster(job_id):
1133
  job_ref = db.reference(f"jobs/{job_id}")
1134
  job_data = job_ref.get()
1135
 
1136
- if not job_data: # Fixed condition
1137
  logger.warning(f"Job {job_id} not found for roster retrieval.")
1138
  return jsonify({"error": "Job not found"}), 404
1139
 
1140
  assignments = job_data.get("assignments", [])
1141
-
1142
- # Format roster as table data
1143
  roster_data = []
1144
  for shift in assignments:
1145
  shift_number = shift["shift_number"]
1146
  assigned_at = shift["assigned_at"]
1147
 
1148
- for assignment in shift["assignments"]:
 
1149
  roster_data.append({
1150
  "shift_number": shift_number,
1151
  "assigned_at": assigned_at,
1152
- "point_name": assignment["point"]["name"],
1153
- "member_name": assignment["member"].get("name", "Unknown"),
1154
  "is_special_case": assignment.get("is_special_case", False),
1155
- "special_case_reason": assignment.get("special_case_reason", "")
1156
  })
1157
 
1158
  logger.debug(f"Retrieved roster for job {job_id} with {len(roster_data)} entries.")
@@ -1166,13 +1001,14 @@ def get_roster(job_id):
1166
  return jsonify({"error": "Internal server error"}), 500
1167
 
1168
  # === Schedule periodic guard rotation ===
1169
- # Run every 5 minutes to check for rotations
1170
  try:
 
1171
  scheduler.add_job(
1172
  func=check_and_rotate_guards,
1173
  trigger="interval",
1174
  minutes=5,
1175
- id="guard_rotation_job"
 
1176
  )
1177
  logger.info("Scheduled periodic guard rotation check job.")
1178
  except Exception as e:
 
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
 
41
  logger.addHandler(console_handler)
42
 
43
  # Also configure the root logger for Flask's default messages
 
44
  root_logger = logging.getLogger()
45
  root_logger.setLevel(logging.INFO)
46
  root_logger.addHandler(file_handler)
 
53
  ADMIN_EMAILS = ["rairorr@gmail.com", "nharingosheperd@gmail.com"]
54
 
55
  logger.info("Environment variables loaded.")
56
+ logger.debug(f"FIREBASE_DB_URL: {FIREBASE_DB_URL}")
57
 
58
  # === Firebase Init ===
59
  try:
 
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
117
+ logger.info(f"Email sent successfully to {to}. Response: {response}")
118
  return response
119
  except Exception as e:
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:
127
  job_ref = db.reference(f"jobs/{job_id}")
128
  job_data = job_ref.get()
 
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>
150
  <p><strong>Job ID:</strong> {job_id}</p>
151
  <p><strong>Shift Number:</strong> {shift_number}</p>
152
+ <p><strong>Rotation Time:</strong> {human_readable_time}</p>
153
 
154
  <h3>Assignments:</h3>
155
  <table border="1" style="border-collapse: collapse; width: 100%;">
156
  <thead>
157
  <tr>
158
  <th>Guarding Point</th>
159
+ <th>Assigned Members</th>
160
  <th>Special Case</th>
161
  </tr>
162
  </thead>
 
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"""
177
  <tr>
178
  <td>{point_name}</td>
179
+ <td>{members_html}</td>
180
  <td>{is_special}</td>
181
  </tr>
182
  """
 
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}")
201
 
202
+
203
  # === Auth Middleware ===
204
  def verify_token(req):
 
 
 
 
 
205
  auth_header = req.headers.get("Authorization")
 
206
  user_email = req.headers.get("X-User-Email")
207
 
208
  if not auth_header:
209
  print("Authorization header missing.")
210
  return None
211
 
 
212
  if not user_email:
213
  logging.info("X-User-Email header missing from request.")
214
  return None
 
218
  return None
219
 
220
  try:
 
221
  if auth_header.startswith("Bearer "):
222
  token = auth_header[7:].strip()
223
  else:
224
  token = auth_header.strip()
225
 
 
226
  decoded = firebase_auth.verify_id_token(token)
 
 
 
 
227
  logging.info(f"User with email {user_email} (UID: {decoded.get('uid')}) is authorized as admin (email in ADMIN_EMAILS).")
228
+ return decoded
229
 
 
 
 
 
 
 
 
 
 
230
  except Exception as e:
231
  print(f"Unexpected error during token verification: {e}")
 
 
 
232
  return None
233
 
234
  # === Admin Setup (Legacy - kept for compatibility) ===
 
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:
248
  logger.info(f"Starting roster assignment for job {job_id}")
 
249
  job_ref = db.reference(f"jobs/{job_id}")
250
  job_data = job_ref.get()
251
 
 
253
  logger.error(f"Job {job_id} not found")
254
  return
255
 
256
+ if job_data.get("status") != "active":
257
+ logger.info(f"Job {job_id} is not active (status: {job_data.get('status')}), skipping assignment.")
 
258
  return
259
 
 
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
  }
317
 
318
+ current_assignments = job_data.get("assignments", [])
319
  current_assignments.append(shift_record)
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,
334
  trigger="date",
 
336
  args=[job_id],
337
  id=f"rotate_{job_id}_{uuid.uuid4().hex[:8]}"
338
  )
339
+ logger.info(f"Scheduled next rotation for job {job_id} in {rotation_period} seconds.")
 
340
  else:
341
+ logger.error(f"No assignments were created for job {job_id}, shift {shift_number}.")
342
 
343
  except Exception as e:
344
  logger.error(f"Error in assign_roster for job {job_id}: {e}", exc_info=True)
345
 
346
+
347
  def check_and_rotate_guards():
348
  """Periodic function to check jobs and rotate guards"""
349
  try:
350
  logger.debug("🔍 Checking for guard rotations...")
 
351
  jobs_ref = db.reference("jobs")
352
  jobs = jobs_ref.get()
353
 
 
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)
 
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():
 
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
 
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
 
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
  }
 
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)
 
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:
 
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:
 
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,
 
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,
 
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):
 
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
 
 
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
  )
 
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"])
 
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
 
 
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
 
 
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
 
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
 
 
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
 
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:
 
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()
 
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:
 
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}.")
 
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:
 
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:
 
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.")
 
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:
 
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
 
 
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,
 
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", [])
 
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:
 
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.")
 
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",
1009
  minutes=5,
1010
+ id="guard_rotation_check_job",
1011
+ replace_existing=True
1012
  )
1013
  logger.info("Scheduled periodic guard rotation check job.")
1014
  except Exception as e: