rairo commited on
Commit
9ab3b72
·
verified ·
1 Parent(s): b5f2ed8

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +555 -280
main.py CHANGED
@@ -10,6 +10,39 @@ from flask_apscheduler import APScheduler
10
  import firebase_admin
11
  from firebase_admin import credentials, db, auth as firebase_auth
12
  from resend import Emails
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  # === ENV Config ===
15
  RESEND_API_KEY = os.getenv("RESEND_API_KEY")
@@ -17,11 +50,19 @@ FIREBASE_CRED_JSON = json.loads(os.getenv("FIREBASE"))
17
  FIREBASE_DB_URL = os.getenv("Firebase_DB")
18
  ADMIN_EMAILS = ["rairorr@gmail.com", "nharingoshepherd@gmail.com"]
19
 
 
 
 
20
  # === Firebase Init ===
21
- cred = credentials.Certificate(FIREBASE_CRED_JSON)
22
- firebase_admin.initialize_app(cred, {
23
- "databaseURL": FIREBASE_DB_URL
24
- })
 
 
 
 
 
25
 
26
  admin_emails = [
27
  "rairorr@gmail.com",
@@ -30,16 +71,19 @@ admin_emails = [
30
 
31
  for email in admin_emails:
32
  key = email.replace("@", "_").replace(".", "_")
33
- db.reference(f"admins/{key}").set({
34
- "email": email,
35
- "is_admin": True
36
- })
37
-
38
- print("✅ Admins added.")
 
 
39
 
40
  # === Flask App Setup ===
41
  app = Flask(__name__)
42
  CORS(app)
 
43
 
44
  # === APScheduler Setup ===
45
  class Config(object):
@@ -50,9 +94,11 @@ app.config.from_object(Config())
50
  scheduler = APScheduler()
51
  scheduler.init_app(app)
52
  scheduler.start()
 
53
 
54
  # === Resend Init ===
55
  Emails.api_key = RESEND_API_KEY
 
56
 
57
  def send_email(to, subject, html):
58
  try:
@@ -62,10 +108,10 @@ def send_email(to, subject, html):
62
  "subject": subject,
63
  "html": html
64
  })
65
- print(f"Email sent successfully to {to}")
66
  return response
67
  except Exception as e:
68
- print(f"Email error to {to}: {e}")
69
  return None
70
 
71
  def send_rotation_notification(job_id, shift_record):
@@ -73,13 +119,14 @@ def send_rotation_notification(job_id, shift_record):
73
  try:
74
  job_ref = db.reference(f"jobs/{job_id}")
75
  job_data = job_ref.get()
76
-
77
  if not job_data:
 
78
  return
79
-
80
  job_name = job_data.get("name", "Unknown Job")
81
  shift_number = shift_record.get("shift_number", "Unknown")
82
-
83
  # Create HTML email content
84
  html_content = f"""
85
  <h2>Guard Rotation Notification</h2>
@@ -87,7 +134,7 @@ def send_rotation_notification(job_id, shift_record):
87
  <p><strong>Job ID:</strong> {job_id}</p>
88
  <p><strong>Shift Number:</strong> {shift_number}</p>
89
  <p><strong>Rotation Time:</strong> {shift_record.get('assigned_at', 'Unknown')}</p>
90
-
91
  <h3>Assignments:</h3>
92
  <table border="1" style="border-collapse: collapse; width: 100%;">
93
  <thead>
@@ -99,18 +146,18 @@ def send_rotation_notification(job_id, shift_record):
99
  </thead>
100
  <tbody>
101
  """
102
-
103
  for assignment in shift_record.get("assignments", []):
104
  point_name = assignment.get("point", {}).get("name", "Unknown Point")
105
  member_name = assignment.get("member", {}).get("name", "Unknown Member")
106
  is_special = "Yes" if assignment.get("is_special_case", False) else "No"
107
  special_reason = assignment.get("special_case_reason", "") if assignment.get("is_special_case", False) else ""
108
-
109
  if special_reason:
110
  member_info = f"{member_name} ({special_reason})"
111
  else:
112
  member_info = member_name
113
-
114
  html_content += f"""
115
  <tr>
116
  <td>{point_name}</td>
@@ -118,54 +165,87 @@ def send_rotation_notification(job_id, shift_record):
118
  <td>{is_special}</td>
119
  </tr>
120
  """
121
-
122
  html_content += """
123
  </tbody>
124
  </table>
125
  <p><em>This is an automated notification from the Guard Rotation System.</em></p>
126
  """
127
-
128
  # Send email to all admin emails
129
  subject = f"Guard Rotation - {job_name} (Shift {shift_number})"
130
-
131
  for admin_email in ADMIN_EMAILS:
132
  send_email(admin_email, subject, html_content)
133
-
134
  except Exception as e:
135
- print(f"Error sending rotation notification for job {job_id}: {e}")
136
 
137
  # === Auth Middleware ===
138
  def verify_token(req):
 
 
 
 
139
  auth_header = req.headers.get("Authorization")
 
 
140
  if not auth_header:
 
141
  return None
142
-
143
  try:
144
  # Extract token
145
  if auth_header.startswith("Bearer "):
146
  token = auth_header[7:].strip()
 
147
  else:
148
  token = auth_header.strip()
149
-
 
 
 
 
 
 
150
  # Verify the token
151
  decoded = firebase_auth.verify_id_token(token)
152
-
 
153
  # Get user UID from decoded token
154
  uid = decoded.get('uid')
155
-
 
 
 
 
156
  # Check if user is admin by querying Firebase Realtime Database
157
  admins_ref = db.reference("/admins")
158
  admin_data = admins_ref.child(uid).get()
159
-
 
160
  if admin_data and admin_data.get("is_admin", False):
 
161
  return decoded
162
-
 
 
 
 
 
 
 
 
 
 
 
163
  return None
164
  except Exception as e:
165
- print(f"Token verification error: {e}")
166
  return None
167
 
168
- # === Admin Setup ===
 
169
  def setup_admins():
170
  ref = db.reference("admins")
171
  for email in ADMIN_EMAILS:
@@ -177,54 +257,58 @@ setup_admins()
177
  def assign_roster(job_id):
178
  """Assign roster for a job - implements the rotation algorithm"""
179
  try:
 
180
  # Get job details
181
  job_ref = db.reference(f"jobs/{job_id}")
182
  job_data = job_ref.get()
183
-
184
- if not job_id:
185
- print(f"Job {job_id} not found")
186
  return
187
-
188
  # Check if job is paused
189
  if job_data.get("status") == "paused":
190
- print(f"Job {job_id} is paused, skipping assignment")
191
  return
192
-
193
  # Get guarding points and assigned members
194
  guarding_points = job_data.get("guarding_points", [])
195
  assigned_members = job_data.get("assigned_members", [])
196
  special_cases = job_data.get("special_cases", [])
197
-
198
  if not guarding_points:
199
- print(f"No guarding points defined for job {job_id}")
200
  return
201
-
202
  if not assigned_members:
203
- print(f"No members assigned to job {job_id}")
204
  return
205
-
206
  # Get current assignments and rotation history
207
  current_assignments = job_data.get("assignments", [])
208
  rotation_history = job_data.get("rotation_history", {})
209
-
210
  # Determine next shift
211
  shift_number = len(current_assignments) + 1
212
-
 
213
  # Apply special cases if any match current shift
214
  special_assignment = None
215
  for case in special_cases:
216
  if case.get("shift_number") == shift_number or case.get("active", False):
217
  special_assignment = case
 
218
  break
219
-
220
  # Generate assignments for this shift
221
  shift_assignments = []
222
-
223
  if special_assignment:
 
224
  # Apply special case assignment
225
  assigned_point = next((p for p in guarding_points if p["id"] == special_assignment["point_id"]), None)
226
  assigned_member = next((m for m in assigned_members if m["id"] == special_assignment["member_id"]), None)
227
-
228
  if assigned_point and assigned_member:
229
  shift_assignments.append({
230
  "point": assigned_point,
@@ -232,59 +316,76 @@ def assign_roster(job_id):
232
  "is_special_case": True,
233
  "special_case_reason": special_assignment.get("reason", "Special Assignment")
234
  })
 
 
235
  else:
 
236
  # Standard rotation algorithm
237
  # Create point-specific rotation history
238
- point_assignments = {}
239
-
240
  for point in guarding_points:
241
  point_id = point["id"]
242
  point_history = rotation_history.get(point_id, [])
243
-
244
  # Find member who hasn't been assigned to this point recently
245
  available_members = assigned_members.copy()
246
-
247
  # Remove recently assigned members (no immediate repetition)
