rairo commited on
Commit
48cc3d6
·
verified ·
1 Parent(s): 207ab44

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +178 -191
main.py CHANGED
@@ -15,8 +15,13 @@ import random
15
  from datetime import datetime as dt_class, timezone
16
  from zoneinfo import ZoneInfo
17
  import pandas as pd
18
- import requests # <-- Used for direct API calls
19
- import certifi # <-- Used for modern SSL certificates
 
 
 
 
 
20
 
21
  # === Logging Configuration ===
22
  if not os.path.exists('logs'):
@@ -38,7 +43,7 @@ root_logger.addHandler(console_handler)
38
 
39
  # === ENV Config & Admin Setup ===
40
  ADMIN_EMAILS = ["rairorr@gmail.com", "nharingosheperd@gmail.com"]
41
- SEND_TO_EMAILS = ["rairorr@gmail.com", "nharingosheperd@gmail.com"]
42
 
43
  RESEND_API_KEY = os.getenv("RESEND_API_KEY")
44
  FIREBASE_CRED_JSON = json.loads(os.getenv("FIREBASE"))
@@ -63,11 +68,12 @@ scheduler.init_app(app)
63
  scheduler.start()
64
  logger.info("Flask and APScheduler initialized.")
65
 
66
- # === DEFINITIVE EMAIL SENDING FUNCTION ===
67
- def send_email(to, subject, html):
 
68
  """
69
- Sends an email using a direct requests call to bypass library issues and
70
- ensure a modern SSL certificate bundle is used.
71
  """
72
  if not RESEND_API_KEY:
73
  logger.error("RESEND_API_KEY is not configured. Cannot send email.")
@@ -79,33 +85,22 @@ def send_email(to, subject, html):
79
  "Content-Type": "application/json"
80
  }
81
  payload = {
82
- "from": "Guard Admin <admin@sozofix.tech>",
83
  "to": to.strip(),
84
  "subject": subject,
85
- "html": html
 
86
  }
87
 
88
- logger.info(f"Attempting DIRECT API call to Resend for recipient: {to}")
89
-
90
  try:
91
  response = requests.post(
92
- url,
93
- headers=headers,
94
- json=payload,
95
- verify=certifi.where(),
96
- timeout=15
97
  )
98
-
99
- logger.info(f"Direct call response status for '{to}': {response.status_code}")
100
  response.raise_for_status()
101
-
102
  response_data = response.json()
103
- logger.info(f"Email sent successfully to {to}. Email ID: {response_data.get('id')}")
104
  return response_data
105
-
106
- except requests.exceptions.SSLError as e:
107
- logger.error(f"CRITICAL: SSL Error occurred for '{to}' despite using Certifi. This points to a deep network issue or firewall. Error: {repr(e)}", exc_info=True)
108
- return None
109
  except requests.exceptions.RequestException as e:
110
  logger.error(f"A low-level network error occurred for '{to}'. The raw error is: {repr(e)}", exc_info=True)
111
  return None
@@ -114,69 +109,139 @@ def send_email(to, subject, html):
114
  return None
115
 
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  def send_rotation_notification(job_id, shift_record):
118
- """Builds and sends email notifications with CAT timezone."""
 
 
 
 
 
 
 
 
119
  try:
120
- job_ref = db.reference(f"jobs/{job_id}")
121
- job_data = job_ref.get()
122
- if not job_data: return
123
-
124
- job_name = job_data.get("name", "Unknown Job")
125
- shift_number = shift_record.get("shift_number", "Unknown")
126
-
127
- human_readable_time = 'Unknown'
128
- try:
129
- assigned_time_iso = shift_record.get('assigned_at', '')
130
- utc_time = dt_class.fromisoformat(assigned_time_iso)
131
- cat_tz = ZoneInfo("Africa/Harare")
132
- cat_time = utc_time.astimezone(cat_tz)
133
- human_readable_time = cat_time.strftime("%B %d, %Y at %I:%M %p %Z")
134
- except (ValueError, TypeError) as e:
135
- logger.warning(f"Could not parse or convert timestamp for email: {e}")
136
- human_readable_time = shift_record.get('assigned_at', 'Unknown')
137
-
138
- html_content = f"""
139
- <h2>Guard Rotation Notification</h2>
140
- <p><strong>Job:</strong> {job_name}</p>
141
- <p><strong>Shift Number:</strong> {shift_number}</p>
142
- <p><strong>Rotation Time:</strong> {human_readable_time}</p>
143
- <h3>Assignments:</h3>
144
- <table border="1" style="border-collapse: collapse; width: 100%;">
145
- <thead><tr><th>Guarding Point</th><th>Assigned Members</th><th>Special Case</th></tr></thead>
146
- <tbody>
147
- """
148
- for assignment in shift_record.get("assignments", []):
149
- point_name = assignment.get("point", {}).get("name", "Unknown Point")
150
- member_names = [m.get("name", "Unknown Member") for m in assignment.get("members", [])]
151
- members_html = "<br>".join(member_names) if member_names else "None"
152
- is_special = "Yes" if assignment.get("is_special_case", False) else "No"
153
- html_content += f"<tr><td>{point_name}</td><td>{members_html}</td><td>{is_special}</td></tr>"
154
- html_content += "</tbody></table><p><em>This is an automated notification from the Guard Rotation System.</em></p>"
155
 
156
- subject = f"Guard Rotation - {job_name} (Shift {shift_number})"
 
 
 
 
 
 
 
 
 
157
 
 
 
 
 
 
 
 
 
 
 
 
158
  for send_to_email in SEND_TO_EMAILS:
