rairo commited on
Commit
f6f6a28
·
verified ·
1 Parent(s): bc0ad85

Update main.py

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