trovesuite 1.0.1__py3-none-any.whl → 1.0.31__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,156 @@
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, use CORE_PLATFORM_RESOURCE_ID_TABLE
100
+ # This table has tenant_id column for filtering
101
+ resource_table = getattr(db_settings, 'CORE_PLATFORM_RESOURCE_ID_TABLE', None)
102
+ if resource_table:
103
+ resource_exists = DatabaseManager.execute_scalar(
104
+ f"""SELECT COUNT(1) FROM {resource_table}
105
+ WHERE tenant_id = %s AND id = %s""",
106
+ (tenant_id, candidate,),
107
+ )
108
+ else:
109
+ # Fallback: assume no conflict if table not configured
110
+ resource_exists = 0
111
+ else:
112
+ # For main/shared schema resource IDs (no tenant_id)
113
+ main_resource_table = getattr(db_settings, 'CORE_PLATFORM_RESOURCE_ID_TABLE', None)
114
+ if main_resource_table:
115
+ resource_exists = DatabaseManager.execute_scalar(
116
+ f"""SELECT COUNT(1) FROM {main_resource_table}
117
+ WHERE id = %s""",
118
+ (candidate,),
119
+ )
120
+ else:
121
+ # Fallback: assume no conflict if table not configured
122
+ resource_exists = 0
123
+ except Exception as e:
124
+ logger.error(
125
+ f"Failed to validate uniqueness for resource identifier {candidate}: {str(e)}",
126
+ exc_info=True,
127
+ )
128
+ raise
129
+
130
+ if resource_exists and int(resource_exists or 0) > 0:
131
+ continue
132
+
133
+ duplicate_found = False
134
+ for check in extra_checks:
135
+ try:
136
+ result = check(candidate)
137
+ except Exception as e:
138
+ logger.error(
139
+ f"Error while executing additional uniqueness check for {candidate}: {str(e)}",
140
+ exc_info=True,
141
+ )
142
+ duplicate_found = True
143
+ break
144
+
145
+ if result and int(result or 0) > 0:
146
+ duplicate_found = True
147
+ break
148
+
149
+ if duplicate_found:
150
+ continue
151
+
152
+ return candidate
153
+
11
154
  @staticmethod
12
155
  def map_to_dto(data: list, dto_class: T) -> List[T]:
13
156
  """
@@ -20,7 +163,7 @@ class Helper:
20
163
  """
21
164
  if not data:
22
165
  return []
23
-
166
+
24
167
  try:
25
168
  result = []
26
169
  for row in data:
@@ -34,3 +177,569 @@ class Helper:
34
177
  except Exception as e:
35
178
  logger.error(f"Error mapping data to DTO: {str(e)}")
36
179
  raise