159
- logger.info(f"Sending rotation notification to {send_to_email}...")
160
- send_email(send_to_email, subject, html_content)
161
  time.sleep(1)
162
 
163
- except Exception as e:
164
- logger.error(f"Error building rotation notification for job {job_id}", exc_info=True)
165
-
 
 
 
 
 
 
 
 
 
166
 
167
  def verify_token(req):
168
- """Verifies Firebase token and checks against the ADMIN_EMAILS list."""
169
  try:
170
  auth_header = req.headers.get("Authorization", "").split("Bearer ")[-1]
171
  user_email = req.headers.get("X-User-Email")
172
- if not auth_header or not user_email or user_email not in ADMIN_EMAILS:
173
- return None
174
  return firebase_auth.verify_id_token(auth_header)
175
- except Exception:
176
- return None
177
 
178
  def setup_admins():
179
- """Sets up admin users in Firebase based on the ADMIN_EMAILS list."""
180
  ref = db.reference("admins")
181
  for email in ADMIN_EMAILS:
182
  uid = f"admin_{email.replace('@', '_').replace('.', '_')}"
@@ -184,36 +249,28 @@ def setup_admins():
184
  setup_admins()
185
 
186
  def assign_roster(job_id):
187
- """Assigns roster for a job."""
188
  try:
189
  logger.info(f"Starting roster assignment for job {job_id}")
190
  job_ref = db.reference(f"jobs/{job_id}")
191
  job_data = job_ref.get()
192
-
193
  if not job_data or job_data.get("status") != "active": return
194
-
195
  guarding_points = job_data.get("guarding_points", [])
196
  assigned_members = job_data.get("assigned_members", [])
197
-
198
  if not guarding_points or not assigned_members:
199
  logger.warning(f"Job {job_id} is missing guarding points or assigned members.")
200
  return
201
-
202
  shift_number = len(job_data.get("assignments", [])) + 1
203
  available_for_shift = random.sample(assigned_members, len(assigned_members))
204
-
205
  total_guards_required = sum(int(point.get('guards', 1)) for point in guarding_points)
206
  if len(available_for_shift) < total_guards_required:
207
  logger.error(f"INSUFFICIENT MEMBERS: Job {job_id} requires {total_guards_required} but only {len(available_for_shift)} are available. Aborting.")
208
  return
209
-
210
  shift_assignments = []
211
  for point in guarding_points:
212
  num_guards_required = int(point.get('guards', 1))
213
  guards_for_point = [available_for_shift.pop(0) for _ in range(num_guards_required) if available_for_shift]
214
  if guards_for_point:
215
  shift_assignments.append({"point": point, "members": guards_for_point, "is_special_case": False})
216
-
217
  if shift_assignments:
218
  shift_record = {
219
  "shift_number": shift_number,
@@ -227,13 +284,10 @@ def assign_roster(job_id):
227
  "assignments": current_assignments,
228
  "last_updated": dt_class.now(timezone.utc).isoformat()
229
  })
230
-
231
  logger.info(f"Shift {shift_number} assigned for job {job_id}.")
232
  send_rotation_notification(job_id, shift_record)
233
-
234
  rotation_period = job_data.get("rotation_period", 28800)
235
  next_run_time = dt_class.now(timezone.utc) + datetime.timedelta(seconds=rotation_period)
