trovesuite 1.0.1__py3-none-any.whl → 1.0.24__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,155 @@
1
- import logging
2
- from typing import List
1
+ from datetime import datetime, timedelta, timezone, date, time
2
+ from typing import Dict, List, Optional, Callable
3
+ from ..configs.database import DatabaseManager
4
+ from ..configs.settings import db_settings
5
+ from ..configs.logging import get_logger
3
6
  from typing import TypeVar
4
-
5
- logger = logging.getLogger(__name__)
7
+ import hashlib
8
+ import uuid
9
+ import jwt
10
+ import json
11
+ from decimal import Decimal
12
+ from .templates import (
13
+ RESOURCE_STATUS_CHANGE_HTML_TEMPLATE,
14
+ RESOURCE_STATUS_CHANGE_TEXT_TEMPLATE,
15
+ )
16
+ import random
17
+ from ..notification import NotificationService
18
+ from ..notification import NotificationEmailServiceWriteDto
6
19
 
7
20
  T = TypeVar("T")
8
21
 
22
+ logger = get_logger("helper")
23
+ ACCESS_TOKEN_EXPIRE_MINUTES = 60
24
+
9
25
  class Helper:
10
26
 
27
+ @staticmethod
28
+ def generate_otp(length: int = 6) -> str:
29
+ """Generate a random numeric OTP of a given length."""
30
+ return ''.join([str(random.randint(0, 9)) for _ in range(length)])
31
+
32
+ @staticmethod
33
+ def current_date_time():
34
+
35
+ if 11 <= int(datetime.now().day) <= 13:
36
+ suffix = "th"
37
+
38
+ last_digit = int(datetime.now().day) % 10
39
+
40
+ if last_digit == 1:
41
+ suffix = "st"
42
+
43
+ elif last_digit == 2:
44
+ suffix = "nd"
45
+
46
+ elif last_digit == 3:
47
+ suffix = "rd"
48
+
49
+ else:
50
+ suffix = "th"
51
+
52
+ now = datetime.now()
53
+ day = now.day
54
+
55
+ # In helper.py
56
+ cdatetime = datetime.now().replace(microsecond=0, second=0)
57
+ cdate = now.strftime(f"%A {day}{suffix} %B, %Y")
58
+ ctime = now.strftime("%I:%M %p")
59
+
60
+ return {"cdate": cdate, "ctime": ctime, "cdatetime": cdatetime}
61
+
62
+ @staticmethod
63
+ def generate_unique_identifier(prefix: str):
64
+
65
+ max_length = 63
66
+ reserved = len(prefix) + len("_")
67
+
68
+ _uuid = f"{uuid.uuid4()}-{uuid.uuid1()}"
69
+ hash_digest = hashlib.sha256(_uuid.encode()).hexdigest()
70
+
71
+ return f"{prefix}_{hash_digest[:max_length - reserved]}"
72
+
73
+ @staticmethod
74
+ def generate_unique_resource_identifier(
75
+ prefix: str,
76
+ tenant_id: Optional[str],
77
+ extra_check_functions: Optional[List[Callable[[str], Optional[int]]]] = None,
78
+ ) -> str:
79
+ """Generate a unique identifier that does not already exist in the resource_ids table.
80
+
81
+ Args:
82
+ prefix: Prefix for the identifier (e.g., 'grp', 'uid').
83
+ tenant_id: Tenant schema name. If None, the main resource_ids table is checked.
84
+ extra_check_functions: Optional list of callables that receive the candidate
85
+ identifier and return a truthy value if the identifier already exists in
86
+ another table that should be considered.
87
+
88
+ Returns:
89
+ A unique identifier that does not exist in the resource_ids table (and passes
90
+ any additional uniqueness checks).
91
+ """
92
+ extra_checks = extra_check_functions or []
93
+
94
+ while True:
95
+ candidate = Helper.generate_unique_identifier(prefix=prefix)
96
+
97
+ try:
98
+ if tenant_id:
99
+ # For tenant-specific resource IDs, check shared schema with tenant_id filter
100
+ tenant_resource_table = getattr(db_settings, 'CORE_PLATFORM_TENANT_RESOURCE_ID_TABLE', None) or getattr(db_settings, 'CORE_PLATFORM_RESOURCE_ID_TABLE', None)
101
+ if tenant_resource_table:
102
+ resource_exists = DatabaseManager.execute_scalar(
103
+ f"""SELECT COUNT(1) FROM {tenant_resource_table}
104
+ WHERE tenant_id = %s AND id = %s""",
105
+ (tenant_id, candidate,),
106
+ )
107
+ else:
108
+ # Fallback: assume no conflict if table not configured
109
+ resource_exists = 0
110
+ else:
111
+ # For main schema resource IDs
112
+ main_resource_table = getattr(db_settings, 'CORE_PLATFORM_RESOURCE_ID_TABLE', None)
113
+ if main_resource_table:
114
+ resource_exists = DatabaseManager.execute_scalar(
115
+ f"""SELECT COUNT(1) FROM {main_resource_table}
116
+ WHERE id = %s""",
117
+ (candidate,),
118
+ )
119
+ else:
120
+ # Fallback: assume no conflict if table not configured
121
+ resource_exists = 0
122
+ except Exception as e:
123
+ logger.error(
124
+ f"Failed to validate uniqueness for resource identifier {candidate}: {str(e)}",
125
+ exc_info=True,
126
+ )
127
+ raise
128
+
129
+ if resource_exists and int(resource_exists or 0) > 0:
130
+ continue
131
+
132
+ duplicate_found = False
133
+ for check in extra_checks:
134
+ try:
135
+ result = check(candidate)
136
+ except Exception as e:
137
+ logger.error(
138
+ f"Error while executing additional uniqueness check for {candidate}: {str(e)}",
139
+ exc_info=True,
140
+ )
141
+ duplicate_found = True
142
+ break
143
+
144
+ if result and int(result or 0) > 0:
145
+ duplicate_found = True
146
+ break
147
+
148
+ if duplicate_found:
149
+ continue
150
+
151
+ return candidate
152
+
11
153
  @staticmethod
