rairo commited on
Commit
434765e
·
verified ·
1 Parent(s): a6d4695

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +103 -284
main.py CHANGED
@@ -1,7 +1,7 @@
1
  import os
2
  import json
3
  import uuid
4
- import datetime # <-- This is the main module
5
  import pandas as pd
6
  from flask import Flask, request, jsonify
7
  from flask_cors import CORS
@@ -14,33 +14,21 @@ import logging
14
  from logging.handlers import RotatingFileHandler
15
  import time
16
  import random
17
- from datetime import datetime as dt_class # Using an alias for the class to avoid confusion
18
 
19
  # === Logging Configuration ===
20
- # Configure logging
21
  if not os.path.exists('logs'):
22
  os.mkdir('logs')
23
-
24
- # Create a custom logger
25
  logger = logging.getLogger('guards_api')
26
- logger.setLevel(logging.DEBUG) # Capture DEBUG and above
27
-
28
- # Create handlers
29
  file_handler = RotatingFileHandler('logs/guards_api.log', maxBytes=10240, backupCount=10)
30
- file_handler.setFormatter(logging.Formatter(
31
- '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
32
- ))
33
- file_handler.setLevel(logging.INFO) # File logs INFO and above
34
-
35
  console_handler = logging.StreamHandler()
36
  console_handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
37
- console_handler.setLevel(logging.DEBUG) # Console logs DEBUG and above
38
-
39
- # Add handlers to the logger
40
  logger.addHandler(file_handler)
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)
@@ -51,58 +39,30 @@ RESEND_API_KEY = os.getenv("RESEND_API_KEY")
51
  FIREBASE_CRED_JSON = json.loads(os.getenv("FIREBASE"))
52
  FIREBASE_DB_URL = os.getenv("Firebase_DB")
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:
60
  cred = credentials.Certificate(FIREBASE_CRED_JSON)
61
- firebase_admin.initialize_app(cred, {
62
- "databaseURL": FIREBASE_DB_URL
63
- })
64
  logger.info("Firebase Admin initialized successfully.")
65
  except Exception as e:
66
- logger.error(f"Failed to initialize Firebase Admin: {e}")
67
  raise
68
 
69
- admin_emails = [
70
- "rairorr@gmail.com",
71
- "nharingosheperd@gmail.com"
72
- ]
73
-
74
- for email in admin_emails:
75
- key = email.replace("@", "_").replace(".", "_")
76
- try:
77
- db.reference(f"admins/{key}").set({
78
- "email": email,
79
- "is_admin": True
80
- })
81
- logger.info(f"✅ Admin {email} added/updated in database.")
82
- except Exception as e:
83
- logger.error(f"Failed to add admin {email} to database: {e}")
84
-
85
- # === Flask App Setup ===
86
  app = Flask(__name__)
87
  CORS(app)
88
- logger.info("Flask app initialized.")
89
-
90
- # === APScheduler Setup ===
91
- class Config(object):
92
- SCHEDULER_API_ENABLED = True
93
-
94
- app.config.from_object(Config())
95
-
96
  scheduler = APScheduler()
97
  scheduler.init_app(app)
98
  scheduler.start()
99
- logger.info("APScheduler initialized and started.")
100
-
101
- # === Resend Init ===
102
  Emails.api_key = RESEND_API_KEY
103
- logger.info("Resend API key configured.")
 
104
 
105
  def send_email(to, subject, html):
 
106
  try:
107
  response = Emails.send({
108
  "from": "Admin <admin@resend.dev>",
@@ -111,20 +71,20 @@ def send_email(to, subject, html):
111
  "html": html
112
  })
113
  if response is None:
114
- logger.warning(f"Email API returned None for recipient {to}. Check Resend dashboard for suppression or other issues.")
115
  return None
116
- logger.info(f"Email sent successfully to {to}. Response: {response}")
117
  return response
118
- except Exception as e:
119
- logger.error(f"Email error to {to}: {e}")
 
120
  return None
121
 
122
  def send_rotation_notification(job_id, shift_record):
123
- """Send email notification for rotation to all admins with rate-limiting prevention"""
124
  try:
125
  job_ref = db.reference(f"jobs/{job_id}")
126
  job_data = job_ref.get()
127
-
128
  if not job_data:
129
  logger.warning(f"Job {job_id} not found for rotation notification.")
130
  return
@@ -142,86 +102,40 @@ def send_rotation_notification(job_id, shift_record):
142
  html_content = f"""
143
  <h2>Guard Rotation Notification</h2>
144
  <p><strong>Job:</strong> {job_name}</p>
145
- <p><strong>Job ID:</strong> {job_id}</p>
146
  <p><strong>Shift Number:</strong> {shift_number}</p>
147
  <p><strong>Rotation Time:</strong> {human_readable_time}</p>
148
-
149
  <h3>Assignments:</h3>
150
  <table border="1" style="border-collapse: collapse; width: 100%;">
151
- <thead>
152
- <tr>
153
- <th>Guarding Point</th>
154
- <th>Assigned Members</th>
155
- <th>Special Case</th>
156
- </tr>
157
- </thead>
158
  <tbody>
159
  """
160
-
161
  for assignment in shift_record.get("assignments", []):
162
  point_name = assignment.get("point", {}).get("name", "Unknown Point")