236
-
237
  scheduler.add_job(
238
  func=assign_roster, trigger="date", run_date=next_run_time,
239
  args=[job_id], id=f"rotate_{job_id}_{uuid.uuid4().hex[:8]}",
@@ -245,18 +299,13 @@ def assign_roster(job_id):
245
 
246
  # === Routes ===
247
  @app.before_request
248
- def log_request_info():
249
- logger.info(f"Incoming request: {request.method} {request.url}")
250
-
251
  @app.after_request
252
- def log_response_info(response):
253
- logger.info(f"Outgoing response: {response.status} for {request.method} {request.url}")
254
- return response
255
-
256
  @app.route("/", methods=["GET"])
257
- def home():
258
- return jsonify({"message": "Rairo Guards API is running.", "status": "ok"}), 200
259
 
 
260
  @app.route("/upload_members", methods=["POST"])
261
  def upload_members():
262
  if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
@@ -265,24 +314,16 @@ def upload_members():
265
  try:
266
  df = pd.read_csv(file) if file.filename.endswith(".csv") else pd.read_excel(file)
267
  members = df.to_dict(orient="records")
268
- for member in members:
269
- db.reference(f"members/{str(uuid.uuid4())}").set(member)
270
  return jsonify({"message": "Members uploaded successfully"}), 200
271
- except Exception as e:
272
- logger.error(f"Error processing uploaded members file: {e}", exc_info=True)
273
- return jsonify({"error": "Error processing file"}), 500
274
 
275
  @app.route("/add_member", methods=["POST"])
276
  def add_member():
277
  if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
278
- data = request.json
279
- member_id = str(uuid.uuid4())
280
- try:
281
- db.reference(f"members/{member_id}").set(data)
282
- return jsonify({"message": "Member added", "member_id": member_id}), 200
283
- except Exception as e:
284
- logger.error(f"Error adding member: {e}", exc_info=True)
285
- return jsonify({"error": "Internal server error"}), 500
286
 
287
  @app.route("/update_member/<member_id>", methods=["POST"])
288
  def update_member(member_id):
@@ -291,42 +332,27 @@ def update_member(member_id):
291
  if not data: return jsonify({"error": "Request body cannot be empty"}), 400
292
  member_ref = db.reference(f"members/{member_id}")
293
  if not member_ref.get(): return jsonify({"error": "Member not found"}), 404
294
- try:
295
- member_ref.update(data)
296
- return jsonify({"message": "Member updated", "member": {**data, "id": member_id}}), 200
297
- except Exception as e:
298
- logger.error(f"Error updating member {member_id}: {e}", exc_info=True)
299
- return jsonify({"error": "Internal server error"}), 500
300
 
301
  @app.route("/delete_member/<member_id>", methods=["DELETE"])
302
  def delete_member(member_id):
303
  if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
304
  member_ref = db.reference(f"members/{member_id}")
305
  if not member_ref.get(): return jsonify({"error": "Member not found"}), 404
306
- try:
307
- member_ref.delete()
308
- return jsonify({"message": f"Member {member_id} deleted"}), 200
309
- except Exception as e:
310
- logger.error(f"Error deleting member {member_id}: {e}", exc_info=True)
311
- return jsonify({"error": "Internal server error"}), 500
312
 
313
  @app.route("/create_job", methods=["POST"])
314
  def create_job():
315
  if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
316
- data = request.json
317
- job_id = str(uuid.uuid4())
318
  guarding_points = data.get("guarding_points", [])
319
- if not 5 <= len(guarding_points) <= 15:
320
- return jsonify({"error": "Guarding points must be between 5 and 15"}), 400
321
- for i, point in enumerate(guarding_points):
322
- point.setdefault("id", f"point_{i+1}")
323
  job_data = {**data, "guarding_points": guarding_points, "created_at": dt_class.now(timezone.utc).isoformat(), "status": "created", "assignments": []}
324
- try:
325
- db.reference(f"jobs/{job_id}").set(job_data)
326
- return jsonify({"message": "Job created", "job_id": job_id}), 200
327
- except Exception as e:
328
- logger.error(f"Error creating job: {e}", exc_info=True)
329
- return jsonify({"error": "Internal server error"}), 500
330
 
331
  @app.route("/update_job/<job_id>", methods=["POST"])
332
  def update_job(job_id):
@@ -339,15 +365,12 @@ def update_job(job_id):
339
  for key in ['assignments', 'rotation_history', 'status']: data.pop(key, None)
340
  job_ref.update(data)
341
  return jsonify({"message": f"Job {job_id} updated"}), 200
342
- except Exception as e:
343
- logger.error(f"Error updating job {job_id}: {e}", exc_info=True)
344
- return jsonify({"error": "Internal server error"}), 500
345
 
346
  @app.route("/schedule_job/<job_id>", methods=["POST"])
347
  def schedule_job(job_id):
348
  if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
349
- data = request.json
350
- start_time_iso = data.get("start_time")
351
  if not start_time_iso: return jsonify({"error": "start_time is required"}), 400
352
  job_ref = db.reference(f"jobs/{job_id}")
353
  if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
@@ -356,11 +379,8 @@ def schedule_job(job_id):
356
  job_ref.update({"scheduled_start_time": start_time_iso, "status": "scheduled"})
357
  scheduler.add_job(func=start_scheduled_job, trigger="date", run_date=start_time, args=[job_id], id=f"start_{job_id}", replace_existing=True)
358
  return jsonify({"message": f"Job {job_id} scheduled to start at {start_time_iso}"}), 200
359
- except ValueError:
360
- return jsonify({"error": "Invalid start_time format."}), 400
361
- except Exception as e:
362
- logger.error(f"Error scheduling job {job_id}: {e}", exc_info=True)
363
- return jsonify({"error": "Failed to schedule job"}), 500
364
 
365
  def start_scheduled_job(job_id):
366
  try:
@@ -369,8 +389,7 @@ def start_scheduled_job(job_id):
369
  if job_ref.get():
370
  job_ref.update({"status": "active", "started_at": dt_class.now(timezone.utc).isoformat()})
371
  assign_roster(job_id)
372
- except Exception as e:
373
- logger.error(f"Error in start_scheduled_job for {job_id}: {e}", exc_info=True)
374
 
375
  @app.route("/start_job/<job_id>", methods=["POST"])
376
  def start_job(job_id):
@@ -386,33 +405,23 @@ def start_job(job_id):
386
  args=[job_id], id=f"start_{job_id}", replace_existing=True
387
  )
388
  return jsonify({"message": f"Job {job_id} started"}), 202
389
- except Exception as e:
390
- logger.error(f"Error starting job {job_id}: {e}", exc_info=True)
391
- return jsonify({"error": "Failed to start job assignments"}), 500
392
 
393
  @app.route("/pause_job/<job_id>", methods=["POST"])
394
  def pause_job(job_id):
395
  if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
396
  job_ref = db.reference(f"jobs/{job_id}")
397
  if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
398
- try:
399
- job_ref.update({"status": "paused"})
400
- return jsonify({"message": f"Job {job_id} paused"}), 200
401
- except Exception as e:
402
- logger.error(f"Error pausing job {job_id}: {e}", exc_info=True)
403
- return jsonify({"error": "Internal server error"}), 500
404
 
405
  @app.route("/delete_job/<job_id>", methods=["DELETE"])
406
  def delete_job(job_id):
407
  if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
408
  job_ref = db.reference(f"jobs/{job_id}")
409
  if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
410
- try:
411
- job_ref.delete()
412
- return jsonify({"message": f"Job {job_id} deleted"}), 200
413
- except Exception as e:
414
- logger.error(f"Error deleting job {job_id}: {e}", exc_info=True)
415
- return jsonify({"error": "Internal server error"}), 500
416
 
417
  @app.route("/jobs", methods=["GET"])
418
  def get_jobs():
@@ -421,9 +430,7 @@ def get_jobs():
421
  jobs = db.reference("jobs").get() or {}