180
+
181
+ @staticmethod
182
+ def generate_jwt_token(data: dict, expires_delta: timedelta | None = None):
183
+
184
+ to_encode = data.copy()
185
+ if expires_delta:
186
+ expire = datetime.now(timezone.utc) + expires_delta
187
+ else:
188
+ expire = datetime.now(timezone.utc) + timedelta(minutes=15)
189
+ to_encode.update({"exp": expire})
190
+ encoded_jwt = jwt.encode(to_encode, db_settings.SECRET_KEY, algorithm=db_settings.ALGORITHM)
191
+ return encoded_jwt
192
+
193
+ @staticmethod
194
+ def log_activity(
195
+ tenant_id: str,
196
+ action: str,
197
+ resource_type: str,
198
+ old_data: dict | None = None,
199
+ new_data: dict | None = None,
200
+ description: str | None = None,
201
+ user_id: Optional[str] = None,
202
+ ):
203
+ """
204
+ Log an activity to the activity_logs table
205
+ Args:
206
+ tenant_id: The tenant ID
207
+ action: The action performed (e.g., 'create', 'update', 'delete')
208
+ resource_type: The type of resource (e.g., 'rt-user', 'rt-group')
209
+ old_data: The old data before the change (optional)
210
+ new_data: The new data after the change (optional)
211
+ description: Additional description (optional)
212
+ user_id: The ID of the user performing the action (optional)
213
+ """
214
+
215
+ def serialize_for_json(obj):
216
+ """
217
+ Convert objects to JSON-serializable format
218
+ """
219
+ if isinstance(obj, datetime):
220
+ return obj.isoformat()
221
+ elif isinstance(obj, date):
222
+ return obj.isoformat()
223
+ elif isinstance(obj, time):
224
+ return obj.isoformat()
225
+ elif isinstance(obj, Decimal):
226
+ return float(obj)
227
+ elif hasattr(obj, '__dict__'):
228
+ # Handle custom objects by converting to dict
229
+ return {k: serialize_for_json(v) for k, v in obj.__dict__.items()}
230
+ elif isinstance(obj, dict):
231
+ return {k: serialize_for_json(v) for k, v in obj.items()}
232
+ elif isinstance(obj, (list, tuple)):
233
+ return [serialize_for_json(item) for item in obj]
234
+ else:
235
+ return obj
236
+
237
+ try:
238
+ # Check if table name is set
239
+ tenant_activity_logs_table = getattr(db_settings, 'CORE_PLATFORM_ACTIVITY_LOGS_TABLE', None)
240
+ if not tenant_activity_logs_table:
241
+ logger.error("CORE_PLATFORM_ACTIVITY_LOGS_TABLE is not configured in settings")
242
+ return
243
+
244
+ log_id = Helper.generate_unique_identifier(prefix="alog")
245
+ time_info = Helper.current_date_time()
246
+ cdate = time_info["cdate"]
247
+ ctime = time_info["ctime"]
248
+ cdatetime = time_info["cdatetime"]
249
+
250
+ # Serialize data to handle datetime and other non-JSON serializable objects
251
+ serialized_old_data = serialize_for_json(old_data) if old_data else None
252
+ serialized_new_data = serialize_for_json(new_data) if new_data else None
253
+
254
+ # Convert to JSONB-compatible format
255
+ old_json = json.dumps(serialized_old_data) if serialized_old_data else None
256
+ new_json = json.dumps(serialized_new_data) if serialized_new_data else None
257
+
258
+ # Fetch user information if user_id is provided
259
+ performed_by_email = None
260
+ performed_by_contact = None
261
+ performed_by_fullname = None
262
+
263
+ if user_id:
264
+ try:
265
+ logger.debug(f"Fetching user information for user_id={user_id}")
266
+ main_users_table = getattr(db_settings, 'CORE_PLATFORM_USERS_TABLE', 'core_platform.cp_users')
267
+ user_data = DatabaseManager.execute_query(
268
+ f"""SELECT email, contact, fullname
269
+ FROM {main_users_table}
270
+ WHERE id = %s AND tenant_id = %s""",
271
+ (user_id, tenant_id)
272
+ )
273
+ logger.debug(f"User data query returned: {user_data}")
274
+
275
+ if user_data and len(user_data) > 0:
276
+ # RealDictRow result - all cursors now use RealDictCursor
277
+ user_record = user_data[0]
278
+ performed_by_email = user_record.get("email")
279
+ performed_by_contact = user_record.get("contact")
280
+ performed_by_fullname = user_record.get("fullname")
281
+
282
+ logger.debug(f"Fetched user info - email: {performed_by_email}, contact: {performed_by_contact}, fullname: {performed_by_fullname}")
283
+ else:
284
+ logger.warning(f"No user found with user_id={user_id}")
285
+ except Exception as e:
286
+ logger.warning(f"Failed to fetch user information for user_id={user_id}: {str(e)}", exc_info=True)
287
+
288
+ logger.info(f"Attempting to log activity: tenant_id={tenant_id}, action={action}, resource_type={resource_type}")
289
+ logger.debug(f"Activity log values - performed_by_email: {performed_by_email}, performed_by_contact: {performed_by_contact}, performed_by_fullname: {performed_by_fullname}")
290
+
291
+ result = DatabaseManager.execute_update(
292
+ f"""INSERT INTO {tenant_activity_logs_table}
293
+ (id, tenant_id, action, resource_type, old_data, new_data, description, performed_by_email, performed_by_contact, performed_by_fullname, cdate, ctime, cdatetime)
294
+ VALUES (%s, %s, %s, NULLIF(%s, '')::text, %s::jsonb, %s::jsonb, %s, %s, %s, %s, %s, %s, %s)""",
295
+ (log_id, tenant_id, action, resource_type, old_json, new_json, description, performed_by_email, performed_by_contact, performed_by_fullname, cdate, ctime, cdatetime),
296
+ )
297
+
298
+ logger.info(f"Activity logged successfully. Rows affected: {result}")
299
+
300
+ # Verify the inserted data
301
+ if result > 0:
302
+ verify_data = DatabaseManager.execute_query(
303
+ f"""SELECT performed_by_email, performed_by_contact, performed_by_fullname
304
+ FROM {tenant_activity_logs_table}
305
+ WHERE tenant_id = %s AND id = %s""",
306
+ (tenant_id, log_id,)
307
+ )
308
+ if verify_data:
309
+ logger.debug(f"Verification - Inserted activity log data: {verify_data[0]}")
310
+ else:
311
+ logger.warning(f"Could not verify inserted activity log with id={log_id}")
312
+
313
+ except Exception as e:
314
+ # Log the error but don't fail the main operation
315
+ logger.error(f"Failed to log activity: {str(e)}", exc_info=True)
316
+
317
+ @staticmethod
318
+ def get_email_credentials(tenant_id: Optional[str] = None) -> tuple[Optional[str], Optional[str]]:
319
+ """
320
+ Get email credentials for sending notifications.
321
+ If tenant_id is provided and tenant has custom email credentials, use those.
322
+ Otherwise, fall back to system default credentials.
323
+
324
+ Args:
325
+ tenant_id: Optional tenant ID to check for tenant-specific credentials
326
+
327
+ Returns:
328
+ Tuple of (email, password) or (None, None) if not configured
329
+ """
330
+ # If tenant_id is provided, check for tenant-specific credentials
331
+ if tenant_id:
332
+ try:
333
+ credentials_table = getattr(db_settings, 'CORE_PLATFORM_NOTIFICATION_EMAIL_CREDENTIALS_TABLE', 'core_platform.cp_notification_email_credentials')
334
+ tenant_credentials = DatabaseManager.execute_query(
335
+ f"""SELECT notification_email, notification_password
336
+ FROM {credentials_table}
337
+ WHERE tenant_id = %s
338
+ AND delete_status = 'NOT_DELETED'
339
+ AND is_active = true""",
340
+ (tenant_id,),
341
+ )
342
+
343
+ if tenant_credentials and len(tenant_credentials) > 0:
344
+ tenant_email = tenant_credentials[0].get('notification_email')
345
+ tenant_password = tenant_credentials[0].get('notification_password')
346
+
347
+ # If tenant has both email and password configured, use them
348
+ if tenant_email and tenant_password:
349
+ logger.info(
350
+ f"Using tenant-specific email credentials for tenant {tenant_id}",
351
+ extra={
352
+ "extra_fields": {
353
+ "tenant_id": tenant_id,
354
+ "email": tenant_email
355
+ }
356
+ }
357
+ )
358
+ return (tenant_email, tenant_password)
359
+ except Exception as e:
360
+ logger.warning(
361
+ f"Error fetching tenant email credentials for tenant {tenant_id}: {str(e)}",
362
+ extra={
363
+ "extra_fields": {
364
+ "tenant_id": tenant_id,
365
+ "error": str(e)
366
+ }
367
+ }
368
+ )
369
+
370
+ # Fall back to system default credentials
371
+ mail_sender_email = getattr(db_settings, 'MAIL_SENDER_EMAIL', None)
372
+ mail_sender_pwd = getattr(db_settings, 'MAIL_SENDER_PWD', None)
373
+
374
+ if tenant_id:
375
+ logger.info(
376
+ f"Using system default email credentials for tenant {tenant_id}",
377
+ extra={
378
+ "extra_fields": {
379
+ "tenant_id": tenant_id
380
+ }
381
+ }
382
+ )
383
+
384
+ return (mail_sender_email, mail_sender_pwd)
385
+
386
+ @staticmethod
387
+ def send_notification(
388
+ email: str,
389
+ subject: str,
390
+ text_template: str,
391
+ html_template: str,
392
+ variables: Optional[Dict[str, str]] = None,
393
+ tenant_id: Optional[str] = None,
394
+ ):
395
+ """
396
+ Send a dynamic notification email.
397
+
398
+ Args:
399
+ email: Recipient email address
400
+ subject: Email subject
401
+ text_template: Plain text email template
402
+ html_template: HTML email template
403
+ variables: Optional dictionary of variables to format templates
404
+ tenant_id: Optional tenant ID to use tenant-specific email credentials
405
+ """
406
+ current_time = Helper.current_date_time()
407
+ cdate = current_time["cdate"]
408
+ ctime = current_time["ctime"]
409
+
410
+ # Add defaults if not provided
411
+ if variables is None:
412
+ variables = {}
413
+
414
+ # Include date/time automatically
415
+ variables.update({"cdate": cdate, "ctime": ctime})
416
+
417
+ def _escape(value):
418
+ if isinstance(value, str):
419
+ return value.replace("{", "{{").replace("}", "}}")
420
+ return value
421
+
422
+ safe_variables = {key: _escape(value) for key, value in variables.items()}
423
+
424
+ # Format templates dynamically using placeholders
425
+ text_message = text_template.format(**safe_variables)
426
+ html_message = html_template.format(**safe_variables)
427
+
428
+ # Get email credentials (tenant-specific or system default)
429
+ mail_sender_email, mail_sender_pwd = Helper.get_email_credentials(tenant_id)
430
+
431
+ if not mail_sender_email or not mail_sender_pwd:
432
+ logger.error(
433
+ "Email credentials not configured. MAIL_SENDER_EMAIL or MAIL_SENDER_PWD not set in settings",
434
+ extra={
435
+ "extra_fields": {
436
+ "tenant_id": tenant_id,
437
+ "receiver_email": email
438
+ }
439
+ }
440
+ )
441
+ return
442
+
443
+ notification_data = NotificationEmailServiceWriteDto(
444
+ sender_email=mail_sender_email,
445
+ receiver_email=email,
446
+ password=mail_sender_pwd,
447
+ text_message=text_message,
448
+ html_message=html_message,
449
+ subject=subject,
450
+ )
451
+
452
+ # Send email
453
+ NotificationService.send_email(data=notification_data)
454
+
455
+ @staticmethod
456
+ def format_login_type_text(
457
+ login_type: str,
458
+ specific_days: Optional[List[str]] = None,
459
+ custom_start: Optional[str] = None,
460
+ custom_end: Optional[str] = None
461
+ ) -> str:
462
+ """
463
+ Format login type information into human-readable text.
464
+
465
+ Args:
466
+ login_type: The type of login access (always_login, specific_days, custom)
467
+ specific_days: List of days when user can login (for specific_days type)
468
+ custom_start: Start datetime for custom login period
469
+ custom_end: End datetime for custom login period
470
+
471
+ Returns:
472
+ Formatted login type description
473
+ """
474
+ if login_type == "always_login":
475
+ return "Anytime - You can login at any time"
476
+ elif login_type == "specific_days":
477
+ if specific_days:
478
+ days_str = ", ".join(specific_days)
479
+ return f"Specific Days - You can login on: {days_str}"
480
+ return "Specific Days"
481
+ elif login_type == "custom":
482
+ if custom_start and custom_end:
483
+ return f"Custom Period - From {custom_start} to {custom_end}"
484
+ elif custom_start:
485
+ return f"Custom Period - Starting from {custom_start}"
486
+ return "Custom Period"
487
+ else:
488
+ return "Login access granted"
489
+
490
+ @staticmethod
491
+ def get_users_with_admin_roles(tenant_id: str) -> List[Dict[str, str]]:
492
+ """
493
+ Get all users with role-owner or role-admin roles in a tenant.
494
+ Checks both direct role assignments and group-based role assignments.
495
+
496
+ Args:
497
+ tenant_id: The tenant ID to search within
498
+
499
+ Returns:
500
+ List of dictionaries containing user_id, email, and name
501
+ """
502
+ try:
503
+ # Get table names from settings with fallbacks
504
+ main_users_table = getattr(db_settings, 'CORE_PLATFORM_USERS_TABLE', 'core_platform.cp_users')
505
+ main_roles_table = getattr(db_settings, 'CORE_PLATFORM_ROLES_TABLE', 'core_platform.cp_roles')
506
+ tenant_assign_roles_table = getattr(db_settings, 'CORE_PLATFORM_ASSIGN_ROLES_TABLE', 'core_platform.cp_assign_roles')
507
+ tenant_user_groups_table = getattr(db_settings, 'CORE_PLATFORM_USER_GROUPS_TABLE', 'core_platform.cp_user_groups')
508
+
509
+ # Query to get users with admin roles - both direct and through groups
510
+ query = f"""
511
+ SELECT DISTINCT u.id as user_id, u.email, u.fullname
512
+ FROM {main_users_table} u
513
+ WHERE u.tenant_id = %s
514
+ AND u.delete_status = 'NOT_DELETED'
515
+ AND u.is_active = true
516
+ AND u.can_login = true
517
+ AND (
518
+ -- Direct role assignment
519
+ (u.id, u.tenant_id) IN (
520
+ SELECT ar.user_id, ar.tenant_id
521
+ FROM {tenant_assign_roles_table} ar
522
+ INNER JOIN {main_roles_table} r ON ar.role_id = r.id AND ar.tenant_id = r.tenant_id
523
+ WHERE ar.tenant_id = %s
524
+ AND ar.delete_status = 'NOT_DELETED'
525
+ AND ar.is_active = true
526
+ AND r.delete_status = 'NOT_DELETED'
527
+ AND r.is_active = true
528
+ AND r.role_name IN ('role-owner', 'role-admin')
529
+ AND ar.user_id IS NOT NULL
530
+ )
531
+ OR
532
+ -- Group-based role assignment
533
+ (u.id, u.tenant_id) IN (
534
+ SELECT ug.user_id, ug.tenant_id
535
+ FROM {tenant_user_groups_table} ug
536
+ INNER JOIN {tenant_assign_roles_table} ar ON ug.group_id = ar.group_id AND ug.tenant_id = ar.tenant_id
537
+ INNER JOIN {main_roles_table} r ON ar.role_id = r.id AND ar.tenant_id = r.tenant_id
538
+ WHERE ug.tenant_id = %s
539
+ AND ar.tenant_id = %s
540
+ AND ug.delete_status = 'NOT_DELETED'
541
+ AND ug.is_active = true
542
+ AND ar.delete_status = 'NOT_DELETED'
543
+ AND ar.is_active = true
544
+ AND r.delete_status = 'NOT_DELETED'
545
+ AND r.is_active = true
546
+ AND r.role_name IN ('role-owner', 'role-admin')
547
+ AND ar.group_id IS NOT NULL
548
+ )
549
+ )
550
+ """
551
+
552
+ results = DatabaseManager.execute_query(query, (tenant_id, tenant_id, tenant_id, tenant_id,))
553
+
554
+ admin_users = []
555
+ if results:
556
+ for row in results:
557
+ row_dict = dict(row)
558
+ admin_users.append(
559
+ {
560
+ "user_id": row_dict.get("user_id"),
561
+ "email": row_dict.get("email"),
562
+ "name": (row_dict.get("fullname") or "").strip() or "Admin",
563
+ }
564
+ )
565
+
566
+ logger.info(
567
+ f"Found {len(admin_users)} admin users for tenant {tenant_id}",
568
+ extra={"extra_fields": {"tenant_id": tenant_id, "admin_count": len(admin_users)}}
569
+ )
570
+
571
+ return admin_users
572
+
573
+ except Exception as e:
574
+ logger.error(
575
+ f"Failed to get admin users for tenant {tenant_id}: {str(e)}",
576
+ extra={"extra_fields": {"tenant_id": tenant_id, "error": str(e)}},
577
+ exc_info=True
578
+ )
579
+ return []
580
+
581
+ @staticmethod
582
+ def notify_admins_of_delete_status_change(
583
+ tenant_id: str,
584
+ resource_type: str,
585
+ resource_name: str,
586
+ status: str,
587
+ actor_user_id: Optional[str] = None,
588
+ message: Optional[str] = None,
589
+ ) -> None:
590
+ """
591
+ Notify all owner/admin users when a resource delete_status changes.
592
+
593
+ Args:
594
+ tenant_id: Tenant identifier
595
+ resource_type: Human-readable resource type (e.g., "User", "Group")
596
+ resource_name: Human-readable resource name for context
597
+ status: New delete_status value ('PENDING', 'DELETED', 'NOT_DELETED')
598
+ actor_user_id: User ID who performed the action
599
+ message: Optional additional message supplied by the actor
600
+ """
601
+ status_key = (status or "").upper()
602
+ status_config = {
603
+ "PENDING": {
604
+ "title": "Deletion Pending Approval",
605
+ "subject": "Pending deletion request for {resource_name}",
606
+ "description": (
607
+ "A deletion request has been submitted for the {resource_type} "
608
+ "\"{resource_name}\" and is awaiting approval."
609
+ ),
610
+ "display": "Pending Deletion",
611
+ "color": "#ffc107",
612
+ "icon": "⏳",
613
+ },
614
+ "DELETED": {
615
+ "title": "Resource Deleted",
616
+ "subject": "Resource deleted: {resource_name}",
617
+ "description": (
618
+ "The {resource_type} \"{resource_name}\" has been deleted."
619
+ ),
620
+ "display": "Deleted",
621
+ "color": "#dc3545",
622
+ "icon": "🗑️",
623
+ },
624
+ "NOT_DELETED": {
625
+ "title": "Resource Restored",
626
+ "subject": "Resource restored: {resource_name}",
627
+ "description": (
628
+ "The {resource_type} \"{resource_name}\" has been restored."
629
+ ),
630
+ "display": "Restored",
631
+ "color": "#28a745",
632
+ "icon": "♻️",
633
+ },
634
+ }
635
+
636
+ config = status_config.get(status_key, status_config["DELETED"])
637
+
638
+ try:
639
+ admin_users = Helper.get_users_with_admin_roles(tenant_id)
640
+ if not admin_users:
641
+ logger.info(
642
+ "No admin users found to notify for status change",
643
+ extra={
644
+ "extra_fields": {
645
+ "tenant_id": tenant_id,
646
+ "resource_type": resource_type,
647
+ "resource_name": resource_name,
648
+ "status": status_key,
649
+ }
650
+ },
651
+ )
652
+ return
653
+
654
+ actor_name = "System"
655
+ actor_email = "no-reply@trovesuite.com"
656
+
657
+ if actor_user_id:
658
+ main_users_table = getattr(db_settings, 'CORE_PLATFORM_USERS_TABLE', 'core_platform.cp_users')
659
+ actor_details = DatabaseManager.execute_query(
660
+ f"""SELECT fullname, email
661
+ FROM {main_users_table}
662
+ WHERE id = %s AND tenant_id = %s""",
663
+ (actor_user_id, tenant_id),
664
+ )
665
+
666
+ if actor_details and len(actor_details) > 0:
667
+ actor_data = dict(actor_details[0])
668
+ actor_name_candidate = (actor_data.get("fullname") or "").strip()
669
+ actor_name = actor_name_candidate or actor_data.get("email") or actor_name
670
+ actor_email = actor_data.get("email") or actor_email
671
+
672
+ resource_name_display = resource_name or "Unknown resource"
673
+ resource_type_display = resource_type or "Resource"
674
+
675
+ message_text = f"Message: {message}\n" if message else ""
676
+ message_row_style = "" if message else "display: none;"
677
+ message_value = message or "No additional message provided."
678
+
679
+ subject = config["subject"].format(
680
+ resource_name=resource_name_display,
681
+ resource_type=resource_type_display,
682
+ )
683
+
684
+ status_description = config["description"].format(
685
+ resource_name=resource_name_display,
686
+ resource_type=resource_type_display,
687
+ )
688
+
689
+ status_title = config["title"]
690
+
691
+ app_url = getattr(db_settings, 'APP_URL', 'https://app.trovesuite.com')
692
+
693
+ for admin in admin_users:
694
+ admin_name = admin.get("name") or "Admin"
695
+ Helper.send_notification(
696
+ email=admin["email"],
697
+ subject=subject,
698
+ text_template=RESOURCE_STATUS_CHANGE_TEXT_TEMPLATE,
699
+ html_template=RESOURCE_STATUS_CHANGE_HTML_TEMPLATE,
700
+ variables={
701
+ "admin_name": admin_name,
702
+ "resource_type": resource_type_display,
703
+ "resource_name": resource_name_display,
704
+ "status_display": config["display"],
705
+ "status_description": status_description,
706
+ "status_title": status_title,
707
+ "status_color": config["color"],
708
+ "status_icon": config["icon"],
709
+ "actor_name": actor_name,
710
+ "actor_email": actor_email,
711
+ "message": message_value,
712
+ "message_text": message_text,
713
+ "message_row_style": message_row_style,
714
+ "app_url": app_url,
715
+ },
716
+ tenant_id=tenant_id,
717
+ )
718
+
719
+ logger.info(
720
+ "Delete status change notifications sent",
721
+ extra={
722
+ "extra_fields": {
723
+ "tenant_id": tenant_id,
724
+ "resource_type": resource_type_display,
725
+ "resource_name": resource_name_display,
726
+ "status": status_key,
727
+ "admin_count": len(admin_users),
728
+ }
729
+ },
730
+ )
731
+
732
+ except Exception as e:
733
+ logger.error(
734
+ f"Failed to send delete status change notifications: {str(e)}",
735
+ extra={
736
+ "extra_fields": {
737
+ "tenant_id": tenant_id,
738
+ "resource_type": resource_type,
739
+ "resource_name": resource_name,
740
+ "status": status_key,
741
+ "error": str(e),
742
+ }
743
+ },
744
+ exc_info=True,
745
+ )