163
- assigned_members = assignment.get("members", [])
164
- member_names = [member.get("name", "Unknown Member") for member in assigned_members]
165
  members_html = "<br>".join(member_names) if member_names else "None"
166
  is_special = "Yes" if assignment.get("is_special_case", False) else "No"
167
-
168
- html_content += f"""
169
- <tr>
170
- <td>{point_name}</td>
171
- <td>{members_html}</td>
172
- <td>{is_special}</td>
173
- </tr>
174
- """
175
-
176
- html_content += """
177
- </tbody>
178
- </table>
179
- <p><em>This is an automated notification from the Guard Rotation System.</em></p>
180
- """
181
 
182
  subject = f"Guard Rotation - {job_name} (Shift {shift_number})"
183
-
184
  for admin_email in ADMIN_EMAILS:
185
  logger.info(f"Sending rotation notification to {admin_email}...")
186
  send_email(admin_email, subject, html_content)
187
  time.sleep(1)
188
-
189
  except Exception as e:
190
- logger.error(f"Error sending rotation notification for job {job_id}: {e}")
191
 
192
 
193
- # === Auth Middleware ===
194
  def verify_token(req):
195
- auth_header = req.headers.get("Authorization")
196
- user_email = req.headers.get("X-User-Email")
197
-
198
- if not auth_header:
199
- print("Authorization header missing.")
200
- return None
201
-
202
- if not user_email:
203
- logging.info("X-User-Email header missing from request.")
204
- return None
205
-
206
- if user_email not in ADMIN_EMAILS:
207
- logging.info(f"Email {user_email} from X-User-Email header not found in ADMIN_EMAILS list.")
208
- return None
209
-
210
  try:
211
- if auth_header.startswith("Bearer "):
212
- token = auth_header[7:].strip()
213
- else:
214
- token = auth_header.strip()
215
-
216
- decoded = firebase_auth.verify_id_token(token)
217
- logging.info(f"User with email {user_email} (UID: {decoded.get('uid')}) is authorized as admin (email in ADMIN_EMAILS).")
218
- return decoded
219
-
220
- except Exception as e:
221
- print(f"Unexpected error during token verification: {e}")
222
  return None
223
 
224
- # === Admin Setup ===
225
  def setup_admins():
226
  ref = db.reference("admins")
227
  for email in ADMIN_EMAILS:
@@ -229,20 +143,15 @@ def setup_admins():
229
  ref.child(uid).set({"email": email, "is_admin": True})
230
  setup_admins()
231
 
232
- # === Core Functions ===
233
  def assign_roster(job_id):
234
- """Assign roster for a job - implements the multi-guard rotation algorithm"""
235
  try:
236
  logger.info(f"Starting roster assignment for job {job_id}")
237
  job_ref = db.reference(f"jobs/{job_id}")
238
  job_data = job_ref.get()
239
 
240
- if not job_data:
241
- logger.error(f"Job {job_id} not found")
242
- return
243
-
244
- if job_data.get("status") != "active":
245
- logger.info(f"Job {job_id} is not active (status: {job_data.get('status')}), skipping assignment.")
246
  return
247
 
248
  guarding_points = job_data.get("guarding_points", [])
@@ -254,24 +163,29 @@ def assign_roster(job_id):
254
 
255
  shift_number = len(job_data.get("assignments", [])) + 1
256
  shift_assignments = []
257
- available_for_shift_members = random.sample(assigned_members, len(assigned_members))
258
 
259
- total_guards_required = sum(point.get('guards', 1) for point in guarding_points)
260
- if len(available_for_shift_members) < total_guards_required:
261
- logger.error(f"Not enough members ({len(available_for_shift_members)}) to fill all points ({total_guards_required}) for job {job_id}. Aborting.")
262
  return
263
 
264
- logger.info(f"Applying standard rotation for job {job_id}, shift {shift_number}")
265
 
266
  for point in guarding_points:
267
  guards_for_point = []
268
- num_guards_required = point.get('guards', 1)
 
 
 
 
 
269
 
270
  for _ in range(num_guards_required):
271
- if not available_for_shift_members:
272
  logger.warning(f"Ran out of available members while assigning to point {point.get('name')}")
273
  break
274
- selected_member = available_for_shift_members.pop(0)
275
  guards_for_point.append(selected_member)
276
 
277
  if guards_for_point:
@@ -288,10 +202,8 @@ def assign_roster(job_id):
288
  "assignments": shift_assignments,
289
  "shift_id": str(uuid.uuid4())
290
  }
291
-
292
  current_assignments = job_data.get("assignments", [])
293
  current_assignments.append(shift_record)