422
  jobs_list = [{"id": j_id, **j_data} for j_id, j_data in jobs.items()]
423
  return jsonify({"jobs": jobs_list}), 200
424
- except Exception as e:
425
- logger.error(f"Error getting jobs: {e}", exc_info=True)
426
- return jsonify({"error": "Internal server error"}), 500
427
 
428
  @app.route("/job/<job_id>", methods=["GET"])
429
  def get_job(job_id):
@@ -432,9 +439,7 @@ def get_job(job_id):
432
  job_data = db.reference(f"jobs/{job_id}").get()
433
  if not job_data: return jsonify({"error": "Job not found"}), 404
434
  return jsonify({"job": {"id": job_id, **job_data}}), 200
435
- except Exception as e:
436
- logger.error(f"Error getting job {job_id}: {e}", exc_info=True)
437
- return jsonify({"error": "Internal server error"}), 500
438
 
439
  @app.route("/members", methods=["GET"])
440
  def get_members():
@@ -443,9 +448,7 @@ def get_members():
443
  members = db.reference("members").get() or {}
444
  members_list = [{"id": m_id, **m_data} for m_id, m_data in members.items()]
445
  return jsonify({"members": members_list}), 200
446
- except Exception as e:
447
- logger.error(f"Error getting members: {e}", exc_info=True)
448
- return jsonify({"error": "Internal server error"}), 500
449
 
450
  @app.route("/assign_members_to_job", methods=["POST"])
451
  def assign_members_to_job():
@@ -461,24 +464,17 @@ def assign_members_to_job():
461
  if len(selected_members) < 25: return jsonify({"error": "At least 25 members must be assigned"}), 400
462
  job_ref.update({"assigned_members": selected_members})
463
  return jsonify({"message": f"{len(selected_members)} members assigned"}), 200
464
- except Exception as e:
465
- logger.error(f"Error assigning members to job {job_id}: {e}", exc_info=True)
466
- return jsonify({"error": "Internal server error"}), 500
467
 
468
  @app.route("/update_job_settings/<job_id>", methods=["POST"])
469
  def update_job_settings(job_id):
470
  if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
471
- data = request.json
472
- rotation_period = data.get("rotation_period")
473
  if rotation_period is None: return jsonify({"error": "rotation_period is required"}), 400
474
  job_ref = db.reference(f"jobs/{job_id}")
475
  if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
476
- try:
477
- job_ref.update({"rotation_period": rotation_period})
478
- return jsonify({"message": f"Rotation period updated to {rotation_period}s"}), 200
479
- except Exception as e:
480
- logger.error(f"Error updating job settings for {job_id}: {e}", exc_info=True)
481
- return jsonify({"error": "Internal server error"}), 500
482
 
483
  @app.route("/add_special_case/<job_id>", methods=["POST"])
484
  def add_special_case(job_id):
@@ -486,22 +482,15 @@ def add_special_case(job_id):
486
  data = request.json
487
  point_id, member_id = data.get("point_id"), data.get("member_id")
488
  if not point_id or not member_id: return jsonify({"error": "point_id and member_id are required"}), 400
489
- job_ref = db.reference(f"jobs/{job_id}")
490
- job_data = job_ref.get()
491
  if not job_data: return jsonify({"error": "Job not found"}), 404
492
- if not any(p["id"] == point_id for p in job_data.get("guarding_points", [])):
493
- return jsonify({"error": "Guarding point not found in job"}), 404
494
- if not any(m["id"] == member_id for m in job_data.get("assigned_members", [])):
495
- return jsonify({"error": "Member not assigned to job"}), 404
496
  special_case = {"id": str(uuid.uuid4()), **data, "created_at": dt_class.now(timezone.utc).isoformat()}
497
  special_cases = job_data.get("special_cases", [])
498
  special_cases.append(special_case)
499
- try:
500
- job_ref.update({"special_cases": special_cases})
501
- return jsonify({"message": "Special case added", "special_case": special_case}), 200
502
- except Exception as e:
503
- logger.error(f"Error adding special case to job {job_id}: {e}", exc_info=True)
504
- return jsonify({"error": "Internal server error"}), 500
505
 
506
  @app.route("/get_roster/<job_id>", methods=["GET"])
507
  def get_roster(job_id):
@@ -522,9 +511,7 @@ def get_roster(job_id):
522
  "is_special_case": assignment.get("is_special_case", False),
523
  })
524
  return jsonify({"roster": roster_data, "job_name": job_data.get("name", "Unknown Job"), "total_shifts": len(assignments)}), 200
525
- except Exception as e:
526
- logger.error(f"Error getting roster for {job_id}: {e}", exc_info=True)
527
- return jsonify({"error": "Internal server error"}), 500
528
 
529
  # === Run Server ===
530
  if __name__ == "__main__":
 
15
  from datetime import datetime as dt_class, timezone
16
  from zoneinfo import ZoneInfo
17
  import pandas as pd
18
+ import requests
19
+ import certifi
20
+ import base64
21
+
22
+ # --- NEW IMPORTS FOR FILE GENERATION ---
23
+ import weasyprint
24
+ import imgkit
25
 
26
  # === Logging Configuration ===
27
  if not os.path.exists('logs'):
 
43
 
44
  # === ENV Config & Admin Setup ===
45
  ADMIN_EMAILS = ["rairorr@gmail.com", "nharingosheperd@gmail.com"]
46
+ SEND_TO_EMAILS = ["rairorr@gmail.com", "nharingosheperd+guard@gmail.com"]
47
 
48
  RESEND_API_KEY = os.getenv("RESEND_API_KEY")
49
  FIREBASE_CRED_JSON = json.loads(os.getenv("FIREBASE"))
 