248
  recently_assigned_member_ids = set()
249
- for recent_assignment in point_history[-len(guarding_points):]: # Look back N assignments
 
250
  recently_assigned_member_ids.add(recent_assignment["member_id"])
251
-
252
  # Filter available members
253
  available_members = [m for m in available_members if m["id"] not in recently_assigned_member_ids]
254
-
255
  # If all members have been recently assigned, use all members
256
  if not available_members:
 
257
  available_members = assigned_members
258
-
259
  # Select next member (simple round-robin from available)
260
- if point_history:
 
261
  last_assigned_member_id = point_history[-1]["member_id"]
262
  last_member_index = next((i for i, m in enumerate(available_members) if m["id"] == last_assigned_member_id), -1)
263
- next_member_index = (last_member_index + 1) % len(available_members)
 
 
 
 
 
 
 
 
264
  else:
265
- next_member_index = 0
266
-
267
- selected_member = available_members[next_member_index]
268
-
269
- # Record assignment
270
- shift_assignments.append({
271
- "point": point,
272
- "member": selected_member,
273
- "is_special_case": False
274
- })
275
-
276
- # Update rotation history
277
- point_history.append({
278
- "member_id": selected_member["id"],
279
- "member_name": selected_member.get("name", "Unknown"),
280
- "assigned_at": datetime.datetime.now().isoformat(),
281
- "shift_number": shift_number
282
- })
283
- rotation_history[point_id] = point_history
284
-
 
 
 
285
  # Update rotation history in database
286
  job_ref.update({"rotation_history": rotation_history})
287
-
288
  # Create shift record
289
  shift_record = {
290
  "shift_number": shift_number,
@@ -292,25 +393,25 @@ def assign_roster(job_id):
292
  "assignments": shift_assignments,
293
  "shift_id": str(uuid.uuid4())
294
  }
295
-
296
  # Add to current assignments
297
  current_assignments.append(shift_record)
298
-
299
  # Update job with new assignment
300
  job_ref.update({
301
  "assignments": current_assignments,
302
  "last_updated": datetime.datetime.now().isoformat()
303
  })
304
-
305
- print(f"Shift {shift_number} assigned for job {job_id} with {len(shift_assignments)} assignments")
306
-
307
  # Send email notification to admins
308
  send_rotation_notification(job_id, shift_record)
309
-
310
  # Schedule next rotation based on job's rotation period
311
  rotation_period = job_data.get("rotation_period", 28800) # Default 8 hours
312
  next_run_time = datetime.datetime.now() + datetime.timedelta(seconds=rotation_period)
313
-
314
  scheduler.add_job(
315
  func=assign_roster,
316
  trigger="date",
@@ -318,107 +419,156 @@ def assign_roster(job_id):
318
  args=[job_id],
319
  id=f"rotate_{job_id}_{uuid.uuid4().hex[:8]}"
320
  )
321
-
322
- print(f"Scheduled next rotation for job {job_id} in {rotation_period} seconds")
323
-
324
  except Exception as e:
325
- print(f"Error in assign_roster for job {job_id}: {e}")
326
 
327
  def check_and_rotate_guards():
328
  """Periodic function to check jobs and rotate guards"""
329
  try:
330
- print("🔍 Checking for guard rotations...")
331
-
332
  # Get all jobs
333
  jobs_ref = db.reference("jobs")
334
  jobs = jobs_ref.get()
335
-
336
  if not jobs:
337
- print("No jobs found")
338
  return
339
-
340
  # Check each job for rotation needs (for jobs that don't have scheduled rotations)
341
  for job_id, job_data in jobs.items():
 
342
  # Skip paused jobs
343
  if job_data.get("status") == "paused":
 
344
  continue
345
-
346
- # Skip jobs that have scheduled rotations
347
- if job_data.get("has_scheduled_rotation", False):
348
- continue
349
-
 
 
350
  # For jobs without scheduled rotations, check manually
351
  assignments = job_data.get("assignments", [])
352
-
353
  if not assignments:
354
  # If no assignments yet, this job hasn't started manually
 
355
  continue
356
  else:
357
  # Check if current assignment is expired
358
  last_assignment = assignments[-1]
359
- assigned_at = datetime.datetime.fromisoformat(last_assignment["assigned_at"].replace("Z", "+00:00"))
 
 
 
 
 
360
  now = datetime.datetime.now(assigned_at.tzinfo)
361
-
362
  # Get rotation period from job or default to 8 hours
363
  rotation_period = job_data.get("rotation_period", 28800)
364
-
365
- if (now - assigned_at).total_seconds() > rotation_period:
366
- print(f"Rotating guard for job {job_id}")
 
 
 
367
  assign_roster(job_id)
368
-
 
 
369
  except Exception as e:
370
- print(f"Error in check_and_rotate_guards: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
 
372
- # === Routes ===
373
  @app.route("/upload_members", methods=["POST"])
374
  def upload_members():
 
375
  user = verify_token(request)
376
  if not user:
 
377
  return jsonify({"error": "Unauthorized"}), 401
378
 
379
  file = request.files.get("file")
380
  if not file:
 
381
  return jsonify({"error": "No file uploaded"}), 400
382
 
383
- df = pd.read_csv(file) if file.filename.endswith(".csv") else pd.read_excel(file)
384
- members = df.to_dict(orient="records")
 
 
385
 
386
- for member in members:
387
- member_id = str(uuid.uuid4())
388
- db.reference(f"members/{member_id}").set(member)
389
 
390
- return jsonify({"message": "Members uploaded successfully"}), 200
 
 
 
 
391
 
392
  @app.route("/add_member", methods=["POST"])
393
  def add_member():
 
394
  user = verify_token(request)
395
  if not user:
 
396
  return jsonify({"error": "Unauthorized"}), 401
397
 
398
  data = request.json
 
399
  member_id = str(uuid.uuid4())
400
- db.reference(f"members/{member_id}").set(data)
401
- return jsonify({"message": "Member added"}), 200
 
 
 
 
 
402
 
403
  @app.route("/create_job", methods=["POST"])
404
  def create_job():
 
405
  user = verify_token(request)
406
  if not user:
 
407
  return jsonify({"error": "Unauthorized"}), 401
408
 
409
  data = request.json
 
410
  job_id = str(uuid.uuid4())
411
-
412
  # Validate guarding points (5-15 points)
413
  guarding_points = data.get("guarding_points", [])
414
  if len(guarding_points) < 5 or len(guarding_points) > 15:
 
415
  return jsonify({"error": "Guarding points must be between 5 and 15"}), 400
416
-
417
  # Add IDs to guarding points if not present
418
  for i, point in enumerate(guarding_points):
419
  if "id" not in point:
420
  point["id"] = f"point_{i+1}"
421
-
422
  # Add default fields
423
  job_data = {
424
  **data,
@@ -432,214 +582,300 @@ def create_job():
432
  "has_scheduled_rotation": False,
433
  "special_cases": data.get("special_cases", [])
434
  }
435
-
436
- db.reference(f"jobs/{job_id}").set(job_data)
437
- return jsonify({"message": "Job created", "job_id": job_id}), 200
 
 
 
 
438
 
439
  @app.route("/schedule_job/<job_id>", methods=["POST"])
440
  def schedule_job(job_id):
441
  """Schedule a job to start at a specific time"""
 
442
  user = verify_token(request)
443
  if not user:
 
444
  return jsonify({"error": "Unauthorized"}), 401
445
 
446
  data = request.json
447
  start_time_iso = data.get("start_time") # ISO format datetime string
448
-
 
449
  if not start_time_iso:
 
450
  return jsonify({"error": "start_time is required (ISO format)"}), 400
451
 
452
  job_ref = db.reference(f"jobs/{job_id}")
453
  job_data = job_ref.get()
454
-
455
- if not job_id:
 
456
  return jsonify({"error": "Job not found"}), 404
457
-
458
  # Parse the start time
459
  try:
460
  start_time = datetime.datetime.fromisoformat(start_time_iso.replace("Z", "+00:00"))
 
461
  except ValueError:
 
462
  return jsonify({"error": "Invalid start_time format. Use ISO format (e.g., 2023-12-01T10:00:00)"}), 400
463
-
464
  # Update job with scheduled start time
465
- job_ref.update({
466
- "scheduled_start_time": start_time_iso,
467
- "status": "scheduled"
468
- })
469
-
 
 
 
 
 
470
  # Schedule the job to start at the specified time
471
- scheduler.add_job(
472
- func=start_scheduled_job,
473
- trigger="date",
474
- run_date=start_time,
475
- args=[job_id],
476
- id=f"start_{job_id}_{uuid.uuid4().hex[:8]}"
477
- )
478
-
479
- return jsonify({"message": f"Job {job_id} scheduled to start at {start_time_iso}"}), 200
 
 
 
 
 
480
 
481
  def start_scheduled_job(job_id):
482
  """Function to start a scheduled job"""
483
  try:
 
484
  job_ref = db.reference(f"jobs/{job_id}")
485
  job_data = job_ref.get()
486
-
487
- if not job_id:
488
- print(f"Job {job_id} not found for scheduled start")
489
  return
490
-
491
  # Update job status to active
492
  job_ref.update({
493
  "status": "active",
494
  "started_at": datetime.datetime.now().isoformat()
495
  })
496
-
 
497
  # Start first assignment
498
  assign_roster(job_id)
499
-
500
- print(f"Job {job_id} started via schedule")
501
  except Exception as e:
502
- print(f"Error starting scheduled job {job_id}: {e}")
503
 
504
  @app.route("/start_job/<job_id>", methods=["POST"])
505
  def start_job(job_id):
 
506
  user = verify_token(request)
507
  if not user:
 
508
  return jsonify({"error": "Unauthorized"}), 401
509
 
510
  job_ref = db.reference(f"jobs/{job_id}")
511
  job_data = job_ref.get()
512
-
513
- if not job_id:
 
514
  return jsonify({"error": "Job not found"}), 404
515
-
516
  # Update job status to active
517
- job_ref.update({
518
- "status": "active",
519
- "started_at": datetime.datetime.now().isoformat()
520
- })
521
-
 
 
 
 
 
522
  # Schedule the first assignment immediately
523
- scheduler.add_job(
524
- func=assign_roster,
525
- trigger="date",
526
- run_date=datetime.datetime.now() + datetime.timedelta(seconds=5),
527
- args=[job_id],
528
- id=f"start_{job_id}_{uuid.uuid4().hex[:8]}"
529
- )
530
-
531
- return jsonify({"message": f"Job {job_id} started"}), 202
 
 
 
 
 
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:
 
537
  return jsonify({"error": "Unauthorized"}), 401
538
 
539
  job_ref = db.reference(f"jobs/{job_id}")
540
  job_data = job_ref.get()
541
-
542
- if not job_id:
 
543
  return jsonify({"error": "Job not found"}), 404
544
-
545
- job_ref.update({"status": "paused"})
546
- return jsonify({"message": f"Job {job_id} paused"}), 200
 
 
 
 
 
547
 
548
  @app.route("/delete_job/<job_id>", methods=["DELETE"])
549
  def delete_job(job_id):
 
550
  user = verify_token(request)
551
  if not user:
 
552
  return jsonify({"error": "Unauthorized"}), 401
553
 
554
  job_ref = db.reference(f"jobs/{job_id}")
555
  job_data = job_ref.get()
556
-
557
- if not job_id:
 
558
  return jsonify({"error": "Job not found"}), 404
559
-
560
- job_ref.delete()
561
- return jsonify({"message": f"Job {job_id} deleted"}), 200
 
 
 
 
 
562
 
563
  @app.route("/jobs", methods=["GET"])
564
  def get_jobs():
 
565
  user = verify_token(request)
566
  if not user:
 
567
  return jsonify({"error": "Unauthorized"}), 401
568
 
569
- jobs_ref = db.reference("jobs")
570
- jobs = jobs_ref.get()
571
-
572
- if not jobs:
573
- return jsonify({"jobs": []}), 200
574
-
575
- # Convert to list format
576
- jobs_list = []
577
- for job_id, job_data in jobs.items():
578
- job_data["id"] = job_id
579
- jobs_list.append(job_data)
580
-
581
- return jsonify({"jobs": jobs_list}), 200
 
 
 
 
 
 
582
 
583
  @app.route("/job/<job_id>", methods=["GET"])
584
  def get_job(job_id):
 
585
  user = verify_token(request)
586
  if not user:
 
587
  return jsonify({"error": "Unauthorized"}), 401
588
 
589
- job_ref = db.reference(f"jobs/{job_id}")
590
- job_data = job_ref.get()
591
-
592
- if not job_id:
593
- return jsonify({"error": "Job not found"}), 404
594
-
595
- job_data["id"] = job_id
596
- return jsonify({"job": job_data}), 200
 
 
 
 
 
 
597
 
598
  @app.route("/members", methods=["GET"])
599
  def get_members():
 
600
  user = verify_token(request)
601
  if not user:
 
602
  return jsonify({"error": "Unauthorized"}), 401
603
 
604
- members_ref = db.reference("members")
605
- members = members_ref.get()
606
-
607
- if not members:
608
- return jsonify({"members": []}), 200
609
-
610
- # Convert to list format
611
- members_list = []
612
- for member_id, member_data in members.items():
613
- member_data["id"] = member_id
614
- members_list.append(member_data)
615
-
616
- return jsonify({"members": members_list}), 200
 
 
 
 
 
 
617
 
618
  @app.route("/assign_members_to_job", methods=["POST"])
619
  def assign_members_to_job():
620
  """Bulk assign members to a job"""
 
621
  user = verify_token(request)
622
  if not user:
 
623
  return jsonify({"error": "Unauthorized"}), 401
624
 
625
  data = request.json
626
  job_id = data.get("job_id")
627
  member_ids = data.get("member_ids", [])
628
-
 
629
  if not job_id or not member_ids:
 
630
  return jsonify({"error": "job_id and member_ids are required"}), 400
631
 
632
  job_ref = db.reference(f"jobs/{job_id}")
633
  job_data = job_ref.get()
634
-
635
- if not job_id:
 
636
  return jsonify({"error": "Job not found"}), 404
637
 
638
  # Get members
639
- members_ref = db.reference("members")
640
- all_members = members_ref.get()
641
-
 
 
 
 
642
  if not all_members:
 
643
  return jsonify({"error": "No members found"}), 404
644
 
645
  # Filter requested members (ensure at least 25)
@@ -651,48 +887,64 @@ def assign_members_to_job():
651
  selected_members.append(member_data)
652
 
653
  if len(selected_members) < 25:
 
654
  return jsonify({"error": "At least 25 members must be assigned to the job"}), 400
655
 
656
  # Update job with assigned members
657
- job_ref.update({"assigned_members": selected_members})
658
-
659
- return jsonify({
660
- "message": f"{len(selected_members)} members assigned to job {job_id}",
661
- "assigned_members": selected_members
662
- }), 200
 
 
 
 
663
 
664
  @app.route("/update_job_settings/<job_id>", methods=["POST"])
665
  def update_job_settings(job_id):
666
  """Update job settings including rotation period"""
 
667
  user = verify_token(request)
668
  if not user:
 
669
  return jsonify({"error": "Unauthorized"}), 401
670
 
671
  data = request.json
672
  rotation_period = data.get("rotation_period") # In seconds
673
-
 
674
  if rotation_period is None:
 
675
  return jsonify({"error": "rotation_period is required (in seconds)"}), 400
676
 
677
  job_ref = db.reference(f"jobs/{job_id}")
678
  job_data = job_ref.get()
679
-
680
- if not job_id:
 
681
  return jsonify({"error": "Job not found"}), 404
682
 
683
  # Update rotation period
684
- job_ref.update({"rotation_period": rotation_period})
685
-
686
- return jsonify({
687
- "message": f"Job {job_id} rotation period updated to {rotation_period} seconds",
688
- "rotation_period": rotation_period
689
- }), 200
 
 
 
 
690
 
691
  @app.route("/add_special_case/<job_id>", methods=["POST"])
692
  def add_special_case(job_id):
693
  """Add a special case assignment for a job"""
 
694
  user = verify_token(request)
695
  if not user:
 
696
  return jsonify({"error": "Unauthorized"}), 401
697
 
698
  data = request.json
@@ -701,26 +953,31 @@ def add_special_case(job_id):
701
  reason = data.get("reason", "Special Assignment")
702
  shift_number = data.get("shift_number")
703
  active = data.get("active", False)
704
-
 
705
  if not point_id or not member_id:
 
706
  return jsonify({"error": "point_id and member_id are required"}), 400
707
 
708
  job_ref = db.reference(f"jobs/{job_id}")
709
  job_data = job_ref.get()
710
-
711
- if not job_id:
 
712
  return jsonify({"error": "Job not found"}), 404
713
 
714
  # Verify point and member exist
715
  guarding_points = job_data.get("guarding_points", [])
716
  assigned_members = job_data.get("assigned_members", [])
717
-
718
  point_exists = any(p["id"] == point_id for p in guarding_points)
719
  member_exists = any(m["id"] == member_id for m in assigned_members)
720
-
721
  if not point_exists:
 
722
  return jsonify({"error": "Guarding point not found in job"}), 404
723
  if not member_exists:
 
724
  return jsonify({"error": "Member not assigned to job"}), 404
725
 
726
  # Add special case
@@ -733,62 +990,80 @@ def add_special_case(job_id):
733
  "active": active,
734
  "created_at": datetime.datetime.now().isoformat()
735
  }
