trovesuite 1.0.14__py3-none-any.whl → 1.0.17__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.
trovesuite/__init__.py CHANGED
@@ -3,19 +3,21 @@ TroveSuite Package
3
3
 
4
4
  A comprehensive authentication, authorization, notification, and storage service for ERP systems.
5
5
  Provides JWT token validation, user authorization, permission checking, notification capabilities,
6
- and Azure Storage blob management.
6
+ Azure Storage blob management, and utility functions for multi-tenant applications.
7
7
  """
8
8
 
9
9
  from .auth import AuthService
10
10
  from .notification import NotificationService
11
11
  from .storage import StorageService
12
+ from .utils import Helper
12
13
 
13
- __version__ = "1.0.7"
14
+ __version__ = "1.0.16"
14
15
  __author__ = "Bright Debrah Owusu"
15
16
  __email__ = "owusu.debrah@deladetech.com"
16
17
 
17
18
  __all__ = [
18
19
  "AuthService",
19
20
  "NotificationService",
20
- "StorageService"
21
+ "StorageService",
22
+ "Helper",
21
23
  ]
@@ -229,10 +229,10 @@ class AuthService:
229
229
  logger.info(f"Fetching system-level roles for user: {user_id}")
230
230
 
231
231
  system_roles = DatabaseManager.execute_query(
232
- """
232
+ f"""
233
233
  SELECT DISTINCT sug.group_id, sug.user_id, sar.role_id, sar.resource_type
234
- FROM main.system_user_groups sug
235
- INNER JOIN main.system_assign_roles sar ON sug.group_id = sar.group_id
234
+ FROM {db_settings.MAIN_SYSTEM_USER_GROUPS_TABLE} sug
235
+ INNER JOIN {db_settings.MAIN_SYSTEM_ASSIGN_ROLES_TABLE} sar ON sug.group_id = sar.group_id
236
236
  WHERE sug.user_id = %s
237
237
  AND sar.is_active = true
238
238
  AND sar.delete_status = 'NOT_DELETED'
@@ -247,9 +247,9 @@ class AuthService:
247
247
 
248
248
  # ✅ NEW: Also check for direct system role assignments (user_id in system_assign_roles)
249
249
  direct_system_roles = DatabaseManager.execute_query(
250
- """
250
+ f"""
251
251
  SELECT DISTINCT NULL as group_id, sar.user_id, sar.role_id, sar.resource_type
252
- FROM main.system_assign_roles sar
252
+ FROM {db_settings.MAIN_SYSTEM_ASSIGN_ROLES_TABLE} sar
253
253
  WHERE sar.user_id = %s
254
254
  AND sar.is_active = true
255
255
  AND sar.delete_status = 'NOT_DELETED'
@@ -9,10 +9,10 @@ class Settings:
9
9
  DB_NAME: str = os.getenv("DB_NAME")
10
10
  DB_PORT: str = os.getenv("DB_PORT")
11
11
  DB_PASSWORD: str = os.getenv("DB_PASSWORD")
12
-
12
+
13
13
  # Application settings
14
- APP_NAME: str = os.getenv("APP_NAME", "Python Template API")
15
14
  DEBUG: bool = os.getenv("DEBUG", "True").lower() in ("true",1)
15
+ APP_NAME: str = os.getenv("APP_NAME", "Python Template API")
16
16
  APP_VERSION: str = os.getenv("APP_VERSION", "1.0.0")
17
17
 
18
18
  # Logging settings
@@ -24,26 +24,67 @@ class Settings:
24
24
  LOG_DIR: str = os.getenv("LOG_DIR", "logs")
25
25
 
26
26
  # Security settings
27
- ENVIRONMENT: str = os.getenv("ENVIRONMENT")
28
27
  ALGORITHM: str = os.getenv("ALGORITHM")
29
28
  SECRET_KEY: str = os.getenv("SECRET_KEY")
29
+ ENVIRONMENT: str = os.getenv("ENVIRONMENT")
30
30
  ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "120"))
31
31
 
32
32
  # =============================================================================
33
33
  # SHARED TABLES (main schema)
34
34
  # =============================================================================
35
- MAIN_TENANTS_TABLE = os.getenv("MAIN_TENANTS_TABLE")
36
- MAIN_ROLE_PERMISSIONS_TABLE = os.getenv("MAIN_ROLE_PERMISSIONS_TABLE")
37
- MAIN_USER_SUBSCRIPTIONS_TABLE = os.getenv("MAIN_USER_SUBSCRIPTIONS_TABLE")
38
- MAIN_USER_SUBSCRIPTION_HISTORY_TABLE = os.getenv("MAIN_USER_SUBSCRIPTION_HISTORY_TABLE")
39
- MAIN_SUBSCRIPTIONS_TABLE = os.getenv("MAIN_SUBSCRIPTIONS_TABLE")
35
+ MAIN_TENANTS_TABLE = os.getenv("MAIN_TENANTS_TABLE", "main.tenants")
36
+ MAIN_TENANT_RESOURCE_ID_TABLE = os.getenv("MAIN_TENANT_RESOURCE_ID_TABLE", "main.tenant_resource_ids")
37
+ MAIN_SUBSCRIPTIONS_TABLE = os.getenv("MAIN_SUBSCRIPTIONS_TABLE", "main.subscriptions")
38
+ MAIN_APPS_TABLE = os.getenv("MAIN_APPS_TABLE", "main.apps")
39
+ MAIN_USERS_TABLE = os.getenv("MAIN_USERS_TABLE", "main.users")
40
+ MAIN_RESOURCE_TYPES_TABLE = os.getenv("MAIN_RESOURCE_TYPES_TABLE", "main.resource_types")
41
+ MAIN_RESOURCE_ID_TABLE = os.getenv("MAIN_RESOURCE_ID_TABLE", "main.resource_ids")
42
+ MAIN_RESOURCES_TABLE = os.getenv("MAIN_RESOURCES_TABLE", "main.resources")
43
+ MAIN_PERMISSIONS_TABLE = os.getenv("MAIN_PERMISSIONS_TABLE", "main.permissions")
44
+ MAIN_ROLES_TABLE = os.getenv("MAIN_ROLES_TABLE", "main.roles")
45
+ MAIN_ROLE_PERMISSIONS_TABLE = os.getenv("MAIN_ROLE_PERMISSIONS_TABLE", "main.role_permissions")
46
+ MAIN_USER_SUBSCRIPTIONS_TABLE = os.getenv("MAIN_USER_SUBSCRIPTIONS_TABLE", "main.user_subscriptions")
47
+ MAIN_USER_SUBSCRIPTION_HISTORY_TABLE = os.getenv("MAIN_USER_SUBSCRIPTION_HISTORY_TABLE", "main.user_subscription_history")
48
+ MAIN_OTP = os.getenv("MAIN_OTP", "main.otps")
49
+ MAIN_PASSWORD_POLICY = os.getenv("MAIN_PASSWORD_POLICY", "main.password_policies")
50
+ MAIN_MULTI_FACTOR_SETTINGS = os.getenv("MAIN_MULTI_FACTOR_SETTINGS", "main.multi_factor_settings")
51
+ MAIN_USER_LOGIN_TRACKING = os.getenv("MAIN_USER_LOGIN_TRACKING", "main.user_login_tracking")
52
+ MAIN_ENTERPRISE_SUBSCRIPTIONS_TABLE = os.getenv("MAIN_ENTERPRISE_SUBSCRIPTIONS_TABLE", "main.enterprise_subscriptions")
53
+ MAIN_CHANGE_PASSWORD_POLICY_TABLE = os.getenv("MAIN_CHANGE_PASSWORD_POLICY_TABLE", "main.change_password_policy")
40
54
 
55
+ # System-level tables
56
+ MAIN_SYSTEM_GROUPS_TABLE = os.getenv("MAIN_SYSTEM_GROUPS_TABLE", "main.system_groups")
57
+ MAIN_SYSTEM_USER_GROUPS_TABLE = os.getenv("MAIN_SYSTEM_USER_GROUPS_TABLE", "main.system_user_groups")
58
+ MAIN_SYSTEM_ASSIGN_ROLES_TABLE = os.getenv("MAIN_SYSTEM_ASSIGN_ROLES_TABLE", "main.system_assign_roles")
59
+
41
60
  # =============================================================================
42
61
  # TENANT-SPECIFIC TABLES (tenant schemas)
43
62
  # =============================================================================
63
+ TENANT_SUBSCRIPTIONS_TABLE = os.getenv("TENANT_SUBSCRIPTIONS_TABLE")
64
+ TENANT_GROUPS_TABLE = os.getenv("TENANT_GROUPS_TABLE")
44
65
  TENANT_LOGIN_SETTINGS_TABLE = os.getenv("TENANT_LOGIN_SETTINGS_TABLE")
66
+ TENANT_RESOURCES_TABLE = os.getenv("TENANT_RESOURCES_TABLE")
45
67
  TENANT_ASSIGN_ROLES_TABLE = os.getenv("TENANT_ASSIGN_ROLES_TABLE")
68
+ TENANT_RESOURCE_ID_TABLE = os.getenv("TENANT_RESOURCE_ID_TABLE")
69
+ TENANT_SUBSCRIPTION_HISTORY_TABLE = os.getenv("TENANT_SUBSCRIPTION_HISTORY_TABLE")
70
+ TENANT_RESOURCE_DELETION_CHAT_HISTORY_TABLE = os.getenv("TENANT_RESOURCE_DELETION_CHAT_HISTORY_TABLE")
46
71
  TENANT_USER_GROUPS_TABLE = os.getenv("TENANT_USER_GROUPS_TABLE")
72
+ TENANT_ACTIVITY_LOGS_TABLE = os.getenv("TENANT_ACTIVITY_LOGS_TABLE")
73
+ TENANT_ORGANIZATIONS_TABLE = os.getenv("TENANT_ORGANIZATIONS_TABLE", "organizations")
74
+ TENANT_BUSINESSES_TABLE = os.getenv("TENANT_BUSINESSES_TABLE", "businesses")
75
+ TENANT_BUSINESS_APPS_TABLE = os.getenv("TENANT_BUSINESS_APPS_TABLE", "business_apps")
76
+ TENANT_LOCATIONS_TABLE = os.getenv("TENANT_LOCATIONS_TABLE", "locations")
77
+ TENANT_ASSIGN_LOCATIONS_TABLE = os.getenv("TENANT_ASSIGN_LOCATIONS_TABLE", "assign_locations")
78
+ TENANT_UNIT_OF_MEASURE_TABLE = os.getenv("TENANT_UNIT_OF_MEASURE_TABLE")
79
+ TENANT_CURRENCY=os.getenv("TENANT_CURRENCY")
80
+
81
+ # Mail Configurations
82
+ MAIL_SENDER_EMAIL=os.getenv("MAIL_SENDER_EMAIL")
83
+ MAIL_SENDER_PWD=os.getenv("MAIL_SENDER_PWD")
84
+
85
+ # Application Configurations
86
+ APP_URL=os.getenv("APP_URL", "https://trovesuite.com")
87
+ USER_ASSIGNED_MANAGED_IDENTITY=os.getenv("USER_ASSIGNED_MANAGED_IDENTITY")
47
88
 
48
89
  @property
49
90
  def database_url(self) -> str:
@@ -5,7 +5,9 @@ Utility functions and helpers for TroveSuite services.
5
5
  """