68
  scheduler.start()
69
  logger.info("Flask and APScheduler initialized.")
70
 
71
+
72
+ # === UPDATED EMAIL SENDING FUNCTION ===
73
+ def send_email(to, subject, html, attachments=None):
74
  """
75
+ Sends an email using a direct requests call, now with attachment support.
76
+ Attachments should be a list of dicts: [{'filename': 'roster.pdf', 'content': 'base64_string'}]
77
  """
78
  if not RESEND_API_KEY:
79
  logger.error("RESEND_API_KEY is not configured. Cannot send email.")
 
85
  "Content-Type": "application/json"
86
  }
87
  payload = {
88
+ "from": "Guard Admin <admin@sozofix.tech>", # Using custom domain
89
  "to": to.strip(),
90
  "subject": subject,
91
+ "html": html,
92
+ "attachments": attachments or []
93
  }
94
 
 
 
95
  try:
96
  response = requests.post(
97
+ url, headers=headers, json=payload,
98
+ verify=certifi.where(), timeout=30 # Increased timeout for attachments
 
 
 
99
  )
 
 
100
  response.raise_for_status()
 
101
  response_data = response.json()
102
+ logger.info(f"Email with {len(payload['attachments'])} attachments sent to {to}. ID: {response_data.get('id')}")
103
  return response_data
 
 
 
 
104
  except requests.exceptions.RequestException as e:
105
  logger.error(f"A low-level network error occurred for '{to}'. The raw error is: {repr(e)}", exc_info=True)
106
  return None
 
109
  return None
110
 
111
 
112
+ # === NEW FILE GENERATION HELPERS ===
113
+ def create_csv_attachment(shift_data, filename):
114
+ """Creates a CSV file from shift data and returns its path."""
115
+ try:
116
+ df_data = []
117
+ for assignment in shift_data.get("assignments", []):
118
+ point = assignment.get("point", {}).get("name", "N/A")
119
+ members = ", ".join([m.get("name", "N/A") for m in assignment.get("members", [])])
120
+ df_data.append({"Guarding Point": point, "Assigned Members": members})
121
+
122
+ df = pd.DataFrame(df_data)
123
+ filepath = f"/tmp/{filename}"
124
+ df.to_csv(filepath, index=False)
125
+ logger.info(f"Successfully created CSV attachment: {filepath}")
126
+ return filepath
127
+ except Exception as e:
128
+ logger.error(f"Failed to create CSV attachment: {e}", exc_info=True)
129
+ return None
130
+
131
+ def create_pdf_attachment(html_content, filename):
132
+ """Creates a PDF file from HTML content and returns its path."""
133
+ try:
134
+ filepath = f"/tmp/{filename}"
135
+ weasyprint.HTML(string=html_content).write_pdf(filepath)
136
+ logger.info(f"Successfully created PDF attachment: {filepath}")
137
+ return filepath
138
+ except Exception as e:
139
+ logger.error(f"Failed to create PDF attachment: {e}", exc_info=True)
140
+ return None
141
+
142
+ def create_image_attachment(html_content, filename):
143
+ """Creates a PNG image file from HTML content and returns its path."""
144
+ try:
145
+ filepath = f"/tmp/{filename}"
146
+ # Options to improve image quality and ensure full capture
147
+ options = {'width': 800, 'disable-smart-width': ''}
148
+ imgkit.from_string(html_content, filepath, options=options)
149
+ logger.info(f"Successfully created image attachment: {filepath}")
150
+ return filepath
151
+ except Exception as e:
152
+ logger.error(f"Failed to create image attachment: {e}", exc_info=True)
153
+ return None
154
+
155
+
156
+ # === UPDATED NOTIFICATION FUNCTION ===
157
  def send_rotation_notification(job_id, shift_record):
158
+ """Orchestrates creating HTML, generating file attachments, and sending the email."""
159
+ job_ref = db.reference(f"jobs/{job_id}")
160
+ job_data = job_ref.get()
161
+ if not job_data: return
162
+
163
+ job_name = job_data.get("name", "Unknown Job")
164
+ shift_number = shift_record.get("shift_number", "Unknown")
165
+
166
+ human_readable_time = 'Unknown'
167
  try:
168
+ utc_time = dt_class.fromisoformat(shift_record.get('assigned_at', ''))
169
+ cat_tz = ZoneInfo("Africa/Harare")
170
+ cat_time = utc_time.astimezone(cat_tz)
171
+ human_readable_time = cat_time.strftime("%B %d, %Y at %I:%M %p %Z")
172
+ except Exception:
173
+ human_readable_time = shift_record.get('assigned_at', 'Unknown')
174
+
175
+ # Generate HTML content once
176
+ html_content = f"""
177
+ <html><head><style>
178
+ body {{ font-family: sans-serif; }}
179
+ table {{ border-collapse: collapse; width: 100%; }}
180
+ th, td {{ border: 1px solid #dddddd; text-align: left; padding: 8px; }}
181
+ th {{ background-color: #f2f2f2; }}
182
+ </style></head><body>
183
+ <h2>Guard Rotation Notification</h2>
184
+ <p><strong>Job:</strong> {job_name}</p>
185
+ <p><strong>Shift Number:</strong> {shift_number}</p>
186
+ <p><strong>Rotation Time:</strong> {human_readable_time}</p>
187
+ <h3>Assignments:</h3>
188
+ <table><thead><tr><th>Guarding Point</th><th>Assigned Members</th></tr></thead><tbody>
189
+ """
190
+ for assignment in shift_record.get("assignments", []):
191
+ point_name = assignment.get("point", {}).get("name", "N/A")
192
+ members_html = "<br>".join([m.get("name", "N/A") for m in assignment.get("members", [])])
193
+ html_content += f"<tr><td>{point_name}</td><td>{members_html}</td></tr>"
194
+ html_content += "</tbody></table><p><em>This is an automated notification from the Guard Rotation System.</em></p></body></html>"
 
 
 
 
 
 
 
 
195
 