736
-
737
  special_cases = job_data.get("special_cases", [])
738
  special_cases.append(special_case)
739
-
740
- job_ref.update({"special_cases": special_cases})
741
-
742
- return jsonify({
743
- "message": "Special case added",
744
- "special_case": special_case
745
- }), 200
 
 
 
 
746
 
747
  @app.route("/get_roster/<job_id>", methods=["GET"])
748
  def get_roster(job_id):
749
  """Get formatted roster for a job"""
 
750
  user = verify_token(request)
751
  if not user:
 
752
  return jsonify({"error": "Unauthorized"}), 401
753
 
754
- job_ref = db.reference(f"jobs/{job_id}")
755
- job_data = job_ref.get()
756
-
757
- if not job_id:
758
- return jsonify({"error": "Job not found"}), 404
 
 
 
 
 
 
 
 
 
 
759
 
760
- assignments = job_data.get("assignments", [])
761
-
762
- # Format roster as table data
763
- roster_data = []
764
- for shift in assignments:
765
- shift_number = shift["shift_number"]
766
- assigned_at = shift["assigned_at"]
767
-
768
- for assignment in shift["assignments"]:
769
- roster_data.append({
770
- "shift_number": shift_number,
771
- "assigned_at": assigned_at,
772
- "point_name": assignment["point"]["name"],
773
- "member_name": assignment["member"].get("name", "Unknown"),
774
- "is_special_case": assignment.get("is_special_case", False),
775
- "special_case_reason": assignment.get("special_case_reason", "")
776
- })
777
-
778
- return jsonify({
779
- "roster": roster_data,
780
- "job_name": job_data.get("name", "Unknown Job"),
781
- "total_shifts": len(assignments)
782
- }), 200
783
 
784
  # === Schedule periodic guard rotation ===
785
  # Run every 5 minutes to check for rotations
786
- scheduler.add_job(
787
- func=check_and_rotate_guards,
788
- trigger="interval",
789
- minutes=5,
790
- id="guard_rotation_job"
791
- )
 
 
 
 
 
792
  # === Run Server ===
793
  if __name__ == "__main__":
794
- app.run(debug=True, host="0.0.0.0", port=7860)
 
 
10
  import firebase_admin
11
  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
18
+ if not os.path.exists('logs'):
19
+ os.mkdir('logs')
20
+
21
+ # Create a custom logger
22
+ logger = logging.getLogger('guards_api')
23
+ logger.setLevel(logging.DEBUG) # Capture DEBUG and above
24
+
25
+ # Create handlers
26
+ file_handler = RotatingFileHandler('logs/guards_api.log', maxBytes=10240, backupCount=10)
27
+ file_handler.setFormatter(logging.Formatter(
28
+ '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
29
+ ))
30
+ file_handler.setLevel(logging.INFO) # File logs INFO and above
31
+
32
+ console_handler = logging.StreamHandler()
33
+ console_handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
34
+ console_handler.setLevel(logging.DEBUG) # Console logs DEBUG and above
35
+
36
+ # Add handlers to the logger
37
+ 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)
45
+ root_logger.addHandler(console_handler)
46
 
47
  # === ENV Config ===
48
  RESEND_API_KEY = os.getenv("RESEND_API_KEY")
 
50
  FIREBASE_DB_URL = os.getenv("Firebase_DB")