12
154
  def map_to_dto(data: list, dto_class: T) -> List[T]:
13
155
  """
@@ -20,7 +162,7 @@ class Helper:
20
162
  """
21
163
  if not data:
22
164
  return []
23
-
165
+
24
166
  try:
25
167
  result = []
26
168
  for row in data:
@@ -34,3 +176,481 @@ class Helper:
34
176
  except Exception as e:
35
177
  logger.error(f"Error mapping data to DTO: {str(e)}")
36
178
  raise
179
+
180
+ @staticmethod
181
+ def generate_jwt_token(data: dict, expires_delta: timedelta | None = None):
182
+
183
+ to_encode = data.copy()
184
+ if expires_delta:
185
+ expire = datetime.now(timezone.utc) + expires_delta
186
+ else:
187
+ expire = datetime.now(timezone.utc) + timedelta(minutes=15)
188
+ to_encode.update({"exp": expire})
189
+ encoded_jwt = jwt.encode(to_encode, db_settings.SECRET_KEY, algorithm=db_settings.ALGORITHM)
190
+ return encoded_jwt
191
+
192
+ @staticmethod
193
+ def log_activity(
194
+ tenant_id: str,
195
+ action: str,
196
+ resource_type: str,
197
+ old_data: dict | None = None,
198
+ new_data: dict | None = None,
199
+ description: str | None = None,
200
+ user_id: Optional[str] = None,
201
+ ):
202
+ """
203
+ Log an activity to the activity_logs table
204
+ Args:
205
+ tenant_id: The tenant ID
206
+ action: The action performed (e.g., 'create', 'update', 'delete')
207
+ resource_type: The type of resource (e.g., 'rt-user', 'rt-group')
208
+ old_data: The old data before the change (optional)
209
+ new_data: The new data after the change (optional)
210
+ description: Additional description (optional)
211
+ user_id: The ID of the user performing the action (optional)
212
+ """
213
+
214
+ def serialize_for_json(obj):
215
+ """
216
+ Convert objects to JSON-serializable format
217
+ """
218
+ if isinstance(obj, datetime):
219
+ return obj.isoformat()
220
+ elif isinstance(obj, date):
221
+ return obj.isoformat()
222
+ elif isinstance(obj, time):
223
+ return obj.isoformat()
224
+ elif isinstance(obj, Decimal):
225
+ return float(obj)
226
+ elif hasattr(obj, '__dict__'):
227
+ # Handle custom objects by converting to dict
228
+ return {k: serialize_for_json(v) for k, v in obj.__dict__.items()}
229
+ elif isinstance(obj, dict):
230
+ return {k: serialize_for_json(v) for k, v in obj.items()}
231
+ elif isinstance(obj, (list, tuple)):
232
+ return [serialize_for_json(item) for item in obj]
233
+ else:
234
+ return obj
235
+
236
+ try:
237
+ # Check if table name is set
238
+ tenant_activity_logs_table = getattr(db_settings, 'CORE_PLATFORM_ACTIVITY_LOGS_TABLE', None)
239
+ if not tenant_activity_logs_table:
240
+ logger.error("CORE_PLATFORM_ACTIVITY_LOGS_TABLE is not configured in settings")
241
+ return
242
+
243
+ log_id = Helper.generate_unique_identifier(prefix="alog")
244
+ time_info = Helper.current_date_time()
245
+ cdate = time_info["cdate"]
246
+ ctime = time_info["ctime"]
247
+ cdatetime = time_info["cdatetime"]
248
+
249
+ # Serialize data to handle datetime and other non-JSON serializable objects
250
+ serialized_old_data = serialize_for_json(old_data) if old_data else None
251
+ serialized_new_data = serialize_for_json(new_data) if new_data else None
252
+
253
+ # Convert to JSONB-compatible format
254
+ old_json = json.dumps(serialized_old_data) if serialized_old_data else None
255
+ new_json = json.dumps(serialized_new_data) if serialized_new_data else None
256
+
257
+ # Fetch user information if user_id is provided
258
+ performed_by_email = None
259
+ performed_by_contact = None
260
+ performed_by_fullname = None
261
+
262
+ if user_id:
263
+ try:
264
+ logger.debug(f"Fetching user information for user_id={user_id}")
265
+ main_users_table = getattr(db_settings, 'CORE_PLATFORM_USERS_TABLE', 'core_platform.cp_users')
266
+ user_data = DatabaseManager.execute_query(
267
+ f"""SELECT email, contact, fullname
268
+ FROM {main_users_table}
269
+ WHERE id = %s AND tenant_id = %s""",
270
+ (user_id, tenant_id)
271
+ )
272
+ logger.debug(f"User data query returned: {user_data}")
273
+
274
+ if user_data and len(user_data) > 0:
275
+ # RealDictRow result - all cursors now use RealDictCursor
276
+ user_record = user_data[0]
277
+ performed_by_email = user_record.get("email")
278
+ performed_by_contact = user_record.get("contact")
279
+ performed_by_fullname = user_record.get("fullname")
280
+
281
+ logger.debug(f"Fetched user info - email: {performed_by_email}, contact: {performed_by_contact}, fullname: {performed_by_fullname}")
282
+ else:
283
+ logger.warning(f"No user found with user_id={user_id}")
284
+ except Exception as e:
285
+ logger.warning(f"Failed to fetch user information for user_id={user_id}: {str(e)}", exc_info=True)
286
+
287
+ logger.info(f"Attempting to log activity: tenant_id={tenant_id}, action={action}, resource_type={resource_type}")
288
+ logger.debug(f"Activity log values - performed_by_email: {performed_by_email}, performed_by_contact: {performed_by_contact}, performed_by_fullname: {performed_by_fullname}")
289
+
290
+ result = DatabaseManager.execute_update(
291
+ f"""INSERT INTO {tenant_activity_logs_table}
292
+ (id, tenant_id, action, resource_type, old_data, new_data, description, performed_by_email, performed_by_contact, performed_by_fullname, cdate, ctime, cdatetime)
293
+ VALUES (%s, %s, %s, NULLIF(%s, '')::text, %s::jsonb, %s::jsonb, %s, %s, %s, %s, %s, %s, %s)""",
294
+ (log_id, tenant_id, action, resource_type, old_json, new_json, description, performed_by_email, performed_by_contact, performed_by_fullname, cdate, ctime, cdatetime),
295
+ )
296
+
297
+ logger.info(f"Activity logged successfully. Rows affected: {result}")
298
+
299
+ # Verify the inserted data
300
+ if result > 0:
301
+ verify_data = DatabaseManager.execute_query(
302
+ f"""SELECT performed_by_email, performed_by_contact, performed_by_fullname
303
+ FROM {tenant_activity_logs_table}
304
+ WHERE tenant_id = %s AND id = %s""",
305
+ (tenant_id, log_id,)
306
+ )
307
+ if verify_data:
308
+ logger.debug(f"Verification - Inserted activity log data: {verify_data[0]}")
309
+ else:
310
+ logger.warning(f"Could not verify inserted activity log with id={log_id}")
311
+
312
+ except Exception as e:
313
+ # Log the error but don't fail the main operation
314
+ logger.error(f"Failed to log activity: {str(e)}", exc_info=True)
315
+
316
+ @staticmethod
317
+ def send_notification(
318
+ email: str,
319
+ subject: str,
320
+ text_template: str,
321
+ html_template: str,
322
+ variables: Optional[Dict[str, str]] = None,
323
+ ):
324
+ """Send a dynamic notification email."""
325
+ current_time = Helper.current_date_time()
326
+ cdate = current_time["cdate"]
327
+ ctime = current_time["ctime"]
328
+
329
+ # Add defaults if not provided
330
+ if variables is None:
331
+ variables = {}
332
+
333
+ # Include date/time automatically
334
+ variables.update({"cdate": cdate, "ctime": ctime})
335
+
336
+ def _escape(value):
337
+ if isinstance(value, str):
338
+ return value.replace("{", "{{").replace("}", "}}")
339
+ return value
340
+
341
+ safe_variables = {key: _escape(value) for key, value in variables.items()}
342
+
343
+ # Format templates dynamically using placeholders
344
+ text_message = text_template.format(**safe_variables)
345
+ html_message = html_template.format(**safe_variables)
346
+
347
+ # Build email data
348
+ mail_sender_email = getattr(db_settings, 'MAIL_SENDER_EMAIL', None)
349
+ mail_sender_pwd = getattr(db_settings, 'MAIL_SENDER_PWD', None)
350
+
351
+ if not mail_sender_email or not mail_sender_pwd:
352
+ logger.error("MAIL_SENDER_EMAIL or MAIL_SENDER_PWD not configured in settings")
353
+ return
354
+
355
+ notification_data = NotificationEmailServiceWriteDto(
356
+ sender_email=mail_sender_email,
357
+ receiver_email=email,
358
+ password=mail_sender_pwd,
359
+ text_message=text_message,
360
+ html_message=html_message,
361
+ subject=subject,
362
+ )
363
+
364
+ # Send email
365
+ NotificationService.send_email(data=notification_data)
366
+
367
+ @staticmethod
368
+ def format_login_type_text(
369
+ login_type: str,
370
+ specific_days: Optional[List[str]] = None,
371
+ custom_start: Optional[str] = None,
372
+ custom_end: Optional[str] = None
373
+ ) -> str:
374
+ """
375
+ Format login type information into human-readable text.
376
+
377
+ Args:
378
+ login_type: The type of login access (always_login, specific_days, custom)
379
+ specific_days: List of days when user can login (for specific_days type)
380
+ custom_start: Start datetime for custom login period
381
+ custom_end: End datetime for custom login period
382
+
383
+ Returns:
384
+ Formatted login type description
385
+ """
386
+ if login_type == "always_login":
387
+ return "Anytime - You can login at any time"
388
+ elif login_type == "specific_days":
389
+ if specific_days:
390
+ days_str = ", ".join(specific_days)
391
+ return f"Specific Days - You can login on: {days_str}"
392
+ return "Specific Days"
393
+ elif login_type == "custom":
394
+ if custom_start and custom_end:
395
+ return f"Custom Period - From {custom_start} to {custom_end}"
396
+ elif custom_start:
397
+ return f"Custom Period - Starting from {custom_start}"
398
+ return "Custom Period"
399
+ else:
400
+ return "Login access granted"
401
+
402
+ @staticmethod
403
+ def get_users_with_admin_roles(tenant_id: str) -> List[Dict[str, str]]:
404
+ """
405
+ Get all users with role-owner or role-admin roles in a tenant.
406
+ Checks both direct role assignments and group-based role assignments.
407
+
408
+ Args:
409
+ tenant_id: The tenant ID to search within
410
+
411
+ Returns:
412
+ List of dictionaries containing user_id, email, and name
413
+ """
414
+ try:
415
+ # Get table names from settings with fallbacks
416
+ main_users_table = getattr(db_settings, 'CORE_PLATFORM_USERS_TABLE', 'core_platform.cp_users')
417
+ main_roles_table = getattr(db_settings, 'CORE_PLATFORM_ROLES_TABLE', 'core_platform.cp_roles')
418
+ tenant_assign_roles_table = getattr(db_settings, 'CORE_PLATFORM_ASSIGN_ROLES_TABLE', 'core_platform.cp_assign_roles')
419
+ tenant_user_groups_table = getattr(db_settings, 'CORE_PLATFORM_USER_GROUPS_TABLE', 'core_platform.cp_user_groups')
420
+
421
+ # Query to get users with admin roles - both direct and through groups
422
+ query = f"""
423
+ SELECT DISTINCT u.id as user_id, u.email, u.fullname
424
+ FROM {main_users_table} u
425
+ WHERE u.tenant_id = %s
426
+ AND u.delete_status = 'NOT_DELETED'
427
+ AND u.is_active = true
428
+ AND u.can_login = true
429
+ AND (
430
+ -- Direct role assignment
431
+ (u.id, u.tenant_id) IN (
432
+ SELECT ar.user_id, ar.tenant_id
433
+ FROM {tenant_assign_roles_table} ar
434
+ INNER JOIN {main_roles_table} r ON ar.role_id = r.id AND ar.tenant_id = r.tenant_id
435
+ WHERE ar.tenant_id = %s
436
+ AND ar.delete_status = 'NOT_DELETED'
437
+ AND ar.is_active = true
438
+ AND r.delete_status = 'NOT_DELETED'
439
+ AND r.is_active = true
440
+ AND r.role_name IN ('role-owner', 'role-admin')
441
+ AND ar.user_id IS NOT NULL
442
+ )
443
+ OR
444
+ -- Group-based role assignment
445
+ (u.id, u.tenant_id) IN (
446
+ SELECT ug.user_id, ug.tenant_id
447
+ FROM {tenant_user_groups_table} ug
448
+ INNER JOIN {tenant_assign_roles_table} ar ON ug.group_id = ar.group_id AND ug.tenant_id = ar.tenant_id
449
+ INNER JOIN {main_roles_table} r ON ar.role_id = r.id AND ar.tenant_id = r.tenant_id
450
+ WHERE ug.tenant_id = %s
451
+ AND ar.tenant_id = %s
452
+ AND ug.delete_status = 'NOT_DELETED'
453
+ AND ug.is_active = true
454
+ AND ar.delete_status = 'NOT_DELETED'
455
+ AND ar.is_active = true
456
+ AND r.delete_status = 'NOT_DELETED'
457
+ AND r.is_active = true
458
+ AND r.role_name IN ('role-owner', 'role-admin')
459
+ AND ar.group_id IS NOT NULL
460
+ )
461
+ )
462
+ """
463
+
464
+ results = DatabaseManager.execute_query(query, (tenant_id, tenant_id, tenant_id, tenant_id,))
465
+
466
+ admin_users = []
467
+ if results:
468
+ for row in results:
469
+ row_dict = dict(row)
470
+ admin_users.append(
471
+ {
472
+ "user_id": row_dict.get("user_id"),
473
+ "email": row_dict.get("email"),
474
+ "name": (row_dict.get("fullname") or "").strip() or "Admin",
475
+ }
476
+ )
477
+
478
+ logger.info(
479
+ f"Found {len(admin_users)} admin users for tenant {tenant_id}",
480
+ extra={"extra_fields": {"tenant_id": tenant_id, "admin_count": len(admin_users)}}
481
+ )
482
+
483
+ return admin_users
484
+
485
+ except Exception as e:
486
+ logger.error(
487
+ f"Failed to get admin users for tenant {tenant_id}: {str(e)}",
488
+ extra={"extra_fields": {"tenant_id": tenant_id, "error": str(e)}},
489
+ exc_info=True
490
+ )
491
+ return []
492
+
493
+ @staticmethod
494
+ def notify_admins_of_delete_status_change(
495
+ tenant_id: str,
496
+ resource_type: str,
497
+ resource_name: str,
498
+ status: str,
499
+ actor_user_id: Optional[str] = None,
500
+ message: Optional[str] = None,
501
+ ) -> None:
502
+ """
503
+ Notify all owner/admin users when a resource delete_status changes.
504
+
505
+ Args:
506
+ tenant_id: Tenant identifier
507
+ resource_type: Human-readable resource type (e.g., "User", "Group")
508
+ resource_name: Human-readable resource name for context
509
+ status: New delete_status value ('PENDING', 'DELETED', 'NOT_DELETED')
510
+ actor_user_id: User ID who performed the action
511
+ message: Optional additional message supplied by the actor
512
+ """
513
+ status_key = (status or "").upper()
514
+ status_config = {
515
+ "PENDING": {
516
+ "title": "Deletion Pending Approval",
517
+ "subject": "Pending deletion request for {resource_name}",
518
+ "description": (
519
+ "A deletion request has been submitted for the {resource_type} "
520
+ "\"{resource_name}\" and is awaiting approval."
521
+ ),
522
+ "display": "Pending Deletion",
523
+ "color": "#ffc107",
524
+ "icon": "⏳",
525
+ },
526
+ "DELETED": {
527
+ "title": "Resource Deleted",
528
+ "subject": "Resource deleted: {resource_name}",
529
+ "description": (
530
+ "The {resource_type} \"{resource_name}\" has been deleted."
531
+ ),
532
+ "display": "Deleted",
533
+ "color": "#dc3545",
534
+ "icon": "🗑️",
535
+ },
536
+ "NOT_DELETED": {
537
+ "title": "Resource Restored",
538
+ "subject": "Resource restored: {resource_name}",
539
+ "description": (
540
+ "The {resource_type} \"{resource_name}\" has been restored."
541
+ ),
542
+ "display": "Restored",
543
+ "color": "#28a745",
544
+ "icon": "♻️",
545
+ },
546
+ }
547
+
548
+ config = status_config.get(status_key, status_config["DELETED"])
549
+
550
+ try:
551
+ admin_users = Helper.get_users_with_admin_roles(tenant_id)
552
+ if not admin_users:
553
+ logger.info(
554
+ "No admin users found to notify for status change",
555
+ extra={
556
+ "extra_fields": {
557
+ "tenant_id": tenant_id,
558
+ "resource_type": resource_type,
559
+ "resource_name": resource_name,
560
+ "status": status_key,
561
+ }
562
+ },
563
+ )
564
+ return
565
+
566
+ actor_name = "System"
567
+ actor_email = "no-reply@trovesuite.com"
568
+
569
+ if actor_user_id:
570
+ main_users_table = getattr(db_settings, 'CORE_PLATFORM_USERS_TABLE', 'core_platform.cp_users')
571
+ actor_details = DatabaseManager.execute_query(
572
+ f"""SELECT fullname, email
573
+ FROM {main_users_table}
574
+ WHERE id = %s AND tenant_id = %s""",
575
+ (actor_user_id, tenant_id),
576
+ )
577
+
578
+ if actor_details and len(actor_details) > 0:
579
+ actor_data = dict(actor_details[0])
580
+ actor_name_candidate = (actor_data.get("fullname") or "").strip()
581
+ actor_name = actor_name_candidate or actor_data.get("email") or actor_name
582
+ actor_email = actor_data.get("email") or actor_email
583
+
584
+ resource_name_display = resource_name or "Unknown resource"
585
+ resource_type_display = resource_type or "Resource"
586
+
587
+ message_text = f"Message: {message}\n" if message else ""
588
+ message_row_style = "" if message else "display: none;"
589
+ message_value = message or "No additional message provided."
590
+
591
+ subject = config["subject"].format(
592
+ resource_name=resource_name_display,
593
+ resource_type=resource_type_display,
594
+ )
595
+
596
+ status_description = config["description"].format(
597
+ resource_name=resource_name_display,
598
+ resource_type=resource_type_display,
599
+ )
600
+
601
+ status_title = config["title"]
602
+
603
+ app_url = getattr(db_settings, 'APP_URL', 'https://app.trovesuite.com')
604
+
605
+ for admin in admin_users:
606
+ admin_name = admin.get("name") or "Admin"
607
+ Helper.send_notification(
608
+ email=admin["email"],
609
+ subject=subject,
610
+ text_template=RESOURCE_STATUS_CHANGE_TEXT_TEMPLATE,
611
+ html_template=RESOURCE_STATUS_CHANGE_HTML_TEMPLATE,
612
+ variables={
613
+ "admin_name": admin_name,
614
+ "resource_type": resource_type_display,
615
+ "resource_name": resource_name_display,
616
+ "status_display": config["display"],
617
+ "status_description": status_description,
618
+ "status_title": status_title,
619
+ "status_color": config["color"],
620
+ "status_icon": config["icon"],
621
+ "actor_name": actor_name,
622
+ "actor_email": actor_email,
623
+ "message": message_value,
624
+ "message_text": message_text,
625
+ "message_row_style": message_row_style,
626
+ "app_url": app_url,
627
+ },
628
+ )
629
+
630
+ logger.info(
631
+ "Delete status change notifications sent",
632
+ extra={
633
+ "extra_fields": {
634
+ "tenant_id": tenant_id,
635
+ "resource_type": resource_type_display,
636
+ "resource_name": resource_name_display,
637
+ "status": status_key,
638
+ "admin_count": len(admin_users),
639
+ }
640
+ },
641
+ )
642
+
643
+ except Exception as e:
644
+ logger.error(
645
+ f"Failed to send delete status change notifications: {str(e)}",
646
+ extra={
647
+ "extra_fields": {
648
+ "tenant_id": tenant_id,
649
+ "resource_type": resource_type,
650
+ "resource_name": resource_name,
651
+ "status": status_key,
652
+ "error": str(e),
653
+ }
654
+ },
655
+ exc_info=True,
656
+ )