196
+ # Generate Attachments
197
+ base_filename = f"roster_{job_name.replace(' ', '_')}_shift_{shift_number}_{int(time.time())}"
198
+ paths_to_clean = []
199
+ attachments = []
200
+
201
+ try:
202
+ # Create files
203
+ csv_path = create_csv_attachment(shift_record, f"{base_filename}.csv")
204
+ pdf_path = create_pdf_attachment(html_content, f"{base_filename}.pdf")
205
+ img_path = create_image_attachment(html_content, f"{base_filename}.png")
206
 
207
+ # Prepare for email sending
208
+ file_paths = {'roster.csv': csv_path, 'roster.pdf': pdf_path, 'roster.png': img_path}
209
+ for filename, filepath in file_paths.items():
210
+ if filepath:
211
+ paths_to_clean.append(filepath)
212
+ with open(filepath, "rb") as f:
213
+ content_b64 = base64.b64encode(f.read()).decode('utf-8')
214
+ attachments.append({'filename': filename, 'content': content_b64})
215
+
216
+ # Send emails
217
+ subject = f"Guard Rotation - {job_name} (Shift {shift_number})"
218
  for send_to_email in SEND_TO_EMAILS:
219
+ logger.info(f"Sending rotation notification with {len(attachments)} attachments to {send_to_email}...")
220
+ send_email(send_to_email, subject, html_content, attachments)
221
  time.sleep(1)
222
 
223
+ finally:
224
+ # CRITICAL: Clean up temporary files
225
+ logger.info(f"Cleaning up {len(paths_to_clean)} temporary files...")
226
+ for path in paths_to_clean:
227
+ try:
228
+ os.remove(path)
229
+ except OSError as e:
230
+ logger.error(f"Error deleting temporary file {path}: {e}")
231
+
232
+ # ... (The rest of your main.py file remains exactly the same)
233
+ # === VERIFY TOKEN, SETUP ADMINS, ASSIGN ROSTER, ALL ROUTES ETC. ===
234
+ # ...
235
 
236
  def verify_token(req):
 
237
  try:
238
  auth_header = req.headers.get("Authorization", "").split("Bearer ")[-1]
239
  user_email = req.headers.get("X-User-Email")
240
+ if not auth_header or not user_email or user_email not in ADMIN_EMAILS: return None
 
241
  return firebase_auth.verify_id_token(auth_header)
242
+ except Exception: return None
 
243
 
244
  def setup_admins():
 
245
  ref = db.reference("admins")
246
  for email in ADMIN_EMAILS:
247
  uid = f"admin_{email.replace('@', '_').replace('.', '_')}"
 
249
  setup_admins()
250
 
251
  def assign_roster(job_id):
 
252
  try:
253
  logger.info(f"Starting roster assignment for job {job_id}")
254
  job_ref = db.reference(f"jobs/{job_id}")
255
  job_data = job_ref.get()
 
256
  if not job_data or job_data.get("status") != "active": return
 
257
  guarding_points = job_data.get("guarding_points", [])
258
  assigned_members = job_data.get("assigned_members", [])
 
259
  if not guarding_points or not assigned_members:
260
  logger.warning(f"Job {job_id} is missing guarding points or assigned members.")
261
  return
 
262
  shift_number = len(job_data.get("assignments", [])) + 1
263
  available_for_shift = random.sample(assigned_members, len(assigned_members))
 
264
  total_guards_required = sum(int(point.get('guards', 1)) for point in guarding_points)
265
  if len(available_for_shift) < total_guards_required:
266
  logger.error(f"INSUFFICIENT MEMBERS: Job {job_id} requires {total_guards_required} but only {len(available_for_shift)} are available. Aborting.")
267
  return
 
268
  shift_assignments = []
269
  for point in guarding_points:
270
  num_guards_required = int(point.get('guards', 1))
271
  guards_for_point = [available_for_shift.pop(0) for _ in range(num_guards_required) if available_for_shift]
272
  if guards_for_point:
273
  shift_assignments.append({"point": point, "members": guards_for_point, "is_special_case": False})
 
274
  if shift_assignments:
275
  shift_record = {
276
  "shift_number": shift_number,
 
284
  "assignments": current_assignments,
285
  "last_updated": dt_class.now(timezone.utc).isoformat()
286
  })
 
287
  logger.info(f"Shift {shift_number} assigned for job {job_id}.")
288
  send_rotation_notification(job_id, shift_record)
 
289
  rotation_period = job_data.get("rotation_period", 28800)
290
  next_run_time = dt_class.now(timezone.utc) + datetime.timedelta(seconds=rotation_period)
 