6
6
 
7
7
  from .helper import Helper
8
+ from . import templates
8
9
 
9
10
  __all__ = [
10
- "Helper"
11
+ "Helper",
12
+ "templates",
11
13
  ]
@@ -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, check tenant schema
100
+ # Note: TENANT_RESOURCE_ID_TABLE needs to be defined in settings
101
+ tenant_resource_table = getattr(db_settings, 'TENANT_RESOURCE_ID_TABLE', None)
102
+ if tenant_resource_table:
103
+ resource_exists = DatabaseManager.execute_scalar(
104
+ f"""SELECT COUNT(1) FROM "{tenant_id}".{tenant_resource_table}
105
+ WHERE id = %s""",
106
+ (candidate,),
107
+ )
108
+ else:
109
+ # Fallback: assume no conflict if table not configured
110
+ resource_exists = 0
111
+ else:
112
+ # For main schema resource IDs
113
+ main_resource_table = getattr(db_settings, 'MAIN_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,477 @@ 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, 'TENANT_ACTIVITY_LOGS_TABLE', None)
240
+ if not tenant_activity_logs_table:
241
+ logger.error("TENANT_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, 'MAIN_USERS_TABLE', 'main.users')
267
+ user_data = DatabaseManager.execute_query(
268
+ f"""SELECT email, contact, fullname
269
+ FROM {main_users_table}
270
+ WHERE id = %s""",
271
+ (user_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_id}".{tenant_activity_logs_table}
293
+ (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, NULLIF(%s, '')::text, %s::jsonb, %s::jsonb, %s, %s, %s, %s, %s, %s, %s)""",
295
+ (log_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_id}".{tenant_activity_logs_table}
305
+ WHERE id = %s""",
306
+ (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 send_notification(
319
+ email: str,
320
+ subject: str,
321
+ text_template: str,
322
+ html_template: str,
323
+ variables: Optional[Dict[str, str]] = None,
324
+ ):
325
+ """Send a dynamic notification email."""
326
+ current_time = Helper.current_date_time()
327
+ cdate = current_time["cdate"]
328
+ ctime = current_time["ctime"]
329
+
330
+ # Add defaults if not provided
331
+ if variables is None:
332
+ variables = {}
333
+
334
+ # Include date/time automatically
335
+ variables.update({"cdate": cdate, "ctime": ctime})
336
+
337
+ def _escape(value):
338
+ if isinstance(value, str):
339
+ return value.replace("{", "{{").replace("}", "}}")
340
+ return value
341
+
342
+ safe_variables = {key: _escape(value) for key, value in variables.items()}
343
+
344
+ # Format templates dynamically using placeholders
345
+ text_message = text_template.format(**safe_variables)
346
+ html_message = html_template.format(**safe_variables)
347
+
348
+ # Build email data
349
+ mail_sender_email = getattr(db_settings, 'MAIL_SENDER_EMAIL', None)
350
+ mail_sender_pwd = getattr(db_settings, 'MAIL_SENDER_PWD', None)
351
+
352
+ if not mail_sender_email or not mail_sender_pwd:
353
+ logger.error("MAIL_SENDER_EMAIL or MAIL_SENDER_PWD not configured in settings")
354
+ return
355
+
356
+ notification_data = NotificationEmailServiceWriteDto(
357
+ sender_email=mail_sender_email,
358
+ receiver_email=email,
359
+ password=mail_sender_pwd,
360
+ text_message=text_message,
361
+ html_message=html_message,
362
+ subject=subject,
363
+ )
364
+
365
+ # Send email
366
+ NotificationService.send_email(data=notification_data)
367
+
368
+ @staticmethod
369
+ def format_login_type_text(
370
+ login_type: str,
371
+ specific_days: Optional[List[str]] = None,
372
+ custom_start: Optional[str] = None,
373
+ custom_end: Optional[str] = None
374
+ ) -> str:
375
+ """
376
+ Format login type information into human-readable text.
377
+
378
+ Args:
379
+ login_type: The type of login access (always_login, specific_days, custom)
380
+ specific_days: List of days when user can login (for specific_days type)
381
+ custom_start: Start datetime for custom login period
382
+ custom_end: End datetime for custom login period
383
+
384
+ Returns:
385
+ Formatted login type description
386
+ """
387
+ if login_type == "always_login":
388
+ return "Anytime - You can login at any time"
389
+ elif login_type == "specific_days":
390
+ if specific_days:
391
+ days_str = ", ".join(specific_days)
392
+ return f"Specific Days - You can login on: {days_str}"
393
+ return "Specific Days"
394
+ elif login_type == "custom":
395
+ if custom_start and custom_end:
396
+ return f"Custom Period - From {custom_start} to {custom_end}"
397
+ elif custom_start:
398
+ return f"Custom Period - Starting from {custom_start}"
399
+ return "Custom Period"
400
+ else:
401
+ return "Login access granted"
402
+
403
+ @staticmethod
404
+ def get_users_with_admin_roles(tenant_id: str) -> List[Dict[str, str]]:
405
+ """
406
+ Get all users with role-owner or role-admin roles in a tenant.
407
+ Checks both direct role assignments and group-based role assignments.
408
+
409
+ Args:
410
+ tenant_id: The tenant ID to search within
411
+
412
+ Returns:
413
+ List of dictionaries containing user_id, email, and name
414
+ """
415
+ try:
416
+ # Get table names from settings with fallbacks
417
+ main_users_table = getattr(db_settings, 'MAIN_USERS_TABLE', 'main.users')
418
+ main_roles_table = getattr(db_settings, 'MAIN_ROLES_TABLE', 'main.roles')
419
+ tenant_assign_roles_table = getattr(db_settings, 'TENANT_ASSIGN_ROLES_TABLE', 'assign_roles')
420
+ tenant_user_groups_table = getattr(db_settings, 'TENANT_USER_GROUPS_TABLE', 'user_groups')
421
+
422
+ # Query to get users with admin roles - both direct and through groups
423
+ query = f"""
424
+ SELECT DISTINCT u.id as user_id, u.email, u.fullname
425
+ FROM {main_users_table} u
426
+ WHERE 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 IN (
432
+ SELECT ar.user_id
433
+ FROM "{tenant_id}".{tenant_assign_roles_table} ar
434
+ INNER JOIN {main_roles_table} r ON ar.role_id = r.id
435
+ WHERE ar.delete_status = 'NOT_DELETED'
436
+ AND ar.is_active = true
437
+ AND r.delete_status = 'NOT_DELETED'
438
+ AND r.is_active = true
439
+ AND r.role_name IN ('role-owner', 'role-admin')
440
+ AND ar.user_id IS NOT NULL
441
+ )
442
+ OR
443
+ -- Group-based role assignment
444
+ u.id IN (
445
+ SELECT ug.user_id
446
+ FROM "{tenant_id}".{tenant_user_groups_table} ug
447
+ INNER JOIN "{tenant_id}".{tenant_assign_roles_table} ar ON ug.group_id = ar.group_id
448
+ INNER JOIN {main_roles_table} r ON ar.role_id = r.id
449
+ WHERE ug.delete_status = 'NOT_DELETED'
450
+ AND ug.is_active = true
451
+ AND ar.delete_status = 'NOT_DELETED'
452
+ AND ar.is_active = true
453
+ AND r.delete_status = 'NOT_DELETED'
454
+ AND r.is_active = true
455
+ AND r.role_name IN ('role-owner', 'role-admin')
456
+ AND ar.group_id IS NOT NULL
457
+ )
458
+ )
459
+ """
460
+
461
+ results = DatabaseManager.execute_query(query)
462
+
463
+ admin_users = []
464
+ if results:
465
+ for row in results:
466
+ row_dict = dict(row)
467
+ admin_users.append(
468
+ {
469
+ "user_id": row_dict.get("user_id"),
470
+ "email": row_dict.get("email"),
471
+ "name": (row_dict.get("fullname") or "").strip() or "Admin",
472
+ }
473
+ )
474
+
475
+ logger.info(
476
+ f"Found {len(admin_users)} admin users for tenant {tenant_id}",
477
+ extra={"extra_fields": {"tenant_id": tenant_id, "admin_count": len(admin_users)}}
478
+ )
479
+
480
+ return admin_users
481
+
482
+ except Exception as e:
483
+ logger.error(
484
+ f"Failed to get admin users for tenant {tenant_id}: {str(e)}",
485
+ extra={"extra_fields": {"tenant_id": tenant_id, "error": str(e)}},
486
+ exc_info=True
487
+ )
488
+ return []
489
+
490
+ @staticmethod
491
+ def notify_admins_of_delete_status_change(
492
+ tenant_id: str,
493
+ resource_type: str,
494
+ resource_name: str,
495
+ status: str,
496
+ actor_user_id: Optional[str] = None,
497
+ message: Optional[str] = None,
498
+ ) -> None:
499
+ """
500
+ Notify all owner/admin users when a resource delete_status changes.
501
+
502
+ Args:
503
+ tenant_id: Tenant identifier
504
+ resource_type: Human-readable resource type (e.g., "User", "Group")
505
+ resource_name: Human-readable resource name for context
506
+ status: New delete_status value ('PENDING', 'DELETED', 'NOT_DELETED')
507
+ actor_user_id: User ID who performed the action
508
+ message: Optional additional message supplied by the actor
509
+ """
510
+ status_key = (status or "").upper()
511
+ status_config = {
512
+ "PENDING": {
513
+ "title": "Deletion Pending Approval",
514
+ "subject": "Pending deletion request for {resource_name}",
515
+ "description": (
516
+ "A deletion request has been submitted for the {resource_type} "
517
+ "\"{resource_name}\" and is awaiting approval."
518
+ ),
519
+ "display": "Pending Deletion",
520
+ "color": "#ffc107",
521
+ "icon": "⏳",
522
+ },
523
+ "DELETED": {
524
+ "title": "Resource Deleted",
525
+ "subject": "Resource deleted: {resource_name}",
526
+ "description": (
527
+ "The {resource_type} \"{resource_name}\" has been deleted."
528
+ ),
529
+ "display": "Deleted",
530
+ "color": "#dc3545",
531
+ "icon": "🗑️",
532
+ },
533
+ "NOT_DELETED": {
534
+ "title": "Resource Restored",
535
+ "subject": "Resource restored: {resource_name}",
536
+ "description": (
537
+ "The {resource_type} \"{resource_name}\" has been restored."
538
+ ),
539
+ "display": "Restored",
540
+ "color": "#28a745",
541
+ "icon": "♻️",
542
+ },
543
+ }
544
+
545
+ config = status_config.get(status_key, status_config["DELETED"])
546
+
547
+ try:
548
+ admin_users = Helper.get_users_with_admin_roles(tenant_id)
549
+ if not admin_users:
550
+ logger.info(
551
+ "No admin users found to notify for status change",
552
+ extra={
553
+ "extra_fields": {
554
+ "tenant_id": tenant_id,
555
+ "resource_type": resource_type,
556
+ "resource_name": resource_name,
557
+ "status": status_key,
558
+ }
559
+ },
560
+ )
561
+ return
562
+
563
+ actor_name = "System"
564
+ actor_email = "no-reply@trovesuite.com"
565
+
566
+ if actor_user_id:
567
+ main_users_table = getattr(db_settings, 'MAIN_USERS_TABLE', 'main.users')
568
+ actor_details = DatabaseManager.execute_query(
569
+ f"""SELECT fullname, email
570
+ FROM {main_users_table}
571
+ WHERE id = %s""",
572
+ (actor_user_id,),
573
+ )
574
+
575
+ if actor_details:
576
+ actor_data = dict(actor_details[0])
577
+ actor_name_candidate = (actor_data.get("fullname") or "").strip()
578
+ actor_name = actor_name_candidate or actor_data.get("email") or actor_name
579
+ actor_email = actor_data.get("email") or actor_email
580
+
581
+ resource_name_display = resource_name or "Unknown resource"
582
+ resource_type_display = resource_type or "Resource"
583
+
584
+ message_text = f"Message: {message}\n" if message else ""
585
+ message_row_style = "" if message else "display: none;"
586
+ message_value = message or "No additional message provided."
587
+
588
+ subject = config["subject"].format(
589
+ resource_name=resource_name_display,
590
+ resource_type=resource_type_display,
591
+ )
592
+
593
+ status_description = config["description"].format(
594
+ resource_name=resource_name_display,
595
+ resource_type=resource_type_display,
596
+ )
597
+
598
+ status_title = config["title"]
599
+
600
+ app_url = getattr(db_settings, 'APP_URL', 'https://app.trovesuite.com')
601
+
602
+ for admin in admin_users:
603
+ admin_name = admin.get("name") or "Admin"
604
+ Helper.send_notification(
605
+ email=admin["email"],
606
+ subject=subject,
607
+ text_template=RESOURCE_STATUS_CHANGE_TEXT_TEMPLATE,
608
+ html_template=RESOURCE_STATUS_CHANGE_HTML_TEMPLATE,
609
+ variables={
610
+ "admin_name": admin_name,
611
+ "resource_type": resource_type_display,
612
+ "resource_name": resource_name_display,
613
+ "status_display": config["display"],
614
+ "status_description": status_description,
615
+ "status_title": status_title,
616
+ "status_color": config["color"],
617
+ "status_icon": config["icon"],
618
+ "actor_name": actor_name,
619
+ "actor_email": actor_email,
620
+ "message": message_value,
621
+ "message_text": message_text,
622
+ "message_row_style": message_row_style,
623
+ "app_url": app_url,
624
+ },
625
+ )
626
+
627
+ logger.info(
628
+ "Delete status change notifications sent",
629
+ extra={
630
+ "extra_fields": {
631
+ "tenant_id": tenant_id,
632
+ "resource_type": resource_type_display,
633
+ "resource_name": resource_name_display,
634
+ "status": status_key,
635
+ "admin_count": len(admin_users),
636
+ }
637
+ },
638
+ )
639
+
640
+ except Exception as e:
641
+ logger.error(
642
+ f"Failed to send delete status change notifications: {str(e)}",
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
+ "error": str(e),
650
+ }
651
+ },
652
+ exc_info=True,
653
+ )
@@ -0,0 +1,487 @@
1
+ OTP_TEXT_TEMPLATE = (
2
+ "Your Trovesuite OTP code is: {otp_code}\n"
3
+ "Please do not share this code with anyone.\n"
4
+ "This code will expire in 5 minutes.\n"
5
+ "Sent at: {cdate}, {ctime}"
6
+ )
7
+
8
+ OTP_HTML_TEMPLATE = """
9
+ <html>
10
+ <body style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;">
11
+ <div style="max-width: 500px; margin: auto; background: white; border-radius: 12px; padding: 25px; box-shadow: 0 2px 6px rgba(0,0,0,0.1);">
12
+ <h2 style="color: #2e86de; text-align: center;">🔐 {message_header}</h2>
13
+ <p style="font-size: 16px; color: #333;">Dear {user_name},</p>
14
+ <p style="font-size: 15px; color: #333;">
15
+ Please use the following <b>One-Time Password (OTP)</b> to complete your verification:
16
+ </p>
17
+ <div style="text-align: center; margin: 30px 0;">
18
+ <div style="display: inline-block; background-color: #2e86de; color: white; padding: 15px 30px; border-radius: 8px; font-size: 28px; letter-spacing: 4px;">
19
+ {otp_code}
20
+ </div>
21
+ </div>
22
+ <p style="font-size: 14px; color: #555;">
23
+ This code will expire in <b>5 minutes</b>. Please do not share this code with anyone.
24
+ </p>
25
+ <hr style="border: none; border-top: 1px solid #eee; margin: 25px 0;">
26
+ <p style="font-size: 12px; color: #888; text-align: center;">
27
+ Trovesuite Security System<br>
28
+ Sent at: {cdate}, {ctime}
29
+ </p>
30
+ </div>
31
+ </body>
32
+ </html>
33
+ """
34
+
35
+ PASSWORD_CHANGE_HTML_TEMPLATE = """
36
+ <html>
37
+ <body style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;">
38
+ <div style="max-width: 500px; margin: auto; background: white; border-radius: 12px; padding: 25px; box-shadow: 0 2px 6px rgba(0,0,0,0.1);">
39
+ <h2 style="color: #2e86de; text-align: center;">🔒 Password Change Alert</h2>
40
+ <p style="font-size: 16px; color: #333;">Hello {user_name},</p>
41
+ <p style="font-size: 15px; color: #333;">
42
+ We noticed that the password for your <b>Trovesuite</b> account was recently changed.
43
+ </p>
44
+ <p style="font-size: 15px; color: #333;">
45
+ If you made this change, you can safely ignore this message.
46
+ </p>
47
+ <p style="font-size: 15px; color: #333;">
48
+ However, if you did <b>not</b> make this change, please take immediate action to secure your account.
49
+ </p>
50
+ <div style="margin-top: 30px; padding: 15px; background-color: #f1f3f6; border-radius: 8px;">
51
+ <p style="font-size: 13px; color: #555; margin: 0;">
52
+ Date: <b>{cdate}</b><br>
53
+ Time: <b>{ctime}</b>
54
+ </p>
55
+ </div>
56
+ <hr style="border: none; border-top: 1px solid #eee; margin: 25px 0;">
57
+ <p style="font-size: 12px; color: #888; text-align: center;">
58
+ Trovesuite Security System<br>
59
+ Ensuring your data safety always.<br>
60
+ Sent at: {cdate}, {ctime}
61
+ </p>
62
+ </div>
63
+ </body>
64
+ </html>
65
+ """
66
+
67
+ PASSWORD_CHANGE_TEXT_TEMPLATE = (
68
+ "Hello {user_name},\n\n"
69
+ "We wanted to let you know that the password for your Trovesuite account was just changed.\n\n"
70
+ "If you made this change, you can safely ignore this message.\n"
71
+ "If you did NOT make this change, please take immediate action to secure your account.\n\n"
72
+ "Date: {cdate}\n"
73
+ "Time: {ctime}\n"
74
+ "— The Trovesuite Security Team"
75
+ )
76
+
77
+ ACCESS_GRANTED_HTML_TEMPLATE = """
78
+ <html>
79
+ <body style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px; margin: 0;">
80
+ <div style="max-width: 600px; margin: auto; background: white; border-radius: 12px; padding: 30px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
81
+ <!-- Header -->
82
+ <div style="text-align: center; padding-bottom: 25px; border-bottom: 2px solid #2e86de;">
83
+ <h1 style="color: #2e86de; margin: 0; font-size: 28px;">🎉 Welcome to Trovesuite!</h1>
84
+ </div>
85
+
86
+ <!-- Greeting -->
87
+ <div style="padding: 25px 0;">
88
+ <p style="font-size: 16px; color: #333; margin-bottom: 15px;">Dear <strong>{user_name}</strong>,</p>
89
+ <p style="font-size: 15px; color: #333; line-height: 1.6;">
90
+ Great news! You have been granted access to <strong>Trovesuite</strong>.
91
+ You can now log in and start exploring all the features available to you.
92
+ </p>
93
+ </div>
94
+
95
+ <!-- Login Details -->
96
+ <div style="background-color: #f8f9fa; border-radius: 8px; padding: 20px; margin: 20px 0;">
97
+ <h3 style="color: #2e86de; margin-top: 0; font-size: 18px; margin-bottom: 15px;">📋 Your Login Details</h3>
98
+
99
+ <table style="width: 100%; border-collapse: collapse;">
100
+ <tr>
101
+ <td style="padding: 8px 0; color: #666; font-size: 14px; width: 40%;">
102
+ <strong>App URL:</strong>
103
+ </td>
104
+ <td style="padding: 8px 0; color: #333; font-size: 14px;">
105
+ <a href="{app_url}" style="color: #2e86de; text-decoration: none; font-weight: 500;">{app_url}</a>
106
+ </td>
107
+ </tr>
108
+ <tr>
109
+ <td style="padding: 8px 0; color: #666; font-size: 14px;">
110
+ <strong>Email:</strong>
111
+ </td>
112
+ <td style="padding: 8px 0; color: #333; font-size: 14px;">
113
+ {user_email}
114
+ </td>
115
+ </tr>
116
+ <tr>
117
+ <td style="padding: 8px 0; color: #666; font-size: 14px;">
118
+ <strong>Password:</strong>
119
+ </td>
120
+ <td style="padding: 8px 0; color: #333; font-size: 14px; font-family: 'Courier New', monospace; background-color: #fff; padding: 5px 10px; border-radius: 4px; display: inline-block;">
121
+ {password}
122
+ </td>
123
+ </tr>
124
+ <tr>
125
+ <td style="padding: 8px 0; color: #666; font-size: 14px;">
126
+ <strong>Login Access:</strong>
127
+ </td>
128
+ <td style="padding: 8px 0; color: #333; font-size: 14px;">
129
+ {login_type_text}
130
+ </td>
131
+ </tr>
132
+ </table>
133
+ </div>
134
+
135
+ <!-- CTA Button -->
136
+ <div style="text-align: center; margin: 30px 0;">
137
+ <a href="{app_url}" style="display: inline-block; background-color: #2e86de; color: white; padding: 14px 35px; text-decoration: none; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(46, 134, 222, 0.3);">
138
+ Login Now →
139
+ </a>
140
+ </div>
141
+
142
+ <!-- Security Note -->
143
+ <div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 25px 0; border-radius: 4px;">
144
+ <p style="font-size: 13px; color: #856404; margin: 0; line-height: 1.5;">
145
+ <strong>⚠️ Security Reminder:</strong> For your security, we recommend changing your password upon your first login.
146
+ Please do not share your credentials with anyone.
147
+ </p>
148
+ </div>
149
+
150
+ <!-- Footer -->
151
+ <hr style="border: none; border-top: 1px solid #eee; margin: 25px 0;">
152
+ <div style="text-align: center;">
153
+ <p style="font-size: 12px; color: #888; margin: 5px 0;">
154
+ Trovesuite - Empowering Your Business<br>
155
+ Sent on: {cdate} at {ctime}
156
+ </p>
157
+ </div>
158
+ </div>
159
+ </body>
160
+ </html>
161
+ """
162
+
163
+ ACCESS_GRANTED_TEXT_TEMPLATE = (
164
+ "Welcome to Trovesuite!\n\n"
165
+ "Dear {user_name},\n\n"
166
+ "Great news! You have been granted access to Trovesuite.\n"
167
+ "You can now log in and start exploring all the features available to you.\n\n"
168
+ "YOUR LOGIN DETAILS:\n"
169
+ "==================\n"
170
+ "App URL: {app_url}\n"
171
+ "Email: {user_email}\n"
172
+ "Password: {password}\n"
173
+ "Login Access: {login_type_text}\n\n"
174
+ "SECURITY REMINDER:\n"
175
+ "For your security, we recommend changing your password upon your first login.\n"
176
+ "Please do not share your credentials with anyone.\n\n"
177
+ "— The Trovesuite Team\n"
178
+ "Sent on: {cdate} at {ctime}"
179
+ )
180
+
181
+ RESET_PASSWORD_TEXT_TEMPLATE = (
182
+ "Trovesuite Password Reset\n\n"
183
+ "Dear {user_name},\n\n"
184
+ "Your Trovesuite account password has been reset by an administrator.\n\n"
185
+ "NEW LOGIN DETAILS:\n"
186
+ "==================\n"
187
+ "App URL: {app_url}\n"
188
+ "Email: {user_email}\n"
189
+ "Temporary Password: {password}\n\n"
190
+ "SECURITY REMINDER:\n"
191
+ "Please sign in and change this temporary password immediately. "
192
+ "Do not share your credentials with anyone.\n\n"
193
+ "— The Trovesuite Team\n"
194
+ "Sent on: {cdate} at {ctime}"
195
+ )
196
+
197
+ RESET_PASSWORD_HTML_TEMPLATE = """
198
+ <html>
199
+ <body style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px; margin: 0;">
200
+ <div style="max-width: 600px; margin: auto; background: white; border-radius: 12px; padding: 30px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
201
+ <!-- Header -->
202
+ <div style="text-align: center; padding-bottom: 25px; border-bottom: 2px solid #2e86de;">
203
+ <h1 style="color: #2e86de; margin: 0; font-size: 26px;">🔑 Password Reset</h1>
204
+ </div>
205
+
206
+ <!-- Greeting -->
207
+ <div style="padding: 25px 0;">
208
+ <p style="font-size: 16px; color: #333; margin-bottom: 15px;">Dear <strong>{user_name}</strong>,</p>
209
+ <p style="font-size: 15px; color: #333; line-height: 1.6;">
210
+ Your Trovesuite account password has been reset by an administrator. Use the credentials below to sign in.
211
+ </p>
212
+ </div>
213
+
214
+ <!-- Login Details -->
215
+ <div style="background-color: #f8f9fa; border-radius: 8px; padding: 20px; margin: 20px 0;">
216
+ <h3 style="color: #2e86de; margin-top: 0; font-size: 18px; margin-bottom: 15px;">📋 New Login Details</h3>
217
+
218
+ <table style="width: 100%; border-collapse: collapse;">
219
+ <tr>
220
+ <td style="padding: 8px 0; color: #666; font-size: 14px; width: 35%;">
221
+ <strong>App URL:</strong>
222
+ </td>
223
+ <td style="padding: 8px 0; color: #333; font-size: 14px;">
224
+ <a href="{app_url}" style="color: #2e86de; text-decoration: none; font-weight: 500;">{app_url}</a>
225
+ </td>
226
+ </tr>
227
+ <tr>
228
+ <td style="padding: 8px 0; color: #666; font-size: 14px;">
229
+ <strong>Email:</strong>
230
+ </td>
231
+ <td style="padding: 8px 0; color: #333; font-size: 14px;">
232
+ {user_email}
233
+ </td>
234
+ </tr>
235
+ <tr>
236
+ <td style="padding: 8px 0; color: #666; font-size: 14px;">
237
+ <strong>Temporary Password:</strong>
238
+ </td>
239
+ <td style="padding: 8px 0; color: #333; font-size: 14px; font-family: 'Courier New', monospace; background-color: #fff; padding: 5px 10px; border-radius: 4px; display: inline-block;">
240
+ {password}
241
+ </td>
242
+ </tr>
243
+ </table>
244
+ </div>
245
+
246
+ <!-- Security Note -->
247
+ <div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 25px 0; border-radius: 4px;">
248
+ <p style="font-size: 13px; color: #856404; margin: 0; line-height: 1.5;">
249
+ <strong>⚠️ Important:</strong> For security reasons, please change this temporary password immediately after logging in.
250
+ </p>
251
+ </div>
252
+
253
+ <!-- Footer -->
254
+ <hr style="border: none; border-top: 1px solid #eee; margin: 25px 0;">
255
+ <div style="text-align: center;">
256
+ <p style="font-size: 12px; color: #888; margin: 5px 0;">
257
+ This is an automated notification from Trovesuite
258
+ </p>
259
+ <p style="font-size: 12px; color: #888; margin: 5px 0;">
260
+ Trovesuite - Empowering Your Business<br>
261
+ Sent on: {cdate} at {ctime}
262
+ </p>
263
+ </div>
264
+ </div>
265
+ </body>
266
+ </html>
267
+ """
268
+
269
+ RESOURCE_DELETION_HTML_TEMPLATE = """
270
+ <html>
271
+ <body style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px; margin: 0;">
272
+ <div style="max-width: 600px; margin: auto; background: white; border-radius: 12px; padding: 30px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
273
+ <!-- Header -->
274
+ <div style="text-align: center; padding-bottom: 25px; border-bottom: 2px solid #dc3545;">
275
+ <h1 style="color: #dc3545; margin: 0; font-size: 28px;">🗑️ Resource Deletion Notice</h1>
276
+ </div>
277
+
278
+ <!-- Greeting -->
279
+ <div style="padding: 25px 0;">
280
+ <p style="font-size: 16px; color: #333; margin-bottom: 15px;">Dear <strong>{admin_name}</strong>,</p>
281
+ <p style="font-size: 15px; color: #333; line-height: 1.6;">
282
+ This is to notify you that a resource has been deleted in your Trovesuite account.
283
+ </p>
284
+ </div>
285
+
286
+ <!-- Deletion Details -->
287
+ <div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 20px; margin: 20px 0; border-radius: 4px;">
288
+ <h3 style="color: #856404; margin-top: 0; font-size: 18px; margin-bottom: 15px;">📋 Deletion Details</h3>
289
+
290
+ <table style="width: 100%; border-collapse: collapse;">
291
+ <tr>
292
+ <td style="padding: 8px 0; color: #666; font-size: 14px; width: 35%;">
293
+ <strong>Resource Type:</strong>
294
+ </td>
295
+ <td style="padding: 8px 0; color: #333; font-size: 14px;">
296
+ {resource_type}
297
+ </td>
298
+ </tr>
299
+ <tr>
300
+ <td style="padding: 8px 0; color: #666; font-size: 14px;">
301
+ <strong>Resource Name:</strong>
302
+ </td>
303
+ <td style="padding: 8px 0; color: #333; font-size: 14px;">
304
+ {resource_name}
305
+ </td>
306
+ </tr>
307
+ <tr>
308
+ <td style="padding: 8px 0; color: #666; font-size: 14px;">
309
+ <strong>Deleted By:</strong>
310
+ </td>
311
+ <td style="padding: 8px 0; color: #333; font-size: 14px;">
312
+ {deleted_by_name} ({deleted_by_email})
313
+ </td>
314
+ </tr>
315
+ <tr>
316
+ <td style="padding: 8px 0; color: #666; font-size: 14px;">
317
+ <strong>Deletion Time:</strong>
318
+ </td>
319
+ <td style="padding: 8px 0; color: #333; font-size: 14px;">
320
+ {cdate} at {ctime}
321
+ </td>
322
+ </tr>
323
+ <tr style="{message_row_style}">
324
+ <td style="padding: 8px 0; color: #666; font-size: 14px; vertical-align: top;">
325
+ <strong>Message:</strong>
326
+ </td>
327
+ <td style="padding: 8px 0; color: #333; font-size: 14px;">
328
+ {message}
329
+ </td>
330
+ </tr>
331
+ </table>
332
+ </div>
333
+
334
+ <!-- Action Note -->
335
+ <div style="background-color: #f8f9fa; padding: 15px; margin: 25px 0; border-radius: 4px; border-left: 4px solid #2e86de;">
336
+ <p style="font-size: 13px; color: #333; margin: 0; line-height: 1.5;">
337
+ <strong>ℹ️ Note:</strong> This resource has been soft-deleted and can be restored if needed.
338
+ Please contact the person who deleted it or log in to your account to review this action.
339
+ </p>
340
+ </div>
341
+
342
+ <!-- CTA Button -->
343
+ <div style="text-align: center; margin: 30px 0;">
344
+ <a href="{app_url}" style="display: inline-block; background-color: #2e86de; color: white; padding: 14px 35px; text-decoration: none; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(46, 134, 222, 0.3);">
345
+ Login to Review →
346
+ </a>
347
+ </div>
348
+
349
+ <!-- Footer -->
350
+ <hr style="border: none; border-top: 1px solid #eee; margin: 25px 0;">
351
+ <div style="text-align: center;">
352
+ <p style="font-size: 12px; color: #888; margin: 5px 0;">
353
+ This is an automated notification from Trovesuite
354
+ </p>
355
+ <p style="font-size: 12px; color: #888; margin: 5px 0;">
356
+ Trovesuite - Empowering Your Business<br>
357
+ Sent on: {cdate} at {ctime}
358
+ </p>
359
+ </div>
360
+ </div>
361
+ </body>
362
+ </html>
363
+ """
364
+
365
+ RESOURCE_DELETION_TEXT_TEMPLATE = (
366
+ "Resource Deletion Notice\n\n"
367
+ "Dear {admin_name},\n\n"
368
+ "This is to notify you that a resource has been deleted in your Trovesuite account.\n\n"
369
+ "DELETION DETAILS:\n"
370
+ "==================\n"
371
+ "Resource Type: {resource_type}\n"
372
+ "Resource Name: {resource_name}\n"
373
+ "Deleted By: {deleted_by_name} ({deleted_by_email})\n"
374
+ "Deletion Time: {cdate} at {ctime}\n"
375
+ "{message_text}"
376
+ "\n"
377
+ "NOTE:\n"
378
+ "This resource has been soft-deleted and can be restored if needed.\n"
379
+ "Please contact the person who deleted it or log in to your account to review this action.\n\n"
380
+ "Login: {app_url}\n\n"
381
+ "— The Trovesuite Team\n"
382
+ "Sent on: {cdate} at {ctime}"
383
+ )
384
+
385
+ RESOURCE_STATUS_CHANGE_HTML_TEMPLATE = """
386
+ <html>
387
+ <body style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px; margin: 0;">
388
+ <div style="max-width: 600px; margin: auto; background: white; border-radius: 12px; padding: 30px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
389
+ <!-- Header -->
390
+ <div style="text-align: center; padding-bottom: 25px; border-bottom: 2px solid {status_color};">
391
+ <h1 style="color: {status_color}; margin: 0; font-size: 28px;">{status_icon} {status_title}</h1>
392
+ </div>
393
+
394
+ <!-- Greeting -->
395
+ <div style="padding: 25px 0;">
396
+ <p style="font-size: 16px; color: #333; margin-bottom: 15px;">Dear <strong>{admin_name}</strong>,</p>
397
+ <p style="font-size: 15px; color: #333; line-height: 1.6;">
398
+ {status_description}
399
+ </p>
400
+ </div>
401
+
402
+ <!-- Status Details -->
403
+ <div style="background-color: #f8f9fa; border-radius: 8px; padding: 20px; margin: 20px 0;">
404
+ <h3 style="color: {status_color}; margin-top: 0; font-size: 18px; margin-bottom: 15px;">📋 Status Details</h3>
405
+ <table style="width: 100%; border-collapse: collapse;">
406
+ <tr>
407
+ <td style="padding: 8px 0; color: #666; font-size: 14px; width: 35%;">
408
+ <strong>Resource Type:</strong>
409
+ </td>
410
+ <td style="padding: 8px 0; color: #333; font-size: 14px;">
411
+ {resource_type}
412
+ </td>
413
+ </tr>
414
+ <tr>
415
+ <td style="padding: 8px 0; color: #666; font-size: 14px;">
416
+ <strong>Resource Name:</strong>
417
+ </td>
418
+ <td style="padding: 8px 0; color: #333; font-size: 14px;">
419
+ {resource_name}
420
+ </td>
421
+ </tr>
422
+ <tr>
423
+ <td style="padding: 8px 0; color: #666; font-size: 14px;">
424
+ <strong>Status:</strong>
425
+ </td>
426
+ <td style="padding: 8px 0; color: {status_color}; font-size: 14px; font-weight: 600;">
427
+ {status_display}
428
+ </td>
429
+ </tr>
430
+ <tr>
431
+ <td style="padding: 8px 0; color: #666; font-size: 14px;">
432
+ <strong>Triggered By:</strong>
433
+ </td>
434
+ <td style="padding: 8px 0; color: #333; font-size: 14px;">
435
+ {actor_name} ({actor_email})
436
+ </td>
437
+ </tr>
438
+ <tr style="{message_row_style}">
439
+ <td style="padding: 8px 0; color: #666; font-size: 14px; vertical-align: top;">
440
+ <strong>Message:</strong>
441
+ </td>
442
+ <td style="padding: 8px 0; color: #333; font-size: 14px;">
443
+ {message}
444
+ </td>
445
+ </tr>
446
+ </table>
447
+ </div>
448
+
449
+ <!-- CTA Button -->
450
+ <div style="text-align: center; margin: 30px 0;">
451
+ <a href="{app_url}" style="display: inline-block; background-color: #2e86de; color: white; padding: 14px 35px; text-decoration: none; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(46, 134, 222, 0.3);">
452
+ Login to Review →
453
+ </a>
454
+ </div>
455
+
456
+ <!-- Footer -->
457
+ <hr style="border: none; border-top: 1px solid #eee; margin: 25px 0;">
458
+ <div style="text-align: center;">
459
+ <p style="font-size: 12px; color: #888; margin: 5px 0;">
460
+ This is an automated notification from Trovesuite
461
+ </p>
462
+ <p style="font-size: 12px; color: #888; margin: 5px 0;">
463
+ Trovesuite - Empowering Your Business<br>
464
+ Sent on: {cdate} at {ctime}
465
+ </p>
466
+ </div>
467
+ </div>
468
+ </body>
469
+ </html>
470
+ """
471
+
472
+ RESOURCE_STATUS_CHANGE_TEXT_TEMPLATE = (
473
+ "Resource Status Update\n\n"
474
+ "Dear {admin_name},\n\n"
475
+ "{status_description}\n\n"
476
+ "DETAILS:\n"
477
+ "========\n"
478
+ "Resource Type: {resource_type}\n"
479
+ "Resource Name: {resource_name}\n"
480
+ "Status: {status_display}\n"
481
+ "Triggered By: {actor_name} ({actor_email})\n"
482
+ "{message_text}"
483
+ "\n"
484
+ "Login: {app_url}\n\n"
485
+ "— The Trovesuite Team\n"
486
+ "Sent on: {cdate} at {ctime}"
487
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trovesuite
3
- Version: 1.0.14
3
+ Version: 1.0.17
4
4
  Summary: TroveSuite services package providing authentication, authorization, notifications, Azure Storage, and other enterprise services for TroveSuite applications
5
5
  Home-page: https://dev.azure.com/brightgclt/trovesuite/_git/packages
6
6
  Author: Bright Debrah Owusu
@@ -1,14 +1,14 @@
1
- trovesuite/__init__.py,sha256=QIY7iN7TVyJKjCoB6CBA4pSqm_mL41frcFgFS4maO8k,555
1
+ trovesuite/__init__.py,sha256=_L_7xg3oeOXLmq1MpXPwZvkXiPL0zrw-H3STHUmEMNg,646
2
2
  trovesuite/auth/__init__.py,sha256=OjZllVvjul1glDazJ-d5TrNjgHFigFlQQi1G99DYshk,239
3
3
  trovesuite/auth/auth_base.py,sha256=rZHQVLeJRBQ8GClgF5UwG-er4_HXVX5-nt8o6_Z29uY,75
4
4
  trovesuite/auth/auth_controller.py,sha256=PAgaVlf5TYEfkSfK4vGGsvO84i8zEmeVVXyUF2YBppI,420
5
5
  trovesuite/auth/auth_read_dto.py,sha256=e27JqKVPVUM83A_mYF452QCflsvGNo7aKje7q_urwFc,571
6
- trovesuite/auth/auth_service.py,sha256=_noR3A4Ss7ioP6j--MvlN0EWvcjs5Nljpa3u9zHDooY,18044
6
+ trovesuite/auth/auth_service.py,sha256=G2_lkXdMnuqpBjKFYGlRUrUvVyq8keukXaSyboiWiUM,18106
7
7
  trovesuite/auth/auth_write_dto.py,sha256=rdwI7w6-9QZGv1H0PAGrjkLBCzaMHjgPIXeLb9RmNec,234
8
8
  trovesuite/configs/__init__.py,sha256=h1mSZOaZ3kUy1ZMO_m9O9KklsxywM0RfMVZLh9h9WvQ,328
9
9
  trovesuite/configs/database.py,sha256=IPSu8fXjxyYeJ3bFknJG06Qm2L2ub6Ht19xhKv8g7nA,11731
10
10
  trovesuite/configs/logging.py,sha256=mGjR2d4urVNry9l5_aXycMMtcY2RAFIpEL35hw33KZg,9308
11
- trovesuite/configs/settings.py,sha256=yUbkiFi4QdO9JZG1RRFbP4tYurT47HmN-ohgYcs2SHM,2561
11
+ trovesuite/configs/settings.py,sha256=9kl4f7AOtulUnmoWcrLVa8LKjGirZkPYSWtACoTqr5U,5652
12
12
  trovesuite/entities/__init__.py,sha256=Dbl_03Bueyh2vOP2hykd40MmNMrl5nNHSRGP-kqwwNo,160
13
13
  trovesuite/entities/health.py,sha256=KaW7yxTQdymIPlnkJJkDqEebBXkD0a7A66i5GgNZLoE,2700
14
14
  trovesuite/entities/sh_response.py,sha256=1_sw3PpVaDxWsNiBU0W9YLHZgTFxEj4JJBLBfSY63Ho,1579
@@ -24,10 +24,11 @@ trovesuite/storage/storage_controller.py,sha256=Yzki0L-jmSPbiw8spFm6Z84Bq-WZfih_
24
24
  trovesuite/storage/storage_read_dto.py,sha256=o7EVJdwrwVZAaeyGU9O01WMECGVaytkvLRwruA256hQ,1471
25
25
  trovesuite/storage/storage_service.py,sha256=V7LIePIV6b_iuhm-9x8r4zwpZHgeRPL1YIe5IBnxhco,19768
26
26
  trovesuite/storage/storage_write_dto.py,sha256=vl1iCZ93bpFmpvkCrn587QtMtOA_TPDseXSoTuj9RTQ,1355
27
- trovesuite/utils/__init__.py,sha256=3UPKTz9cluTgAM-ldNsJxsnoPTZiqacXlAmzUEHy6q8,143
28
- trovesuite/utils/helper.py,sha256=lvZ1mvaqY84dkIPB5Ov0uwYDOWBziAS8twobEJZh2Ik,1002
29
- trovesuite-1.0.14.dist-info/licenses/LICENSE,sha256=EJT35ct-Q794JYPdAQy3XNczQGKkU1HzToLeK1YVw2s,1070
30
- trovesuite-1.0.14.dist-info/METADATA,sha256=3A2HHGAEGEtZ1HTRflqCVmO6nhSewwiSrentaWVQSTQ,21737
31
- trovesuite-1.0.14.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
32
- trovesuite-1.0.14.dist-info/top_level.txt,sha256=GzKhG_-MTaxeHrIgkGkBH_nof2vroGFBrjeHKWUIwNc,11
33
- trovesuite-1.0.14.dist-info/RECORD,,
27
+ trovesuite/utils/__init__.py,sha256=mDZuY77BphvQFYLmcWxjP5Tcq9ZZ3WXJWBKB1v6wzHU,185
28
+ trovesuite/utils/helper.py,sha256=k7Meg8sN4aPBdqcjaF1tQcZ32KvuA-IqjpWniQP_Mpk,26438
29
+ trovesuite/utils/templates.py,sha256=_92k4-EkqWs-h0LNJxPgorbspmp24kDngS7O3qWIFyQ,20388
30
+ trovesuite-1.0.17.dist-info/licenses/LICENSE,sha256=EJT35ct-Q794JYPdAQy3XNczQGKkU1HzToLeK1YVw2s,1070
31
+ trovesuite-1.0.17.dist-info/METADATA,sha256=UB2VoJbb7XXl1GUPVjq8BL_zXB_DMQMzSmuYabHBuAo,21737
32
+ trovesuite-1.0.17.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
33
+ trovesuite-1.0.17.dist-info/top_level.txt,sha256=GzKhG_-MTaxeHrIgkGkBH_nof2vroGFBrjeHKWUIwNc,11
34
+ trovesuite-1.0.17.dist-info/RECORD,,