294
-
295
  job_ref.update({
296
  "assignments": current_assignments,
297
  "last_updated": dt_class.now().isoformat()
@@ -301,117 +213,37 @@ def assign_roster(job_id):
301
  send_rotation_notification(job_id, shift_record)
302
 
303
  rotation_period = job_data.get("rotation_period", 28800)
304
- # --- FIX #2 HERE ---
305
  next_run_time = dt_class.now() + datetime.timedelta(seconds=rotation_period)
306
 
307
  scheduler.add_job(
308
- func=assign_roster,
309
- trigger="date",
310
- run_date=next_run_time,
311
- args=[job_id],
312
- id=f"rotate_{job_id}_{uuid.uuid4().hex[:8]}"
313
  )
314
  logger.info(f"Scheduled next rotation for job {job_id} in {rotation_period} seconds.")
315
  else:
316
  logger.error(f"No assignments were created for job {job_id}, shift {shift_number}.")
317
 
318
- except Exception as e:
319
- logger.error(f"Error in assign_roster for job {job_id}: {e}", exc_info=True)
320
-
321
-
322
- def check_and_rotate_guards():
323
- """Periodic function to check jobs and rotate guards"""
324
- try:
325
- logger.debug("🔍 Checking for guard rotations...")
326
- jobs_ref = db.reference("jobs")
327
- jobs = jobs_ref.get()
328
-
329
- if not jobs:
330
- return
331
-
332
- for job_id, job_data in jobs.items():
333
- if job_data.get("status") != "active":
334
- continue
335
-
336
- assignments = job_data.get("assignments", [])
337
- if not assignments:
338
- continue
339
-
340
- last_assignment = assignments[-1]
341
- try:
342
- assigned_at_str = last_assignment.get("assigned_at", "")
343
- assigned_at = dt_class.fromisoformat(assigned_at_str.replace("Z", "+00:00"))
344
- except (ValueError, TypeError):
345
- logger.warning(f"Could not parse assigned_at time for job {job_id}: {last_assignment.get('assigned_at')}")
346
- continue
347
-
348
- now = dt_class.now(assigned_at.tzinfo)
349
- rotation_period = job_data.get("rotation_period", 28800)
350
- time_since_last_assignment = (now - assigned_at).total_seconds()
351
-
352
- if time_since_last_assignment > rotation_period:
353
- logger.info(f"Rotating guards for job {job_id} based on interval check.")
354
- assign_roster(job_id)
355
-
356
- except Exception as e:
357
- logger.error(f"Error in check_and_rotate_guards: {e}", exc_info=True)
358
 
359
  # === Routes ===
360
  @app.before_request
361
  def log_request_info():
362
  logger.info(f"Incoming request: {request.method} {request.url}")
363
-
364
- @app.route("/", methods=["GET"])
365
- def home():
366
- return jsonify({"message": "Rairo Guards API is running.", "status": "ok"}), 200
367
 
368
  @app.after_request
369
  def log_response_info(response):
370
  logger.info(f"Outgoing response: {response.status} for {request.method} {request.url}")
371
  return response
372
 
373
- @app.route("/start_job/<job_id>", methods=["POST"])
374
- def start_job(job_id):
375
- logger.info(f"Handling /start_job/{job_id} request")
376
- user = verify_token(request)
377
- if not user:
378
- return jsonify({"error": "Unauthorized"}), 401
379
-
380
- job_ref = db.reference(f"jobs/{job_id}")
381
- if not job_ref.get():
382
- return jsonify({"error": "Job not found"}), 404
383
-
384
- try:
385
- job_ref.update({
386
- "status": "active",
387
- "started_at": dt_class.now().isoformat()
388
- })
389
- logger.info(f"Job {job_id} status updated to active.")
390
- except Exception as e:
391
- logger.error(f"Error updating job {job_id} to active: {e}", exc_info=True)
392
- return jsonify({"error": "Internal server error"}), 500
393
-
394
- try:
395
- scheduler.add_job(
396
- func=assign_roster,
397
- trigger="date",
398
- # --- FIX #1 HERE ---
399
- run_date=dt_class.now() + datetime.timedelta(seconds=5),
400
- args=[job_id],
401
- id=f"start_{job_id}_{uuid.uuid4().hex[:8]}"
402
- )
403
- logger.info(f"First assignment for job {job_id} scheduled.")
404
- return jsonify({"message": f"Job {job_id} started"}), 202
405
- except Exception as e:
406
- # This is where the original error was caught
407
- logger.error(f"Error scheduling first assignment for job {job_id}: {e}", exc_info=True)
408
- return jsonify({"error": "Failed to start job assignments"}), 500
409
 
410
- # (Keep all your other routes: /upload_members, /add_member, /create_job, etc. They are unchanged and correct)
411
  @app.route("/upload_members", methods=["POST"])
412
  def upload_members():
413
- user = verify_token(request)
414
- if not user: return jsonify({"error": "Unauthorized"}), 401
415
  file = request.files.get("file")
416
  if not file: return jsonify({"error": "No file uploaded"}), 400
417
  try:
@@ -426,8 +258,7 @@ def upload_members():
426
 
427
  @app.route("/add_member", methods=["POST"])
428
  def add_member():
429
- user = verify_token(request)
430
- if not user: return jsonify({"error": "Unauthorized"}), 401
431
  data = request.json
432
  member_id = str(uuid.uuid4())
433
  try:
@@ -439,8 +270,7 @@ def add_member():
439
 
440
  @app.route("/update_member/<member_id>", methods=["POST"])
441
  def update_member(member_id):
442
- user = verify_token(request)
443
- if not user: return jsonify({"error": "Unauthorized"}), 401
444
  data = request.json
445
  if not data: return jsonify({"error": "Request body cannot be empty"}), 400
446
  member_ref = db.reference(f"members/{member_id}")
@@ -454,8 +284,7 @@ def update_member(member_id):
454
 
455
  @app.route("/delete_member/<member_id>", methods=["DELETE"])
456
  def delete_member(member_id):
457
- user = verify_token(request)
458
- if not user: return jsonify({"error": "Unauthorized"}), 401
459
  member_ref = db.reference(f"members/{member_id}")
460
  if not member_ref.get(): return jsonify({"error": "Member not found"}), 404
461
  try:
@@ -467,26 +296,25 @@ def delete_member(member_id):
467
 
468
  @app.route("/create_job", methods=["POST"])
469
  def create_job():
470
- user = verify_token(request)
471
- if not user: return jsonify({"error": "Unauthorized"}), 401
472
  data = request.json
473
  job_id = str(uuid.uuid4())
474
  guarding_points = data.get("guarding_points", [])
475
  if not 5 <= len(guarding_points) <= 15:
476
  return jsonify({"error": "Guarding points must be between 5 and 15"}), 400
477
- for i, point in enumerate(guarding_points): point.setdefault("id", f"point_{i+1}")
478
- job_data = {**data, "guarding_points": guarding_points, "created_at": dt_class.now().isoformat(), "status": "created", "assignments": [], "rotation_history": {}, "rotation_period": data.get("rotation_period", 28800)}
 
479
  try:
480
  db.reference(f"jobs/{job_id}").set(job_data)
481
  return jsonify({"message": "Job created", "job_id": job_id}), 200
482
  except Exception as e:
483
- logger.error(f"Error creating job: {e}", exc_info=True)
484
  return jsonify({"error": "Internal server error"}), 500
485
 
486
  @app.route("/update_job/<job_id>", methods=["POST"])
487
  def update_job(job_id):
488
- user = verify_token(request)
489
- if not user: return jsonify({"error": "Unauthorized"}), 401
490
  data = request.json
491
  if not data: return jsonify({"error": "Request body cannot be empty"}), 400
492
  job_ref = db.reference(f"jobs/{job_id}")
@@ -501,8 +329,7 @@ def update_job(job_id):
501
 
502
  @app.route("/schedule_job/<job_id>", methods=["POST"])
503
  def schedule_job(job_id):
504
- user = verify_token(request)
505
- if not user: return jsonify({"error": "Unauthorized"}), 401
506
  data = request.json
507
  start_time_iso = data.get("start_time")
508
  if not start_time_iso: return jsonify({"error": "start_time is required"}), 400
@@ -510,12 +337,11 @@ def schedule_job(job_id):
510
  if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
511
  try:
512
  start_time = dt_class.fromisoformat(start_time_iso.replace("Z", "+00:00"))
513
- except ValueError:
514
- return jsonify({"error": "Invalid start_time format."}), 400
515
- try:
516
  job_ref.update({"scheduled_start_time": start_time_iso, "status": "scheduled"})
517
- scheduler.add_job(func=start_scheduled_job, trigger="date", run_date=start_time, args=[job_id], id=f"start_{job_id}_{uuid.uuid4().hex[:8]}")
518
  return jsonify({"message": f"Job {job_id} scheduled to start at {start_time_iso}"}), 200
 
 
519
  except Exception as e:
520
  logger.error(f"Error scheduling job {job_id}: {e}", exc_info=True)
521
  return jsonify({"error": "Failed to schedule job"}), 500
@@ -524,16 +350,34 @@ def start_scheduled_job(job_id):
524
  try:
525
  logger.info(f"Executing scheduled start for job {job_id}")
526
  job_ref = db.reference(f"jobs/{job_id}")
527
- if not job_ref.get(): return
528
- job_ref.update({"status": "active", "started_at": dt_class.now().isoformat()})
529
- assign_roster(job_id)
530
  except Exception as e:
531
  logger.error(f"Error in start_scheduled_job for {job_id}: {e}", exc_info=True)
532
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
533
  @app.route("/pause_job/<job_id>", methods=["POST"])
534
  def pause_job(job_id):
535
- user = verify_token(request)
536
- if not user: return jsonify({"error": "Unauthorized"}), 401
537
  job_ref = db.reference(f"jobs/{job_id}")
538
  if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
539
  try:
@@ -545,8 +389,7 @@ def pause_job(job_id):
545
 
546
  @app.route("/delete_job/<job_id>", methods=["DELETE"])
547
  def delete_job(job_id):
548
- user = verify_token(request)
549
- if not user: return jsonify({"error": "Unauthorized"}), 401
550
  job_ref = db.reference(f"jobs/{job_id}")
551
  if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
552
  try:
@@ -558,11 +401,9 @@ def delete_job(job_id):
558
 
559
  @app.route("/jobs", methods=["GET"])
560
  def get_jobs():
561
- user = verify_token(request)
562
- if not user: return jsonify({"error": "Unauthorized"}), 401
563
  try:
564
- jobs = db.reference("jobs").get()
565
- if not jobs: return jsonify({"jobs": []}), 200
566
  jobs_list = [{"id": j_id, **j_data} for j_id, j_data in jobs.items()]
567
  return jsonify({"jobs": jobs_list}), 200
568
  except Exception as e:
@@ -571,8 +412,7 @@ def get_jobs():
571
 
572
  @app.route("/job/<job_id>", methods=["GET"])
573
  def get_job(job_id):
574
- user = verify_token(request)
575
- if not user: return jsonify({"error": "Unauthorized"}), 401
576
  try:
577
  job_data = db.reference(f"jobs/{job_id}").get()
578
  if not job_data: return jsonify({"error": "Job not found"}), 404
@@ -583,11 +423,9 @@ def get_job(job_id):
583
 
584
  @app.route("/members", methods=["GET"])
585
  def get_members():
586
- user = verify_token(request)
587
- if not user: return jsonify({"error": "Unauthorized"}), 401
588
  try:
589
- members = db.reference("members").get()
590
- if not members: return jsonify({"members": []}), 200
591
  members_list = [{"id": m_id, **m_data} for m_id, m_data in members.items()]
592
  return jsonify({"members": members_list}), 200
593
  except Exception as e:
@@ -596,17 +434,14 @@ def get_members():
596
 
597
  @app.route("/assign_members_to_job", methods=["POST"])
598
  def assign_members_to_job():
599
- user = verify_token(request)
600
- if not user: return jsonify({"error": "Unauthorized"}), 401
601
  data = request.json
602
- job_id = data.get("job_id")
603
- member_ids = data.get("member_ids", [])
604
  if not job_id or not member_ids: return jsonify({"error": "job_id and member_ids are required"}), 400
605
  job_ref = db.reference(f"jobs/{job_id}")
606
  if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
607
  try:
608
- all_members = db.reference("members").get()
609
- if not all_members: return jsonify({"error": "No members found"}), 404
610
  selected_members = [{"id": m_id, **m_data} for m_id, m_data in all_members.items() if m_id in member_ids]
611
  if len(selected_members) < 25: return jsonify({"error": "At least 25 members must be assigned"}), 400
612
  job_ref.update({"assigned_members": selected_members})
@@ -617,8 +452,7 @@ def assign_members_to_job():
617
 
618
  @app.route("/update_job_settings/<job_id>", methods=["POST"])
619
  def update_job_settings(job_id):
620
- user = verify_token(request)
621
- if not user: return jsonify({"error": "Unauthorized"}), 401
622
  data = request.json
623
  rotation_period = data.get("rotation_period")
624
  if rotation_period is None: return jsonify({"error": "rotation_period is required"}), 400
@@ -633,8 +467,7 @@ def update_job_settings(job_id):
633
 
634
  @app.route("/add_special_case/<job_id>", methods=["POST"])
635
  def add_special_case(job_id):
636
- user = verify_token(request)
637
- if not user: return jsonify({"error": "Unauthorized"}), 401
638
  data = request.json
639
  point_id, member_id = data.get("point_id"), data.get("member_id")
640
  if not point_id or not member_id: return jsonify({"error": "point_id and member_id are required"}), 400
@@ -657,8 +490,7 @@ def add_special_case(job_id):
657
 
658
  @app.route("/get_roster/<job_id>", methods=["GET"])
659
  def get_roster(job_id):
660
- user = verify_token(request)
661
- if not user: return jsonify({"error": "Unauthorized"}), 401
662
  try:
663
  job_data = db.reference(f"jobs/{job_id}").get()
664
  if not job_data: return jsonify({"error": "Job not found"}), 404
@@ -679,19 +511,6 @@ def get_roster(job_id):
679
  logger.error(f"Error getting roster for {job_id}: {e}", exc_info=True)
680
  return jsonify({"error": "Internal server error"}), 500
681
 
682
- # === Schedule periodic guard rotation ===
683
- try:
684
- scheduler.add_job(
685
- func=check_and_rotate_guards,
686
- trigger="interval",
687
- minutes=5,
688
- id="guard_rotation_check_job",
689
- replace_existing=True
690
- )
691
- logger.info("Scheduled periodic guard rotation check job.")
692
- except Exception as e:
693
- logger.error(f"Failed to schedule periodic guard rotation check: {e}", exc_info=True)
694
-
695
  # === Run Server ===
696
  if __name__ == "__main__":
697
  logger.info("Starting Flask application...")
 
1
  import os
2
  import json
3
  import uuid
4
+ import datetime
5
  import pandas as pd
6
  from flask import Flask, request, jsonify
7
  from flask_cors import CORS
 
14
  from logging.handlers import RotatingFileHandler
15
  import time
16
  import random
17
+ from datetime import datetime as dt_class
18
 
19
  # === Logging Configuration ===
 
20
  if not os.path.exists('logs'):
21
  os.mkdir('logs')
 
 
22
  logger = logging.getLogger('guards_api')
23
+ logger.setLevel(logging.DEBUG)
 
 
24
  file_handler = RotatingFileHandler('logs/guards_api.log', maxBytes=10240, backupCount=10)
25
+ file_handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'))
26
+ file_handler.setLevel(logging.INFO)
 
 
 
27
  console_handler = logging.StreamHandler()
28
  console_handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
29
+ console_handler.setLevel(logging.DEBUG)
 
 
30
  logger.addHandler(file_handler)
31
  logger.addHandler(console_handler)
 
 
32
  root_logger = logging.getLogger()
33
  root_logger.setLevel(logging.INFO)
34
  root_logger.addHandler(file_handler)
 
39
  FIREBASE_CRED_JSON = json.loads(os.getenv("FIREBASE"))
40
  FIREBASE_DB_URL = os.getenv("Firebase_DB")
41
  ADMIN_EMAILS = ["rairorr@gmail.com", "nharingosheperd@gmail.com"]
 
42
  logger.info("Environment variables loaded.")
 
43
 
44
  # === Firebase Init ===
45
  try:
46
  cred = credentials.Certificate(FIREBASE_CRED_JSON)
47
+ firebase_admin.initialize_app(cred, {"databaseURL": FIREBASE_DB_URL})
 
 
48
  logger.info("Firebase Admin initialized successfully.")
49
  except Exception as e:
50
+ logger.error(f"Failed to initialize Firebase Admin: {e}", exc_info=True)
51
  raise
52
 
53
+ # === Flask App & Scheduler & Resend Setup ===
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  app = Flask(__name__)
55
  CORS(app)
56
+ app.config.from_object(type('Config', (object,), {'SCHEDULER_API_ENABLED': True})())
 
 
 
 
 
 
 
57
  scheduler = APScheduler()
58
  scheduler.init_app(app)
59
  scheduler.start()
 
 
 
60
  Emails.api_key = RESEND_API_KEY
61
+ logger.info("Flask, APScheduler, and Resend initialized.")
62
+
63
 
64
  def send_email(to, subject, html):
65
+ """Sends an email and provides detailed error logging."""
66
  try:
67
  response = Emails.send({
68
  "from": "Admin <admin@resend.dev>",
 
71
  "html": html
72
  })
73
  if response is None:
74
+ logger.warning(f"Email API returned None for recipient {to}. Check Resend dashboard for suppression list.")
75
  return None
76
+ logger.info(f"Email sent successfully to {to}. Response ID: {response.get('id')}")
77
  return response
78
+ except Exception:
79
+ # IMPROVED LOGGING: This will now log the full traceback to your log file
80
+ logger.error(f"Caught exception while sending email to {to}", exc_info=True)
81
  return None
82
 
83
  def send_rotation_notification(job_id, shift_record):
84
+ """Sends email notification for rotation to all admins with rate-limiting prevention."""
85
  try:
86
  job_ref = db.reference(f"jobs/{job_id}")
87
  job_data = job_ref.get()
 
88
  if not job_data:
89
  logger.warning(f"Job {job_id} not found for rotation notification.")
90
  return
 
102
  html_content = f"""
103
  <h2>Guard Rotation Notification</h2>
104
  <p><strong>Job:</strong> {job_name}</p>
 
105
  <p><strong>Shift Number:</strong> {shift_number}</p>
106
  <p><strong>Rotation Time:</strong> {human_readable_time}</p>
 
107
  <h3>Assignments:</h3>
108
  <table border="1" style="border-collapse: collapse; width: 100%;">
109
+ <thead><tr><th>Guarding Point</th><th>Assigned Members</th><th>Special Case</th></tr></thead>
 
 
 
 
 
 
110
  <tbody>
111
  """
 
112
  for assignment in shift_record.get("assignments", []):
113
  point_name = assignment.get("point", {}).get("name", "Unknown Point")
114
+ member_names = [m.get("name", "Unknown Member") for m in assignment.get("members", [])]
 
115
  members_html = "<br>".join(member_names) if member_names else "None"
116
  is_special = "Yes" if assignment.get("is_special_case", False) else "No"
117
+ html_content += f"<tr><td>{point_name}</td><td>{members_html}</td><td>{is_special}</td></tr>"
118
+ html_content += "</tbody></table><p><em>This is an automated notification from the Guard Rotation System.</em></p>"
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
  subject = f"Guard Rotation - {job_name} (Shift {shift_number})"
 
121
  for admin_email in ADMIN_EMAILS:
122
  logger.info(f"Sending rotation notification to {admin_email}...")
123
  send_email(admin_email, subject, html_content)
124
  time.sleep(1)
 
125
  except Exception as e:
126
+ logger.error(f"Error building rotation notification for job {job_id}", exc_info=True)
127
 
128
 
 
129
  def verify_token(req):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  try:
131
+ auth_header = req.headers.get("Authorization", "").split("Bearer ")[-1]
132
+ user_email = req.headers.get("X-User-Email")
133
+ if not auth_header or not user_email or user_email not in ADMIN_EMAILS:
134
+ return None
135
+ return firebase_auth.verify_id_token(auth_header)
136
+ except Exception:
 
 
 
 
 
137
  return None
138
 
 
139
  def setup_admins():
140
  ref = db.reference("admins")
141
  for email in ADMIN_EMAILS:
 
143
  ref.child(uid).set({"email": email, "is_admin": True})
144
  setup_admins()
145
 
 
146
  def assign_roster(job_id):
147
+ """Assigns roster for a job, correctly handling multiple guards per point."""
148
  try:
149
  logger.info(f"Starting roster assignment for job {job_id}")
150
  job_ref = db.reference(f"jobs/{job_id}")
151
  job_data = job_ref.get()
152
 
153
+ if not job_data or job_data.get("status") != "active":
154
+ logger.info(f"Job {job_id} not found or not active. Skipping assignment.")
 
 
 
 
155
  return
156
 
157
  guarding_points = job_data.get("guarding_points", [])
 
163
 
164
  shift_number = len(job_data.get("assignments", [])) + 1
165
  shift_assignments = []
166
+ available_for_shift = random.sample(assigned_members, len(assigned_members))
167
 
168
+ total_guards_required = sum(int(point.get('guards', 1)) for point in guarding_points)
169
+ if len(available_for_shift) < total_guards_required:
170
+ logger.error(f"INSUFFICIENT MEMBERS: Job {job_id} requires {total_guards_required} guards but only {len(available_for_shift)} are available. Aborting shift.")
171
  return
172
 
173
+ logger.info(f"Assigning shift {shift_number} for job {job_id}. Required guards: {total_guards_required}.")
174
 
175
  for point in guarding_points:
176
  guards_for_point = []
177
+ try:
178
+ num_guards_required = int(point.get('guards', 1))
179
+ except (ValueError, TypeError):
180
+ num_guards_required = 1
181
+
182
+ logger.debug(f"Assigning to point '{point.get('name')}'. Need {num_guards_required} guard(s).")
183
 
184
  for _ in range(num_guards_required):
185
+ if not available_for_shift:
186
  logger.warning(f"Ran out of available members while assigning to point {point.get('name')}")
187
  break
188
+ selected_member = available_for_shift.pop(0)
189
  guards_for_point.append(selected_member)
190
 
191
  if guards_for_point:
 
202
  "assignments": shift_assignments,
203
  "shift_id": str(uuid.uuid4())
204
  }
 
205
  current_assignments = job_data.get("assignments", [])
206
  current_assignments.append(shift_record)
 
207
  job_ref.update({
208
  "assignments": current_assignments,
209
  "last_updated": dt_class.now().isoformat()
 
213
  send_rotation_notification(job_id, shift_record)
214
 
215
  rotation_period = job_data.get("rotation_period", 28800)
 
216
  next_run_time = dt_class.now() + datetime.timedelta(seconds=rotation_period)
217
 
218
  scheduler.add_job(
219
+ func=assign_roster, trigger="date", run_date=next_run_time,
220
+ args=[job_id], id=f"rotate_{job_id}_{uuid.uuid4().hex[:8]}",
221
+ replace_existing=True
 
 
222
  )
223
  logger.info(f"Scheduled next rotation for job {job_id} in {rotation_period} seconds.")
224
  else:
225
  logger.error(f"No assignments were created for job {job_id}, shift {shift_number}.")
226
 
227
+ except Exception:
228
+ logger.error(f"CRITICAL ERROR in assign_roster for job {job_id}", exc_info=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
230
  # === Routes ===
231
  @app.before_request
232
  def log_request_info():
233
  logger.info(f"Incoming request: {request.method} {request.url}")
 
 
 
 
234
 
235
  @app.after_request
236
  def log_response_info(response):
237
  logger.info(f"Outgoing response: {response.status} for {request.method} {request.url}")
238
  return response
239
 
240
+ @app.route("/", methods=["GET"])
241
+ def home():
242
+ return jsonify({"message": "Rairo Guards API is running.", "status": "ok"}), 200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
 
 
244
  @app.route("/upload_members", methods=["POST"])
245
  def upload_members():
246
+ if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
 
247
  file = request.files.get("file")
248
  if not file: return jsonify({"error": "No file uploaded"}), 400
249
  try:
 
258
 
259
  @app.route("/add_member", methods=["POST"])
260
  def add_member():
261
+ if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
 
262
  data = request.json
263
  member_id = str(uuid.uuid4())
264
  try:
 
270
 
271
  @app.route("/update_member/<member_id>", methods=["POST"])
272
  def update_member(member_id):
273
+ if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
 
274
  data = request.json
275
  if not data: return jsonify({"error": "Request body cannot be empty"}), 400
276
  member_ref = db.reference(f"members/{member_id}")
 
284
 
285
  @app.route("/delete_member/<member_id>", methods=["DELETE"])
286
  def delete_member(member_id):
287
+ if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
 
288
  member_ref = db.reference(f"members/{member_id}")
289
  if not member_ref.get(): return jsonify({"error": "Member not found"}), 404
290
  try:
 
296
 
297
  @app.route("/create_job", methods=["POST"])
298
  def create_job():
299
+ if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
 
300
  data = request.json
301
  job_id = str(uuid.uuid4())
302
  guarding_points = data.get("guarding_points", [])
303
  if not 5 <= len(guarding_points) <= 15:
304
  return jsonify({"error": "Guarding points must be between 5 and 15"}), 400
305
+ for i, point in enumerate(guarding_points):
306
+ point.setdefault("id", f"point_{i+1}")
307
+ job_data = {**data, "guarding_points": guarding_points, "created_at": dt_class.now().isoformat(), "status": "created", "assignments": []}
308
  try:
309
  db.reference(f"jobs/{job_id}").set(job_data)
310
  return jsonify({"message": "Job created", "job_id": job_id}), 200
311
  except Exception as e:
312
+ logger.error("Error creating job: {e}", exc_info=True)
313
  return jsonify({"error": "Internal server error"}), 500
314
 
315
  @app.route("/update_job/<job_id>", methods=["POST"])
316
  def update_job(job_id):
317
+ if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
 
318
  data = request.json
319
  if not data: return jsonify({"error": "Request body cannot be empty"}), 400
320
  job_ref = db.reference(f"jobs/{job_id}")
 
329
 
330
  @app.route("/schedule_job/<job_id>", methods=["POST"])
331
  def schedule_job(job_id):
332
+ if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
 
333
  data = request.json
334
  start_time_iso = data.get("start_time")
335
  if not start_time_iso: return jsonify({"error": "start_time is required"}), 400
 
337
  if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
338
  try:
339
  start_time = dt_class.fromisoformat(start_time_iso.replace("Z", "+00:00"))
 
 
 
340
  job_ref.update({"scheduled_start_time": start_time_iso, "status": "scheduled"})
341
+ scheduler.add_job(func=start_scheduled_job, trigger="date", run_date=start_time, args=[job_id], id=f"start_{job_id}", replace_existing=True)
342
  return jsonify({"message": f"Job {job_id} scheduled to start at {start_time_iso}"}), 200
343
+ except ValueError:
344
+ return jsonify({"error": "Invalid start_time format."}), 400
345
  except Exception as e:
346
  logger.error(f"Error scheduling job {job_id}: {e}", exc_info=True)
347
  return jsonify({"error": "Failed to schedule job"}), 500
 
350
  try:
351
  logger.info(f"Executing scheduled start for job {job_id}")
352
  job_ref = db.reference(f"jobs/{job_id}")
353
+ if job_ref.get():
354
+ job_ref.update({"status": "active", "started_at": dt_class.now().isoformat()})
355
+ assign_roster(job_id)
356
  except Exception as e:
357
  logger.error(f"Error in start_scheduled_job for {job_id}: {e}", exc_info=True)
358
 
359
+ @app.route("/start_job/<job_id>", methods=["POST"])
360
+ def start_job(job_id):
361
+ if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
362
+ job_ref = db.reference(f"jobs/{job_id}")
363
+ if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
364
+ try:
365
+ job_ref.update({"status": "active", "started_at": dt_class.now().isoformat()})
366
+ logger.info(f"Job {job_id} status updated to active.")
367
+ scheduler.add_job(
368
+ func=assign_roster, trigger="date",
369
+ run_date=dt_class.now() + datetime.timedelta(seconds=5),
370
+ args=[job_id], id=f"start_{job_id}", replace_existing=True
371
+ )
372
+ logger.info(f"First assignment for job {job_id} scheduled.")
373
+ return jsonify({"message": f"Job {job_id} started"}), 202
374
+ except Exception as e:
375
+ logger.error(f"Error starting job {job_id}: {e}", exc_info=True)
376
+ return jsonify({"error": "Failed to start job assignments"}), 500
377
+
378
  @app.route("/pause_job/<job_id>", methods=["POST"])
379
  def pause_job(job_id):
380
+ if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
 
381
  job_ref = db.reference(f"jobs/{job_id}")
382
  if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
383
  try:
 
389
 
390
  @app.route("/delete_job/<job_id>", methods=["DELETE"])
391
  def delete_job(job_id):
392
+ if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
 
393
  job_ref = db.reference(f"jobs/{job_id}")
394
  if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
395
  try:
 
401
 
402
  @app.route("/jobs", methods=["GET"])
403
  def get_jobs():
404
+ if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
 
405
  try:
406
+ jobs = db.reference("jobs").get() or {}
 
407
  jobs_list = [{"id": j_id, **j_data} for j_id, j_data in jobs.items()]
408
  return jsonify({"jobs": jobs_list}), 200
409
  except Exception as e:
 
412
 
413
  @app.route("/job/<job_id>", methods=["GET"])
414
  def get_job(job_id):
415
+ if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
 
416
  try:
417
  job_data = db.reference(f"jobs/{job_id}").get()
418
  if not job_data: return jsonify({"error": "Job not found"}), 404
 
423
 
424
  @app.route("/members", methods=["GET"])
425
  def get_members():
426
+ if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
 
427
  try:
428
+ members = db.reference("members").get() or {}
 
429
  members_list = [{"id": m_id, **m_data} for m_id, m_data in members.items()]
430
  return jsonify({"members": members_list}), 200
431
  except Exception as e:
 
434
 
435
  @app.route("/assign_members_to_job", methods=["POST"])
436
  def assign_members_to_job():
437
+ if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
 
438
  data = request.json
439
+ job_id, member_ids = data.get("job_id"), data.get("member_ids", [])
 
440
  if not job_id or not member_ids: return jsonify({"error": "job_id and member_ids are required"}), 400
441
  job_ref = db.reference(f"jobs/{job_id}")
442
  if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
443
  try:
444
+ all_members = db.reference("members").get() or {}
 
445
  selected_members = [{"id": m_id, **m_data} for m_id, m_data in all_members.items() if m_id in member_ids]
446
  if len(selected_members) < 25: return jsonify({"error": "At least 25 members must be assigned"}), 400
447
  job_ref.update({"assigned_members": selected_members})
 
452
 
453
  @app.route("/update_job_settings/<job_id>", methods=["POST"])
454
  def update_job_settings(job_id):
455
+ if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
 
456
  data = request.json
457
  rotation_period = data.get("rotation_period")
458
  if rotation_period is None: return jsonify({"error": "rotation_period is required"}), 400
 
467
 
468
  @app.route("/add_special_case/<job_id>", methods=["POST"])
469
  def add_special_case(job_id):
470
+ if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
 
471
  data = request.json
472
  point_id, member_id = data.get("point_id"), data.get("member_id")
473
  if not point_id or not member_id: return jsonify({"error": "point_id and member_id are required"}), 400
 
490
 
491
  @app.route("/get_roster/<job_id>", methods=["GET"])
492
  def get_roster(job_id):
493
+ if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
 
494
  try:
495
  job_data = db.reference(f"jobs/{job_id}").get()
496
  if not job_data: return jsonify({"error": "Job not found"}), 404
 
511
  logger.error(f"Error getting roster for {job_id}: {e}", exc_info=True)
512
  return jsonify({"error": "Internal server error"}), 500
513
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514
  # === Run Server ===
515
  if __name__ == "__main__":
516
  logger.info("Starting Flask application...")