291
  scheduler.add_job(
292
  func=assign_roster, trigger="date", run_date=next_run_time,
293
  args=[job_id], id=f"rotate_{job_id}_{uuid.uuid4().hex[:8]}",
 
299
 
300
  # === Routes ===
301
  @app.before_request
302
+ def log_request_info(): logger.info(f"Incoming request: {request.method} {request.url}")
 
 
303
  @app.after_request
304
+ def log_response_info(response): logger.info(f"Outgoing response: {response.status} for {request.method} {request.url}"); return response
 
 
 
305
  @app.route("/", methods=["GET"])
306
+ def home(): return jsonify({"message": "Rairo Guards API is running.", "status": "ok"}), 200
 
307
 
308
+ # (All other routes follow)
309
  @app.route("/upload_members", methods=["POST"])
310
  def upload_members():
311
  if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
 
314
  try:
315
  df = pd.read_csv(file) if file.filename.endswith(".csv") else pd.read_excel(file)
316
  members = df.to_dict(orient="records")
317
+ for member in members: db.reference(f"members/{str(uuid.uuid4())}").set(member)
 
318
  return jsonify({"message": "Members uploaded successfully"}), 200
319
+ except Exception as e: logger.error(f"Error processing uploaded members file: {e}", exc_info=True); return jsonify({"error": "Error processing file"}), 500
 
 
320
 
321
  @app.route("/add_member", methods=["POST"])
322
  def add_member():
323
  if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
324
+ data, member_id = request.json, str(uuid.uuid4())
325
+ try: db.reference(f"members/{member_id}").set(data); return jsonify({"message": "Member added", "member_id": member_id}), 200
326
+ except Exception as e: logger.error(f"Error adding member: {e}", exc_info=True); return jsonify({"error": "Internal server error"}), 500
 
 
 
 
 
327
 
328
  @app.route("/update_member/<member_id>", methods=["POST"])
329
  def update_member(member_id):
 
332
  if not data: return jsonify({"error": "Request body cannot be empty"}), 400
333
  member_ref = db.reference(f"members/{member_id}")
334
  if not member_ref.get(): return jsonify({"error": "Member not found"}), 404
335
+ try: member_ref.update(data); return jsonify({"message": "Member updated", "member": {**data, "id": member_id}}), 200
336
+ except Exception as e: logger.error(f"Error updating member {member_id}: {e}", exc_info=True); return jsonify({"error": "Internal server error"}), 500
 
 
 
 
337
 
338
  @app.route("/delete_member/<member_id>", methods=["DELETE"])
339
  def delete_member(member_id):
340
  if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
341
  member_ref = db.reference(f"members/{member_id}")
342
  if not member_ref.get(): return jsonify({"error": "Member not found"}), 404
343
+ try: member_ref.delete(); return jsonify({"message": f"Member {member_id} deleted"}), 200
344
+ except Exception as e: logger.error(f"Error deleting member {member_id}: {e}", exc_info=True); return jsonify({"error": "Internal server error"}), 500
 
 
 
 
345
 
346
  @app.route("/create_job", methods=["POST"])
347
  def create_job():
348
  if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
349
+ data, job_id = request.json, str(uuid.uuid4())
 
350
  guarding_points = data.get("guarding_points", [])
351
+ if not 5 <= len(guarding_points) <= 15: return jsonify({"error": "Guarding points must be between 5 and 15"}), 400
352
+ for i, point in enumerate(guarding_points): point.setdefault("id", f"point_{i+1}")
 
 
353
  job_data = {**data, "guarding_points": guarding_points, "created_at": dt_class.now(timezone.utc).isoformat(), "status": "created", "assignments": []}
354
+ try: db.reference(f"jobs/{job_id}").set(job_data); return jsonify({"message": "Job created", "job_id": job_id}), 200
355
+ except Exception as e: logger.error(f"Error creating job: {e}", exc_info=True); return jsonify({"error": "Internal server error"}), 500
 
 
 
 
356
 
357
  @app.route("/update_job/<job_id>", methods=["POST"])
358
  def update_job(job_id):
 
365
  for key in ['assignments', 'rotation_history', 'status']: data.pop(key, None)
366
  job_ref.update(data)
367
  return jsonify({"message": f"Job {job_id} updated"}), 200
368
+ except Exception as e: logger.error(f"Error updating job {job_id}: {e}", exc_info=True); return jsonify({"error": "Internal server error"}), 500
 
 
369
 
370
  @app.route("/schedule_job/<job_id>", methods=["POST"])
371
  def schedule_job(job_id):
372
  if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
373
+ data, start_time_iso = request.json, data.get("start_time")
 
374
  if not start_time_iso: return jsonify({"error": "start_time is required"}), 400
375
  job_ref = db.reference(f"jobs/{job_id}")
376
  if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
 
379
  job_ref.update({"scheduled_start_time": start_time_iso, "status": "scheduled"})
380
  scheduler.add_job(func=start_scheduled_job, trigger="date", run_date=start_time, args=[job_id], id=f"start_{job_id}", replace_existing=True)
381
  return jsonify({"message": f"Job {job_id} scheduled to start at {start_time_iso}"}), 200
382
+ except ValueError: return jsonify({"error": "Invalid start_time format."}), 400
383
+ except Exception as e: logger.error(f"Error scheduling job {job_id}: {e}", exc_info=True); return jsonify({"error": "Failed to schedule job"}), 500
 
 
 
384
 
385
  def start_scheduled_job(job_id):
386
  try:
 
389
  if job_ref.get():
390
  job_ref.update({"status": "active", "started_at": dt_class.now(timezone.utc).isoformat()})
391
  assign_roster(job_id)
392
+ except Exception as e: logger.error(f"Error in start_scheduled_job for {job_id}: {e}", exc_info=True)
 
393
 
394
  @app.route("/start_job/<job_id>", methods=["POST"])
395
  def start_job(job_id):
 
405
  args=[job_id], id=f"start_{job_id}", replace_existing=True
406
  )
407
  return jsonify({"message": f"Job {job_id} started"}), 202
408
+ except Exception as e: logger.error(f"Error starting job {job_id}: {e}", exc_info=True); return jsonify({"error": "Failed to start job assignments"}), 500
 
 
409
 
410
  @app.route("/pause_job/<job_id>", methods=["POST"])