51
  ADMIN_EMAILS = ["rairorr@gmail.com", "nharingoshepherd@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:
58
+ cred = credentials.Certificate(FIREBASE_CRED_JSON)
59
+ firebase_admin.initialize_app(cred, {
60
+ "databaseURL": FIREBASE_DB_URL
61
+ })
62
+ logger.info("Firebase Admin initialized successfully.")
63
+ except Exception as e:
64
+ logger.error(f"Failed to initialize Firebase Admin: {e}")
65
+ raise
66
 
67
  admin_emails = [
68
  "rairorr@gmail.com",
 
71
 
72
  for email in admin_emails:
73
  key = email.replace("@", "_").replace(".", "_")
74
+ try:
75
+ db.reference(f"admins/{key}").set({
76
+ "email": email,
77
+ "is_admin": True
78
+ })
79
+ logger.info(f"✅ Admin {email} added/updated in database.")
80
+ except Exception as e:
81
+ logger.error(f"Failed to add admin {email} to database: {e}")
82
 
83
  # === Flask App Setup ===
84
  app = Flask(__name__)
85
  CORS(app)
86
+ logger.info("Flask app initialized.")
87
 
88
  # === APScheduler Setup ===
89
  class Config(object):
 
94
  scheduler = APScheduler()
95
  scheduler.init_app(app)
96
  scheduler.start()
97
+ logger.info("APScheduler initialized and started.")
98
 
99
  # === Resend Init ===
100
  Emails.api_key = RESEND_API_KEY
101
+ logger.info("Resend API key configured.")
102
 
103
  def send_email(to, subject, html):
104
  try:
 
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):
 
119
  try:
120
  job_ref = db.reference(f"jobs/{job_id}")
121
  job_data = job_ref.get()
122
+
123
  if not job_data:
124
+ logger.warning(f"Job {job_id} not found for rotation notification.")
125
  return
126
+
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>
 
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>
 
146
  </thead>
147
  <tbody>
148
  """
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>
 
165
  <td>{is_special}</td>
166
  </tr>
167
  """
168
+
169
  html_content += """
170
  </tbody>
171
  </table>
172
  <p><em>This is an automated notification from the Guard Rotation System.</em></p>
173
  """
174
+
175
  # Send email to all admin emails
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 is admin.
188
+ Returns decoded token dict if valid admin, None otherwise.
189
+ """
190
  auth_header = req.headers.get("Authorization")
191
+ logger.debug(f"Verifying token for request to {req.path}. Authorization header present: {bool(auth_header)}")
192
+
193
  if not auth_header:
194
+ logger.warning("Authorization header missing.")
195
  return None
196
+
197
  try:
198
  # Extract token
199
  if auth_header.startswith("Bearer "):
200
  token = auth_header[7:].strip()
201
+ logger.debug("Bearer prefix found in Authorization header.")
202
  else:
203
  token = auth_header.strip()
204
+ logger.debug("No Bearer prefix found, using header as token.")
205
+
206
+ if not token:
207
+ logger.warning("Authorization header is empty or only whitespace.")
208
+ return None
209
+
210
+ logger.debug("Attempting to verify Firebase ID token...")
211
  # Verify the token
212
  decoded = firebase_auth.verify_id_token(token)
213
+ logger.debug(f"Token verified successfully. Decoded token UID: {decoded.get('uid')}")
214
+
215
  # Get user UID from decoded token
216
  uid = decoded.get('uid')
217
+ if not uid:
218
+ logger.warning("Verified token does not contain a UID.")
219
+ return None
220
+
221
+ logger.debug(f"Checking admin status for UID: {uid}")
222
  # Check if user is admin by querying Firebase Realtime Database
223
  admins_ref = db.reference("/admins")
224
  admin_data = admins_ref.child(uid).get()
225
+ logger.debug(f"Admin data retrieved for UID {uid}: {admin_data}")
226
+
227
  if admin_data and admin_data.get("is_admin", False):
228
+ logger.info(f"User {uid} ({admin_data.get('email', 'Unknown Email')}) is authorized as admin.")
229
  return decoded
230
+ else:
231
+ logger.warning(f"User {uid} is not an admin or admin data not found.")
232
+ return None
233
+
234
+ except firebase_auth.InvalidIdTokenError as e:
235
+ logger.error(f"Invalid Firebase ID token provided: {e}")
236
+ return None
237
+ except firebase_auth.ExpiredIdTokenError as e:
238
+ logger.error(f"Firebase ID token has expired: {e}")
239
+ return None
240
+ except firebase_auth.RevokedIdTokenError as e:
241
+ logger.error(f"Firebase ID token has been revoked: {e}")
242
  return None
243
  except Exception as e:
244
+ logger.error(f"Unexpected error during token verification: {e}", exc_info=True) # exc_info logs the full traceback
245
  return None
246
 
247
+
248
+ # === Admin Setup (Legacy - kept for compatibility) ===
249
  def setup_admins():
250
  ref = db.reference("admins")
251
  for email in ADMIN_EMAILS:
 
257
  def assign_roster(job_id):
258
  """Assign roster for a job - implements the rotation algorithm"""
259
  try:
260
+ logger.info(f"Starting roster assignment for job {job_id}")
261
  # Get job details
262
  job_ref = db.reference(f"jobs/{job_id}")
263
  job_data = job_ref.get()
264
+
265
+ if not job_data: # Fixed the condition: job_data is None, not job_id
266
+ logger.error(f"Job {job_id} not found")
267
  return
268
+
269
  # Check if job is paused
270
  if job_data.get("status") == "paused":
271
+ logger.info(f"Job {job_id} is paused, skipping assignment")
272
  return
273
+
274
  # Get guarding points and assigned members
275
  guarding_points = job_data.get("guarding_points", [])
276
  assigned_members = job_data.get("assigned_members", [])
277
  special_cases = job_data.get("special_cases", [])
278
+
279
  if not guarding_points:
280
+ logger.warning(f"No guarding points defined for job {job_id}")
281
  return
282
+
283
  if not assigned_members:
284
+ logger.warning(f"No members assigned to job {job_id}")
285
  return
286
+
287
  # Get current assignments and rotation history
288
  current_assignments = job_data.get("assignments", [])
289
  rotation_history = job_data.get("rotation_history", {})
290
+
291
  # Determine next shift
292
  shift_number = len(current_assignments) + 1
293
+ logger.debug(f"Determined shift number: {shift_number} for job {job_id}")
294
+
295
  # Apply special cases if any match current shift
296
  special_assignment = None
297
  for case in special_cases:
298
  if case.get("shift_number") == shift_number or case.get("active", False):
299
  special_assignment = case
300
+ logger.debug(f"Found matching special case for shift {shift_number}: {case}")
301
  break
302
+
303
  # Generate assignments for this shift
304
  shift_assignments = []
305
+
306
  if special_assignment:
307
+ logger.info(f"Applying special case assignment for job {job_id}, shift {shift_number}")
308
  # Apply special case assignment
309
  assigned_point = next((p for p in guarding_points if p["id"] == special_assignment["point_id"]), None)
310
  assigned_member = next((m for m in assigned_members if m["id"] == special_assignment["member_id"]), None)
311
+
312
  if assigned_point and assigned_member:
313
  shift_assignments.append({
314
  "point": assigned_point,
 
316
  "is_special_case": True,
317
  "special_case_reason": special_assignment.get("reason", "Special Assignment")
318
  })
319
+ else:
320
+ logger.warning(f"Special case assignment failed: Point or Member not found for job {job_id}")
321
  else:
322
+ logger.info(f"Applying standard rotation algorithm for job {job_id}, shift {shift_number}")
323
  # Standard rotation algorithm
324
  # Create point-specific rotation history
325
+ # point_assignments = {} # This variable was defined but not used, removed.
326
+
327
  for point in guarding_points:
328
  point_id = point["id"]
329
  point_history = rotation_history.get(point_id, [])
330
+
331
  # Find member who hasn't been assigned to this point recently
332
  available_members = assigned_members.copy()
333
+
334
  # Remove recently assigned members (no immediate repetition)
335
  recently_assigned_member_ids = set()
336
+ lookback_window = min(len(guarding_points), len(assigned_members)) # Prevent index error
337
+ for recent_assignment in point_history[-lookback_window:]: # Look back N assignments
338
  recently_assigned_member_ids.add(recent_assignment["member_id"])
339
+
340
  # Filter available members
341
  available_members = [m for m in available_members if m["id"] not in recently_assigned_member_ids]
342
+
343
  # If all members have been recently assigned, use all members
344
  if not available_members:
345
+ logger.debug(f"All members recently assigned to point {point_id}. Using full member list.")
346
  available_members = assigned_members
347
+
348
  # Select next member (simple round-robin from available)
349
+ selected_member = None
350
+ if point_history and available_members:
351
  last_assigned_member_id = point_history[-1]["member_id"]
352
  last_member_index = next((i for i, m in enumerate(available_members) if m["id"] == last_assigned_member_id), -1)
353
+ if last_member_index != -1: # Only proceed if last member was in available list
354
+ next_member_index = (last_member_index + 1) % len(available_members)
355
+ else:
356
+ # Last assigned member is not in available list, pick first available
357
+ next_member_index = 0
358
+ selected_member = available_members[next_member_index]
359
+ elif available_members:
360
+ # No history or last member not in available list, pick first
361
+ selected_member = available_members[0]
362
  else:
363
+ # This shouldn't happen due to the fallback, but log if it does
364
+ logger.error(f"No available members for point {point_id} in job {job_id}")
365
+ continue # Skip this point
366
+
367
+ if selected_member:
368
+ # Record assignment
369
+ shift_assignments.append({
370
+ "point": point,
371
+ "member": selected_member,
372
+ "is_special_case": False
373
+ })
374
+
375
+ # Update rotation history
376
+ point_history.append({
377
+ "member_id": selected_member["id"],
378
+ "member_name": selected_member.get("name", "Unknown"),
379
+ "assigned_at": datetime.datetime.now().isoformat(),
380
+ "shift_number": shift_number
381
+ })
382
+ rotation_history[point_id] = point_history
383
+ else:
384
+ logger.warning(f"Could not select a member for point {point_id} in job {job_id}")
385
+
386
  # Update rotation history in database
387
  job_ref.update({"rotation_history": rotation_history})
388
+
389
  # Create shift record
390
  shift_record = {
391
  "shift_number": shift_number,
 
393
  "assignments": shift_assignments,
394
  "shift_id": str(uuid.uuid4())
395
  }
396
+
397
  # Add to current assignments
398
  current_assignments.append(shift_record)
399
+
400
  # Update job with new assignment
401
  job_ref.update({
402
  "assignments": current_assignments,
403
  "last_updated": datetime.datetime.now().isoformat()
404
  })
405
+
406
+ logger.info(f"Shift {shift_number} assigned for job {job_id} with {len(shift_assignments)} assignments")
407
+
408
  # Send email notification to admins
409
  send_rotation_notification(job_id, shift_record)
410
+
411
  # Schedule next rotation based on job's rotation period
412
  rotation_period = job_data.get("rotation_period", 28800) # Default 8 hours
413
  next_run_time = datetime.datetime.now() + datetime.timedelta(seconds=rotation_period)
414
+
415
  scheduler.add_job(
416
  func=assign_roster,
417
  trigger="date",
 
419
  args=[job_id],
420
  id=f"rotate_{job_id}_{uuid.uuid4().hex[:8]}"
421
  )
422
+
423
+ logger.info(f"Scheduled next rotation for job {job_id} in {rotation_period} seconds")
424
+
425
  except Exception as e:
426
+ logger.error(f"Error in assign_roster for job {job_id}: {e}", exc_info=True)
427
 
428
  def check_and_rotate_guards():
429
  """Periodic function to check jobs and rotate guards"""
430
  try:
431
+ logger.debug("🔍 Checking for guard rotations...")
 
432
  # Get all jobs
433
  jobs_ref = db.reference("jobs")
434
  jobs = jobs_ref.get()
435
+
436
  if not jobs:
437
+ logger.debug("No jobs found")
438
  return
439
+
440
  # Check each job for rotation needs (for jobs that don't have scheduled rotations)
441
  for job_id, job_data in jobs.items():
442
+ logger.debug(f"Evaluating job {job_id} for rotation...")
443
  # Skip paused jobs
444
  if job_data.get("status") == "paused":
445
+ logger.debug(f"Job {job_id} is paused, skipping.")
446
  continue
447
+
448
+ # Skip jobs that have scheduled rotations handled by APScheduler
449
+ # Note: This logic might need refinement based on how you track scheduled jobs.
450
+ # if job_data.get("has_scheduled_rotation", False):
451
+ # logger.debug(f"Job {job_id} has scheduled rotation, skipping manual check.")
452
+ # continue
453
+
454
  # For jobs without scheduled rotations, check manually
455
  assignments = job_data.get("assignments", [])
456
+
457
  if not assignments:
458
  # If no assignments yet, this job hasn't started manually
459
+ logger.debug(f"Job {job_id} has no assignments yet, not started.")
460
  continue
461
  else:
462
  # Check if current assignment is expired
463
  last_assignment = assignments[-1]
464
+ try:
465
+ assigned_at = datetime.datetime.fromisoformat(last_assignment["assigned_at"].replace("Z", "+00:00"))
466
+ except ValueError:
467
+ logger.warning(f"Could not parse assigned_at time for job {job_id}: {last_assignment['assigned_at']}")
468
+ continue # Skip if time format is unexpected
469
+
470
  now = datetime.datetime.now(assigned_at.tzinfo)
471
+
472
  # Get rotation period from job or default to 8 hours
473
  rotation_period = job_data.get("rotation_period", 28800)
474
+
475
+ time_since_last_assignment = (now - assigned_at).total_seconds()
476
+ logger.debug(f"Job {job_id}: Last assignment was {time_since_last_assignment}s ago. Period is {rotation_period}s.")
477
+
478
+ if time_since_last_assignment > rotation_period:
479
+ logger.info(f"Rotating guard for job {job_id}")
480
  assign_roster(job_id)
481
+ else:
482
+ logger.debug(f"Job {job_id} not due for rotation yet.")
483
+
484
  except Exception as e:
485
+ logger.error(f"Error in check_and_rotate_guards: {e}", exc_info=True)
486
+
487
+ # === Routes with Logging ===
488
+ @app.before_request
489
+ def log_request_info():
490
+ """Log incoming request details."""
491
+ logger.info(f"Incoming request: {request.method} {request.url}")
492
+ logger.debug(f"Headers: {dict(request.headers)}")
493
+ # Be very careful logging request.data or request.json as they might contain sensitive info
494
+ # logger.debug(f"Body: {request.get_data()}") # Uncomment only for debugging, then remove!
495
+
496
+ @app.after_request
497
+ def log_response_info(response):
498
+ """Log outgoing response details."""
499
+ logger.info(f"Outgoing response: {response.status} for {request.method} {request.url}")
500
+ return response
501
 
 
502
  @app.route("/upload_members", methods=["POST"])
503
  def upload_members():
504
+ logger.info("Handling /upload_members request")
505
  user = verify_token(request)
506
  if not user:
507
+ logger.warning("Unauthorized access attempt to /upload_members")
508
  return jsonify({"error": "Unauthorized"}), 401
509
 
510
  file = request.files.get("file")
511
  if not file:
512
+ logger.warning("No file uploaded in /upload_members request")
513
  return jsonify({"error": "No file uploaded"}), 400
514
 
515
+ try:
516
+ df = pd.read_csv(file) if file.filename.endswith(".csv") else pd.read_excel(file)
517
+ members = df.to_dict(orient="records")
518
+ logger.info(f"Processing {len(members)} members from uploaded file.")
519
 
520
+ for member in members:
521
+ member_id = str(uuid.uuid4())
522
+ db.reference(f"members/{member_id}").set(member)
523
 
524
+ logger.info("Members uploaded successfully.")
525
+ return jsonify({"message": "Members uploaded successfully"}), 200
526
+ except Exception as e:
527
+ logger.error(f"Error processing uploaded members file: {e}", exc_info=True)
528
+ return jsonify({"error": "Error processing file"}), 500
529
 
530
  @app.route("/add_member", methods=["POST"])
531
  def add_member():
532
+ logger.info("Handling /add_member request")
533
  user = verify_token(request)
534
  if not user:
535
+ logger.warning("Unauthorized access attempt to /add_member")
536
  return jsonify({"error": "Unauthorized"}), 401
537
 
538
  data = request.json
539
+ logger.debug(f"Adding member with data: {data}")
540
  member_id = str(uuid.uuid4())
541
+ try:
542
+ db.reference(f"members/{member_id}").set(data)
543
+ logger.info(f"Member added successfully with ID: {member_id}")
544
+ return jsonify({"message": "Member added"}), 200
545
+ except Exception as e:
546
+ logger.error(f"Error adding member {member_id}: {e}", exc_info=True)
547
+ return jsonify({"error": "Internal server error"}), 500
548
 
549
  @app.route("/create_job", methods=["POST"])
550
  def create_job():
551
+ logger.info("Handling /create_job request")
552
  user = verify_token(request)
553
  if not user:
554
+ logger.warning("Unauthorized access attempt to /create_job")
555
  return jsonify({"error": "Unauthorized"}), 401
556
 
557
  data = request.json
558
+ logger.debug(f"Creating job with data: {data}")
559
  job_id = str(uuid.uuid4())
560
+
561
  # Validate guarding points (5-15 points)
562
  guarding_points = data.get("guarding_points", [])
563
  if len(guarding_points) < 5 or len(guarding_points) > 15:
564
+ logger.warning(f"Invalid number of guarding points ({len(guarding_points)}) for job creation.")
565
  return jsonify({"error": "Guarding points must be between 5 and 15"}), 400
566
+
567
  # Add IDs to guarding points if not present
568
  for i, point in enumerate(guarding_points):
569
  if "id" not in point:
570
  point["id"] = f"point_{i+1}"
571
+
572
  # Add default fields
573
  job_data = {
574
  **data,
 
582
  "has_scheduled_rotation": False,
583
  "special_cases": data.get("special_cases", [])
584
  }
585
+ try:
586
+ db.reference(f"jobs/{job_id}").set(job_data)
587
+ logger.info(f"Job created successfully with ID: {job_id}")
588
+ return jsonify({"message": "Job created", "job_id": job_id}), 200
589
+ except Exception as e:
590
+ logger.error(f"Error creating job {job_id}: {e}", exc_info=True)
591
+ return jsonify({"error": "Internal server error"}), 500
592
 
593
  @app.route("/schedule_job/<job_id>", methods=["POST"])
594
  def schedule_job(job_id):
595
  """Schedule a job to start at a specific time"""
596
+ logger.info(f"Handling /schedule_job/{job_id} request")
597
  user = verify_token(request)
598
  if not user:
599
+ logger.warning(f"Unauthorized access attempt to /schedule_job/{job_id}")
600
  return jsonify({"error": "Unauthorized"}), 401
601
 
602
  data = request.json
603
  start_time_iso = data.get("start_time") # ISO format datetime string
604
+ logger.debug(f"Scheduling job {job_id} with data: {data}")
605
+
606
  if not start_time_iso:
607
+ logger.warning("start_time is required for scheduling job.")
608
  return jsonify({"error": "start_time is required (ISO format)"}), 400
609
 
610
  job_ref = db.reference(f"jobs/{job_id}")
611
  job_data = job_ref.get()
612
+
613
+ if not job_data: # Fixed condition
614
+ logger.warning(f"Job {job_id} not found for scheduling.")
615
  return jsonify({"error": "Job not found"}), 404
616
+
617
  # Parse the start time
618
  try:
619
  start_time = datetime.datetime.fromisoformat(start_time_iso.replace("Z", "+00:00"))
620
+ logger.debug(f"Parsed start time: {start_time}")
621
  except ValueError:
622
+ logger.error(f"Invalid start_time format provided: {start_time_iso}")
623
  return jsonify({"error": "Invalid start_time format. Use ISO format (e.g., 2023-12-01T10:00:00)"}), 400
624
+
625
  # Update job with scheduled start time
626
+ try:
627
+ job_ref.update({
628
+ "scheduled_start_time": start_time_iso,
629
+ "status": "scheduled"
630
+ })
631
+ logger.info(f"Job {job_id} status updated to scheduled.")
632
+ except Exception as e:
633
+ logger.error(f"Error updating job {job_id} for scheduling: {e}", exc_info=True)
634
+ return jsonify({"error": "Internal server error"}), 500
635
+
636
  # Schedule the job to start at the specified time
637
+ try:
638
+ scheduler.add_job(
639
+ func=start_scheduled_job,
640
+ trigger="date",
641
+ run_date=start_time,
642
+ args=[job_id],
643
+ id=f"start_{job_id}_{uuid.uuid4().hex[:8]}"
644
+ )
645
+ logger.info(f"Job {job_id} scheduled to start at {start_time_iso}")
646
+ return jsonify({"message": f"Job {job_id} scheduled to start at {start_time_iso}"}), 200
647
+ except Exception as e:
648
+ logger.error(f"Error scheduling start job for {job_id}: {e}", exc_info=True)
649
+ # Consider rolling back the status update if scheduling fails
650
+ return jsonify({"error": "Failed to schedule job start"}), 500
651
 
652
  def start_scheduled_job(job_id):
653
  """Function to start a scheduled job"""
654
  try:
655
+ logger.info(f"Executing scheduled start for job {job_id}")
656
  job_ref = db.reference(f"jobs/{job_id}")
657
  job_data = job_ref.get()
658
+
659
+ if not job_data: # Fixed condition
660
+ logger.error(f"Job {job_id} not found for scheduled start")
661
  return
662
+
663
  # Update job status to active
664
  job_ref.update({
665
  "status": "active",
666
  "started_at": datetime.datetime.now().isoformat()
667
  })
668
+ logger.info(f"Job {job_id} status updated to active.")
669
+
670
  # Start first assignment
671
  assign_roster(job_id)
672
+ logger.info(f"Job {job_id} started via schedule")
673
+
674
  except Exception as e:
675
+ logger.error(f"Error starting scheduled job {job_id}: {e}", exc_info=True)
676
 
677
  @app.route("/start_job/<job_id>", methods=["POST"])
678
  def start_job(job_id):
679
+ logger.info(f"Handling /start_job/{job_id} request")
680
  user = verify_token(request)
681
  if not user:
682
+ logger.warning(f"Unauthorized access attempt to /start_job/{job_id}")
683
  return jsonify({"error": "Unauthorized"}), 401
684
 
685
  job_ref = db.reference(f"jobs/{job_id}")
686
  job_data = job_ref.get()
687
+
688
+ if not job_data: # Fixed condition
689
+ logger.warning(f"Job {job_id} not found for starting.")
690
  return jsonify({"error": "Job not found"}), 404
691
+
692
  # Update job status to active
693
+ try:
694
+ job_ref.update({
695
+ "status": "active",
696
+ "started_at": datetime.datetime.now().isoformat()
697
+ })
698
+ logger.info(f"Job {job_id} status updated to active.")
699
+ except Exception as e:
700
+ logger.error(f"Error updating job {job_id} to active: {e}", exc_info=True)
701
+ return jsonify({"error": "Internal server error"}), 500
702
+
703
  # Schedule the first assignment immediately
704
+ try:
705
+ scheduler.add_job(
706
+ func=assign_roster,
707
+ trigger="date",
708
+ run_date=datetime.datetime.now() + datetime.timedelta(seconds=5),
709
+ args=[job_id],
710
+ id=f"start_{job_id}_{uuid.uuid4().hex[:8]}"
711
+ )
712
+ logger.info(f"First assignment for job {job_id} scheduled.")
713
+ return jsonify({"message": f"Job {job_id} started"}), 202
714
+ except Exception as e:
715
+ logger.error(f"Error scheduling first assignment for job {job_id}: {e}", exc_info=True)
716
+ # Consider rolling back the status update if scheduling fails
717
+ return jsonify({"error": "Failed to start job assignments"}), 500
718
 
719
  @app.route("/pause_job/<job_id>", methods=["POST"])
720
  def pause_job(job_id):
721
+ logger.info(f"Handling /pause_job/{job_id} request")
722
  user = verify_token(request)
723
  if not user:
724
+ logger.warning(f"Unauthorized access attempt to /pause_job/{job_id}")
725
  return jsonify({"error": "Unauthorized"}), 401
726
 
727
  job_ref = db.reference(f"jobs/{job_id}")
728
  job_data = job_ref.get()
729
+
730
+ if not job_data: # Fixed condition
731
+ logger.warning(f"Job {job_id} not found for pausing.")
732
  return jsonify({"error": "Job not found"}), 404
733
+
734
+ try:
735
+ job_ref.update({"status": "paused"})
736
+ logger.info(f"Job {job_id} status updated to paused.")
737
+ return jsonify({"message": f"Job {job_id} paused"}), 200
738
+ except Exception as e:
739
+ logger.error(f"Error pausing job {job_id}: {e}", exc_info=True)
740
+ return jsonify({"error": "Internal server error"}), 500
741
 
742
  @app.route("/delete_job/<job_id>", methods=["DELETE"])
743
  def delete_job(job_id):
744
+ logger.info(f"Handling /delete_job/{job_id} request")
745
  user = verify_token(request)
746
  if not user:
747
+ logger.warning(f"Unauthorized access attempt to /delete_job/{job_id}")
748
  return jsonify({"error": "Unauthorized"}), 401
749
 
750
  job_ref = db.reference(f"jobs/{job_id}")
751
  job_data = job_ref.get()
752
+
753
+ if not job_data: # Fixed condition
754
+ logger.warning(f"Job {job_id} not found for deletion.")
755
  return jsonify({"error": "Job not found"}), 404
756
+
757
+ try:
758
+ job_ref.delete()
759
+ logger.info(f"Job {job_id} deleted successfully.")
760
+ return jsonify({"message": f"Job {job_id} deleted"}), 200
761
+ except Exception as e:
762
+ logger.error(f"Error deleting job {job_id}: {e}", exc_info=True)
763
+ return jsonify({"error": "Internal server error"}), 500
764
 
765
  @app.route("/jobs", methods=["GET"])
766
  def get_jobs():
767
+ logger.info("Handling /jobs request")
768
  user = verify_token(request)
769
  if not user:
770
+ logger.warning("Unauthorized access attempt to /jobs")
771
  return jsonify({"error": "Unauthorized"}), 401
772
 
773
+ try:
774
+ jobs_ref = db.reference("jobs")
775
+ jobs = jobs_ref.get()
776
+
777
+ if not jobs:
778
+ logger.debug("No jobs found in database.")
779
+ return jsonify({"jobs": []}), 200
780
+
781
+ # Convert to list format
782
+ jobs_list = []
783
+ for job_id, job_data in jobs.items():
784
+ job_data["id"] = job_id
785
+ jobs_list.append(job_data)
786
+
787
+ logger.debug(f"Retrieved {len(jobs_list)} jobs.")
788
+ return jsonify({"jobs": jobs_list}), 200
789
+ except Exception as e:
790
+ logger.error(f"Error retrieving jobs: {e}", exc_info=True)
791
+ return jsonify({"error": "Internal server error"}), 500
792
 
793
  @app.route("/job/<job_id>", methods=["GET"])
794
  def get_job(job_id):
795
+ logger.info(f"Handling /job/{job_id} request")
796
  user = verify_token(request)
797
  if not user:
798
+ logger.warning(f"Unauthorized access attempt to /job/{job_id}")
799
  return jsonify({"error": "Unauthorized"}), 401
800
 
801
+ try:
802
+ job_ref = db.reference(f"jobs/{job_id}")
803
+ job_data = job_ref.get()
804
+
805
+ if not job_data: # Fixed condition
806
+ logger.warning(f"Job {job_id} not found.")
807
+ return jsonify({"error": "Job not found"}), 404
808
+
809
+ job_data["id"] = job_id
810
+ logger.debug(f"Retrieved job {job_id}.")
811
+ return jsonify({"job": job_data}), 200
812
+ except Exception as e:
813
+ logger.error(f"Error retrieving job {job_id}: {e}", exc_info=True)
814
+ return jsonify({"error": "Internal server error"}), 500
815
 
816
  @app.route("/members", methods=["GET"])
817
  def get_members():
818
+ logger.info("Handling /members request")
819
  user = verify_token(request)
820
  if not user:
821
+ logger.warning("Unauthorized access attempt to /members")
822
  return jsonify({"error": "Unauthorized"}), 401
823
 
824
+ try:
825
+ members_ref = db.reference("members")
826
+ members = members_ref.get()
827
+
828
+ if not members:
829
+ logger.debug("No members found in database.")
830
+ return jsonify({"members": []}), 200
831
+
832
+ # Convert to list format
833
+ members_list = []
834
+ for member_id, member_data in members.items():
835
+ member_data["id"] = member_id
836
+ members_list.append(member_data)
837
+
838
+ logger.debug(f"Retrieved {len(members_list)} members.")
839
+ return jsonify({"members": members_list}), 200
840
+ except Exception as e:
841
+ logger.error(f"Error retrieving members: {e}", exc_info=True)
842
+ return jsonify({"error": "Internal server error"}), 500
843
 
844
  @app.route("/assign_members_to_job", methods=["POST"])
845
  def assign_members_to_job():
846
  """Bulk assign members to a job"""
847
+ logger.info("Handling /assign_members_to_job request")
848
  user = verify_token(request)
849
  if not user:
850
+ logger.warning("Unauthorized access attempt to /assign_members_to_job")
851
  return jsonify({"error": "Unauthorized"}), 401
852
 
853
  data = request.json
854
  job_id = data.get("job_id")
855
  member_ids = data.get("member_ids", [])
856
+ logger.debug(f"Assigning members to job {job_id} with data: {data}")
857
+
858
  if not job_id or not member_ids:
859
+ logger.warning("job_id and member_ids are required for member assignment.")
860
  return jsonify({"error": "job_id and member_ids are required"}), 400
861
 
862
  job_ref = db.reference(f"jobs/{job_id}")
863
  job_data = job_ref.get()
864
+
865
+ if not job_data: # Fixed condition
866
+ logger.warning(f"Job {job_id} not found for member assignment.")
867
  return jsonify({"error": "Job not found"}), 404
868
 
869
  # Get members
870
+ try:
871
+ members_ref = db.reference("members")
872
+ all_members = members_ref.get()
873
+ except Exception as e:
874
+ logger.error(f"Error retrieving members for assignment to job {job_id}: {e}", exc_info=True)
875
+ return jsonify({"error": "Internal server error"}), 500
876
+
877
  if not all_members:
878
+ logger.warning("No members found in database for assignment.")
879
  return jsonify({"error": "No members found"}), 404
880
 
881
  # Filter requested members (ensure at least 25)
 
887
  selected_members.append(member_data)
888
 
889
  if len(selected_members) < 25:
890
+ logger.warning(f"Insufficient members ({len(selected_members)}) assigned to job {job_id}. Minimum is 25.")
891
  return jsonify({"error": "At least 25 members must be assigned to the job"}), 400
892
 
893
  # Update job with assigned members
894
+ try:
895
+ job_ref.update({"assigned_members": selected_members})
896
+ logger.info(f"{len(selected_members)} members assigned to job {job_id}.")
897
+ return jsonify({
898
+ "message": f"{len(selected_members)} members assigned to job {job_id}",
899
+ "assigned_members": selected_members
900
+ }), 200
901
+ except Exception as e:
902
+ logger.error(f"Error updating assigned members for job {job_id}: {e}", exc_info=True)
903
+ return jsonify({"error": "Internal server error"}), 500
904
 
905
  @app.route("/update_job_settings/<job_id>", methods=["POST"])
906
  def update_job_settings(job_id):
907
  """Update job settings including rotation period"""
908
+ logger.info(f"Handling /update_job_settings/{job_id} request")
909
  user = verify_token(request)
910
  if not user:
911
+ logger.warning(f"Unauthorized access attempt to /update_job_settings/{job_id}")
912
  return jsonify({"error": "Unauthorized"}), 401
913
 
914
  data = request.json
915
  rotation_period = data.get("rotation_period") # In seconds
916
+ logger.debug(f"Updating settings for job {job_id} with data: {data}")
917
+
918
  if rotation_period is None:
919
+ logger.warning("rotation_period is required for job settings update.")
920
  return jsonify({"error": "rotation_period is required (in seconds)"}), 400
921
 
922
  job_ref = db.reference(f"jobs/{job_id}")
923
  job_data = job_ref.get()
924
+
925
+ if not job_data: # Fixed condition
926
+ logger.warning(f"Job {job_id} not found for settings update.")
927
  return jsonify({"error": "Job not found"}), 404
928
 
929
  # Update rotation period
930
+ try:
931
+ job_ref.update({"rotation_period": rotation_period})
932
+ logger.info(f"Job {job_id} rotation period updated to {rotation_period} seconds.")
933
+ return jsonify({
934
+ "message": f"Job {job_id} rotation period updated to {rotation_period} seconds",
935
+ "rotation_period": rotation_period
936
+ }), 200
937
+ except Exception as e:
938
+ logger.error(f"Error updating rotation period for job {job_id}: {e}", exc_info=True)
939
+ return jsonify({"error": "Internal server error"}), 500
940
 
941
  @app.route("/add_special_case/<job_id>", methods=["POST"])
942
  def add_special_case(job_id):
943
  """Add a special case assignment for a job"""
944
+ logger.info(f"Handling /add_special_case/{job_id} request")
945
  user = verify_token(request)
946
  if not user:
947
+ logger.warning(f"Unauthorized access attempt to /add_special_case/{job_id}")
948
  return jsonify({"error": "Unauthorized"}), 401
949
 
950
  data = request.json
 
953
  reason = data.get("reason", "Special Assignment")
954
  shift_number = data.get("shift_number")
955
  active = data.get("active", False)
956
+ logger.debug(f"Adding special case to job {job_id} with data: {data}")
957
+
958
  if not point_id or not member_id:
959
+ logger.warning("point_id and member_id are required for special case.")
960
  return jsonify({"error": "point_id and member_id are required"}), 400
961
 
962
  job_ref = db.reference(f"jobs/{job_id}")
963
  job_data = job_ref.get()
964
+
965
+ if not job_data: # Fixed condition
966
+ logger.warning(f"Job {job_id} not found for special case addition.")
967
  return jsonify({"error": "Job not found"}), 404
968
 
969
  # Verify point and member exist
970
  guarding_points = job_data.get("guarding_points", [])
971
  assigned_members = job_data.get("assigned_members", [])
972
+
973
  point_exists = any(p["id"] == point_id for p in guarding_points)
974
  member_exists = any(m["id"] == member_id for m in assigned_members)
975
+
976
  if not point_exists:
977
+ logger.warning(f"Guarding point {point_id} not found in job {job_id}.")
978
  return jsonify({"error": "Guarding point not found in job"}), 404
979
  if not member_exists:
980
+ logger.warning(f"Member {member_id} not assigned to job {job_id}.")
981
  return jsonify({"error": "Member not assigned to job"}), 404
982
 
983
  # Add special case
 
990
  "active": active,
991
  "created_at": datetime.datetime.now().isoformat()
992
  }
993
+
994
  special_cases = job_data.get("special_cases", [])
995
  special_cases.append(special_case)
996
+
997
+ try:
998
+ job_ref.update({"special_cases": special_cases})
999
+ logger.info(f"Special case added to job {job_id}.")
1000
+ return jsonify({
1001
+ "message": "Special case added",
1002
+ "special_case": special_case
1003
+ }), 200
1004
+ except Exception as e:
1005
+ logger.error(f"Error adding special case to job {job_id}: {e}", exc_info=True)
1006
+ return jsonify({"error": "Internal server error"}), 500
1007
 
1008
  @app.route("/get_roster/<job_id>", methods=["GET"])
1009
  def get_roster(job_id):
1010
  """Get formatted roster for a job"""
1011
+ logger.info(f"Handling /get_roster/{job_id} request")
1012
  user = verify_token(request)
1013
  if not user:
1014
+ logger.warning(f"Unauthorized access attempt to /get_roster/{job_id}")
1015
  return jsonify({"error": "Unauthorized"}), 401
1016
 
1017
+ try:
1018
+ job_ref = db.reference(f"jobs/{job_id}")
1019
+ job_data = job_ref.get()
1020
+
1021
+ if not job_data: # Fixed condition
1022
+ logger.warning(f"Job {job_id} not found for roster retrieval.")
1023
+ return jsonify({"error": "Job not found"}), 404
1024
+
1025
+ assignments = job_data.get("assignments", [])
1026
+
1027
+ # Format roster as table data
1028
+ roster_data = []
1029
+ for shift in assignments:
1030
+ shift_number = shift["shift_number"]
1031
+ assigned_at = shift["assigned_at"]
1032
 
1033
+ for assignment in shift["assignments"]:
1034
+ roster_data.append({
1035
+ "shift_number": shift_number,
1036
+ "assigned_at": assigned_at,
1037
+ "point_name": assignment["point"]["name"],
1038
+ "member_name": assignment["member"].get("name", "Unknown"),
1039
+ "is_special_case": assignment.get("is_special_case", False),
1040
+ "special_case_reason": assignment.get("special_case_reason", "")
1041
+ })
1042
+
1043
+ logger.debug(f"Retrieved roster for job {job_id} with {len(roster_data)} entries.")
1044
+ return jsonify({
1045
+ "roster": roster_data,
1046
+ "job_name": job_data.get("name", "Unknown Job"),
1047
+ "total_shifts": len(assignments)
1048
+ }), 200
1049
+ except Exception as e:
1050
+ logger.error(f"Error retrieving roster for job {job_id}: {e}", exc_info=True)
1051
+ return jsonify({"error": "Internal server error"}), 500
 
 
 
 
1052
 
1053
  # === Schedule periodic guard rotation ===
1054
  # Run every 5 minutes to check for rotations
1055
+ try:
1056
+ scheduler.add_job(
1057
+ func=check_and_rotate_guards,
1058
+ trigger="interval",
1059
+ minutes=5,
1060
+ id="guard_rotation_job"
1061
+ )
1062
+ logger.info("Scheduled periodic guard rotation check job.")
1063
+ except Exception as e:
1064
+ logger.error(f"Failed to schedule periodic guard rotation check: {e}", exc_info=True)
1065
+
1066
  # === Run Server ===
1067
  if __name__ == "__main__":
1068
+ logger.info("Starting Flask application...")
1069
+ app.run(debug=True, host="0.0.0.0", port=int(os.getenv("PORT", 7860)))