411
  def pause_job(job_id):
412
  if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
413
  job_ref = db.reference(f"jobs/{job_id}")
414
  if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
415
+ try: job_ref.update({"status": "paused"}); return jsonify({"message": f"Job {job_id} paused"}), 200
416
+ except Exception as e: logger.error(f"Error pausing job {job_id}: {e}", exc_info=True); return jsonify({"error": "Internal server error"}), 500
 
 
 
 
417
 
418
  @app.route("/delete_job/<job_id>", methods=["DELETE"])
419
  def delete_job(job_id):
420
  if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
421
  job_ref = db.reference(f"jobs/{job_id}")
422
  if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
423
+ try: job_ref.delete(); return jsonify({"message": f"Job {job_id} deleted"}), 200
424
+ except Exception as e: logger.error(f"Error deleting job {job_id}: {e}", exc_info=True); return jsonify({"error": "Internal server error"}), 500
 
 
 
 
425
 
426
  @app.route("/jobs", methods=["GET"])
427
  def get_jobs():
 
430
  jobs = db.reference("jobs").get() or {}
431
  jobs_list = [{"id": j_id, **j_data} for j_id, j_data in jobs.items()]
432
  return jsonify({"jobs": jobs_list}), 200
433
+ except Exception as e: logger.error(f"Error getting jobs: {e}", exc_info=True); return jsonify({"error": "Internal server error"}), 500
 
 
434
 
435
  @app.route("/job/<job_id>", methods=["GET"])
436
  def get_job(job_id):
 
439
  job_data = db.reference(f"jobs/{job_id}").get()
440
  if not job_data: return jsonify({"error": "Job not found"}), 404
441
  return jsonify({"job": {"id": job_id, **job_data}}), 200
442
+ except Exception as e: logger.error(f"Error getting job {job_id}: {e}", exc_info=True); return jsonify({"error": "Internal server error"}), 500
 
 
443
 
444
  @app.route("/members", methods=["GET"])
445
  def get_members():
 
448
  members = db.reference("members").get() or {}
449
  members_list = [{"id": m_id, **m_data} for m_id, m_data in members.items()]
450
  return jsonify({"members": members_list}), 200
451
+ except Exception as e: logger.error(f"Error getting members: {e}", exc_info=True); return jsonify({"error": "Internal server error"}), 500
 
 
452
 
453
  @app.route("/assign_members_to_job", methods=["POST"])
454
  def assign_members_to_job():
 
464
  if len(selected_members) < 25: return jsonify({"error": "At least 25 members must be assigned"}), 400
465
  job_ref.update({"assigned_members": selected_members})
466
  return jsonify({"message": f"{len(selected_members)} members assigned"}), 200
467
+ except Exception as e: logger.error(f"Error assigning members to job {job_id}: {e}", exc_info=True); return jsonify({"error": "Internal server error"}), 500
 
 
468
 
469
  @app.route("/update_job_settings/<job_id>", methods=["POST"])
470
  def update_job_settings(job_id):
471
  if not verify_token(request): return jsonify({"error": "Unauthorized"}), 401
472
+ data, rotation_period = request.json, data.get("rotation_period")
 
473
  if rotation_period is None: return jsonify({"error": "rotation_period is required"}), 400
474
  job_ref = db.reference(f"jobs/{job_id}")
475
  if not job_ref.get(): return jsonify({"error": "Job not found"}), 404
476
+ try: job_ref.update({"rotation_period": rotation_period}); return jsonify({"message": f"Rotation period updated to {rotation_period}s"}), 200
477
+ except Exception as e: logger.error(f"Error updating job settings for {job_id}: {e}", exc_info=True); return jsonify({"error": "Internal server error"}), 500
 
 
 
 
478
 
479
  @app.route("/add_special_case/<job_id>", methods=["POST"])
480
  def add_special_case(job_id):
 
482
  data = request.json
483
  point_id, member_id = data.get("point_id"), data.get("member_id")
484
  if not point_id or not member_id: return jsonify({"error": "point_id and member_id are required"}), 400
485
+ job_ref, job_data = db.reference(f"jobs/{job_id}"), job_ref.get()
 
486
  if not job_data: return jsonify({"error": "Job not found"}), 404
487
+ if not any(p["id"] == point_id for p in job_data.get("guarding_points", [])): return jsonify({"error": "Guarding point not found in job"}), 404
488
+ if not any(m["id"] == member_id for m in job_data.get("assigned_members", [])): return jsonify({"error": "Member not assigned to job"}), 404
 
 
489
  special_case = {"id": str(uuid.uuid4()), **data, "created_at": dt_class.now(timezone.utc).isoformat()}
490
  special_cases = job_data.get("special_cases", [])
491
  special_cases.append(special_case)
492
+ try: job_ref.update({"special_cases": special_cases}); return jsonify({"message": "Special case added", "special_case": special_case}), 200
493
+ except Exception as e: logger.error(f"Error adding special case to job {job_id}: {e}", exc_info=True); return jsonify({"error": "Internal server error"}), 500
 
 
 
 
494
 
495
  @app.route("/get_roster/<job_id>", methods=["GET"])
496
  def get_roster(job_id):
 
511
  "is_special_case": assignment.get("is_special_case", False),
512
  })
513
  return jsonify({"roster": roster_data, "job_name": job_data.get("name", "Unknown Job"), "total_shifts": len(assignments)}), 200
514
+ except Exception as e: logger.error(f"Error getting roster for {job_id}: {e}", exc_info=True); return jsonify({"error": "Internal server error"}), 500
 
 
515
 
516
  # === Run Server ===
517
  if __name__ == "__main__":