ipulse-shared-core-ftredge 22.1.1__py3-none-any.whl → 23.1.1__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.

Potentially problematic release.


This version of ipulse-shared-core-ftredge might be problematic. Click here for more details.

Files changed (20) hide show
  1. ipulse_shared_core_ftredge/dependencies/auth_firebase_token_validation.py +60 -23
  2. ipulse_shared_core_ftredge/dependencies/authz_for_apis.py +128 -157
  3. ipulse_shared_core_ftredge/exceptions/base_exceptions.py +12 -4
  4. ipulse_shared_core_ftredge/models/catalog/subscriptionplan.py +4 -3
  5. ipulse_shared_core_ftredge/models/catalog/usertype.py +8 -1
  6. ipulse_shared_core_ftredge/models/user/user_subscription.py +142 -30
  7. ipulse_shared_core_ftredge/models/user/userstatus.py +63 -14
  8. ipulse_shared_core_ftredge/services/base/base_firestore_service.py +5 -3
  9. ipulse_shared_core_ftredge/services/catalog/catalog_subscriptionplan_service.py +27 -23
  10. ipulse_shared_core_ftredge/services/catalog/catalog_usertype_service.py +94 -25
  11. ipulse_shared_core_ftredge/services/user/user_core_service.py +141 -23
  12. ipulse_shared_core_ftredge/services/user/user_multistep_operations.py +144 -74
  13. ipulse_shared_core_ftredge/services/user/user_subscription_operations.py +24 -20
  14. ipulse_shared_core_ftredge/services/user/userstatus_operations.py +268 -4
  15. {ipulse_shared_core_ftredge-22.1.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/METADATA +1 -1
  16. {ipulse_shared_core_ftredge-22.1.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/RECORD +19 -20
  17. ipulse_shared_core_ftredge/services/user/firebase_auth_admin_helpers.py +0 -160
  18. {ipulse_shared_core_ftredge-22.1.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/WHEEL +0 -0
  19. {ipulse_shared_core_ftredge-22.1.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/licenses/LICENCE +0 -0
  20. {ipulse_shared_core_ftredge-22.1.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/top_level.txt +0 -0
@@ -3,7 +3,7 @@ from dateutil.relativedelta import relativedelta
3
3
  import uuid
4
4
  from typing import Optional, ClassVar, Dict, Any, List
5
5
  from pydantic import Field, ConfigDict, model_validator
6
- from ipulse_shared_base_ftredge import Layer, Module, list_enums_as_lower_strings, Subject, SubscriptionPlanName, SubscriptionStatus
6
+ from ipulse_shared_base_ftredge import Layer, Module, list_enums_as_lower_strings, Subject, SubscriptionPlanName, SubscriptionStatus, TimeUnit
7
7
  from ..base_data_model import BaseDataModel
8
8
  from .user_permissions import UserPermission
9
9
  # ORIGINAL AUTHOR ="russlan.ramdowar;russlan@ftredge.com"
@@ -62,9 +62,9 @@ class UserSubscription(BaseDataModel):
62
62
  )
63
63
 
64
64
  # Direct field instead of computed - will be auto-calculated
65
- cycle_end_date: Optional[datetime] = Field(
66
- default=None,
67
- description="Subscription Cycle End Date (auto-calculated if not provided)"
65
+ cycle_end_datetime: Optional[datetime] = Field(
66
+ default=None, # Optional during creation, auto-calculated by validator
67
+ description="Subscription Cycle End Date (auto-calculated if not provided during creation)"
68
68
  )
69
69
 
70
70
  # Fields for cycle calculation
@@ -79,9 +79,9 @@ class UserSubscription(BaseDataModel):
79
79
  )
80
80
 
81
81
  # Renewal and status fields
82
- auto_renew: bool = Field(
83
- ..., # Required field, no default
84
- description="Auto-renewal status"
82
+ auto_renew_end_datetime: Optional[datetime] = Field(
83
+ default=None,
84
+ description="End datetime for auto-renewal period. If None, no auto-renewal. If set, auto-renewal is active until this time."
85
85
  )
86
86
 
87
87
  status: SubscriptionStatus = Field(
@@ -163,25 +163,62 @@ class UserSubscription(BaseDataModel):
163
163
  @classmethod
164
164
  def auto_calculate_cycle_end_date(cls, data: Dict[str, Any]) -> Dict[str, Any]:
165
165
  """
166
- Auto-calculate cycle_end_date if not provided, based on cycle_start_date,
166
+ Auto-calculate cycle_end_datetime if not provided, based on cycle_start_date,
167
167
  validity_time_length, and validity_time_unit.
168
168
  """
169
169
  if not isinstance(data, dict):
170
170
  return data
171
171
 
172
- # Only calculate if cycle_end_date is not already provided
173
- if 'cycle_end_date' not in data or data['cycle_end_date'] is None:
172
+ # Only calculate if cycle_end_datetime is not already provided or is the default
173
+ if ('cycle_end_datetime' not in data or
174
+ data['cycle_end_datetime'] is None or
175
+ # Check if it's the default factory value (close to now)
176
+ (isinstance(data.get('cycle_end_datetime'), datetime) and
177
+ abs((data['cycle_end_datetime'] - datetime.now(timezone.utc)).total_seconds()) < 5)):
178
+
174
179
  cycle_start_date = data.get('cycle_start_date')
175
180
  validity_time_length = data.get('validity_time_length')
176
181
  validity_time_unit = data.get('validity_time_unit')
177
182
 
178
183
  if cycle_start_date and validity_time_length and validity_time_unit:
179
- data['cycle_end_date'] = cls.calculate_cycle_end_date(
184
+ data['cycle_end_datetime'] = cls.calculate_cycle_end_date(
180
185
  cycle_start_date, validity_time_length, validity_time_unit
181
186
  )
187
+ else:
188
+ raise ValueError(
189
+ "Cannot create subscription without cycle_end_datetime. "
190
+ "Either provide cycle_end_datetime directly or provide "
191
+ "cycle_start_date, validity_time_length, and validity_time_unit for auto-calculation."
192
+ )
182
193
 
183
194
  return data
184
195
 
196
+ @model_validator(mode='after')
197
+ def validate_cycle_end_date_required(self) -> 'UserSubscription':
198
+ """
199
+ Ensures cycle_end_datetime is NEVER None after all processing.
200
+ This is a business rule validation that must always pass.
201
+ """
202
+ if self.cycle_end_datetime is None:
203
+ raise ValueError(
204
+ "cycle_end_datetime is required and cannot be None. "
205
+ "This is a critical business rule violation."
206
+ )
207
+ return self
208
+
209
+ @property
210
+ def cycle_end_datetime_safe(self) -> datetime:
211
+ """
212
+ Get cycle_end_datetime with guaranteed non-None value.
213
+ This property enforces the business rule that cycle_end_datetime is never None after validation.
214
+ """
215
+ if self.cycle_end_datetime is None:
216
+ raise ValueError(
217
+ "cycle_end_datetime is None - this violates the business rule. "
218
+ "Subscription model validation should have prevented this."
219
+ )
220
+ return self.cycle_end_datetime
221
+
185
222
  # Helper method to calculate cycle end date
186
223
  @classmethod
187
224
  def calculate_cycle_end_date(cls, start_date: datetime, validity_length: int, validity_unit: str) -> datetime:
@@ -202,35 +239,110 @@ class UserSubscription(BaseDataModel):
202
239
  # Methods for subscription management
203
240
  def is_active(self) -> bool:
204
241
  """Check if the subscription is currently active."""
205
- if not self.cycle_end_date:
206
- return False
207
242
  now = datetime.now(timezone.utc)
208
243
  return (
209
244
  self.status == SubscriptionStatus.ACTIVE and
210
- self.cycle_start_date <= now <= self.cycle_end_date
245
+ self.cycle_start_date <= now <= self.cycle_end_datetime_safe
211
246
  )
212
247
 
213
248
  def is_expired(self) -> bool:
214
249
  """Check if the subscription has expired."""
215
- if not self.cycle_end_date:
216
- return True
217
250
  now = datetime.now(timezone.utc)
218
- return now > self.cycle_end_date
251
+ return now > self.cycle_end_datetime_safe
252
+
253
+ def subscription_time_remaining(self, unit: TimeUnit = TimeUnit.SECOND, with_auto_renew: bool = False) -> float:
254
+ """
255
+ Calculate time remaining in the subscription.
256
+
257
+ Args:
258
+ unit: Time unit to return (using TimeUnit enum)
259
+ with_auto_renew: Whether to consider auto-renewal cycles until auto_renew_end_datetime
219
260
 
220
- def days_remaining(self) -> int:
221
- """Calculate the number of days remaining in the subscription."""
222
- if not self.cycle_end_date:
223
- return 0
261
+ Returns:
262
+ Time remaining in the specified unit as float
263
+ """
224
264
  now = datetime.now(timezone.utc)
225
- if now > self.cycle_end_date:
226
- return 0
227
265
 
228
- # Get time difference
229
- time_diff = self.cycle_end_date - now
266
+ if with_auto_renew and self.auto_renew_end_datetime:
267
+ # Calculate with auto-renewal logic
268
+ # If auto-renewal ends before/at current cycle end, only current cycle matters
269
+ if self.auto_renew_end_datetime <= self.cycle_end_datetime_safe:
270
+ if now >= self.cycle_end_datetime_safe:
271
+ return 0.0
272
+ time_diff = self.cycle_end_datetime_safe - now
273
+ else:
274
+ # If we're past the auto-renewal end date, no time remaining
275
+ if now >= self.auto_renew_end_datetime:
276
+ return 0.0
277
+
278
+ # Calculate the last cycle end date that falls within auto_renew_end_datetime
279
+ last_cycle_end = self._calculate_last_cycle_end_within_auto_renew()
280
+
281
+ # Calculate time from now until that last cycle end
282
+ if now >= last_cycle_end:
283
+ return 0.0
284
+
285
+ time_diff = last_cycle_end - now
286
+ else:
287
+ # Basic time calculation without auto-renewal
288
+ if now >= self.cycle_end_datetime_safe:
289
+ return 0.0
290
+ time_diff = self.cycle_end_datetime_safe - now
291
+
292
+ # Convert to the requested unit
293
+ return self._convert_time_to_unit(time_diff.total_seconds(), unit.value)
294
+
295
+ def _calculate_last_cycle_end_within_auto_renew(self) -> datetime:
296
+ """
297
+ Calculate the last cycle end date that falls within the auto_renew_end_datetime period.
298
+
299
+ Returns:
300
+ The last cycle end date before auto_renew_end_datetime expires
301
+ """
302
+ if not self.auto_renew_end_datetime:
303
+ return self.cycle_end_datetime_safe
304
+
305
+ # Start with current cycle end
306
+ current_cycle_end = self.cycle_end_datetime_safe
307
+
308
+ # Keep adding cycle lengths until we pass auto_renew_end_datetime
309
+ while current_cycle_end < self.auto_renew_end_datetime:
310
+ # Calculate next cycle end by adding the cycle length
311
+ next_cycle_end = self.calculate_cycle_end_date(
312
+ start_date=current_cycle_end,
313
+ validity_length=self.validity_time_length,
314
+ validity_unit=self.validity_time_unit
315
+ )
316
+
317
+ # If the next cycle would end after auto_renew_end_datetime, we stop
318
+ if next_cycle_end > self.auto_renew_end_datetime:
319
+ break
230
320
 
231
- # If there's any time remaining but less than a day, return 1
232
- if time_diff.days == 0 and time_diff.seconds > 0:
233
- return 1
321
+ current_cycle_end = next_cycle_end
322
+
323
+ return current_cycle_end
324
+
325
+ def _convert_time_to_unit(self, total_seconds: float, unit: str) -> float:
326
+ """
327
+ Convert seconds to the specified time unit.
328
+
329
+ Args:
330
+ total_seconds: Total seconds to convert
331
+ unit: Target unit (string value from TimeUnit enum)
332
+
333
+ Returns:
334
+ Time in the specified unit as float
335
+ """
336
+ conversions = {
337
+ 'second': 1.0,
338
+ 'minute': 60.0,
339
+ 'hour': 3600.0,
340
+ 'day': 3600.0 * 24.0,
341
+ 'week': 3600.0 * 24.0 * 7.0,
342
+ 'month': 3600.0 * 24.0 * 30.0, # Approximate 30 days per month
343
+ 'year': 3600.0 * 24.0 * 365.0, # Approximate 365 days per year
344
+ }
345
+
346
+ divisor = conversions.get(unit.lower(), 1.0)
347
+ return total_seconds / divisor
234
348
 
235
- # Otherwise return the number of complete days
236
- return time_diff.days
@@ -1,8 +1,8 @@
1
1
  """ User Status model for tracking user subscription and access rights. """
2
- from datetime import datetime, timezone
2
+ from datetime import datetime, timezone, timedelta
3
3
  from typing import Set, Optional, Dict, List, ClassVar, Any
4
4
  from pydantic import Field, ConfigDict, model_validator, field_validator
5
- from ipulse_shared_base_ftredge import Layer, Module, list_enums_as_lower_strings, Subject
5
+ from ipulse_shared_base_ftredge import Layer, Module, list_enums_as_lower_strings, Subject, TimeUnit
6
6
  from ipulse_shared_base_ftredge.enums.enums_iam import IAMUnit
7
7
  from .user_subscription import UserSubscription
8
8
  from ..base_data_model import BaseDataModel
@@ -19,7 +19,7 @@ class UserStatus(BaseDataModel):
19
19
  model_config = ConfigDict(frozen=False, extra="forbid")
20
20
 
21
21
  # Class constants
22
- VERSION: ClassVar[float] = 6.0 # Major version bump for flattened IAM permissions structure
22
+ VERSION: ClassVar[float] = 7.0 # Major version bump for flattened IAM permissions structure
23
23
  DOMAIN: ClassVar[str] = "_".join(list_enums_as_lower_strings(Layer.PULSE_APP, Module.CORE, Subject.USER))
24
24
  OBJ_REF: ClassVar[str] = "userstatus"
25
25
 
@@ -329,7 +329,7 @@ class UserStatus(BaseDataModel):
329
329
  iam_unit_type=permission.iam_unit_type,
330
330
  permission_ref=permission.permission_ref,
331
331
  source=source,
332
- expires_at=subscription.cycle_end_date,
332
+ expires_at=subscription.cycle_end_datetime,
333
333
  granted_by=granted_by
334
334
  )
335
335
  added_count += 1
@@ -343,14 +343,8 @@ class UserStatus(BaseDataModel):
343
343
  return self.active_subscription.is_active()
344
344
  return False
345
345
 
346
- # Method instead of computed field
347
- def subscription_expires_in_days(self) -> Optional[int]:
348
- """Get days until subscription expiration."""
349
- if self.active_subscription and self.active_subscription.is_active():
350
- return self.active_subscription.days_remaining()
351
- return None
352
346
 
353
- def apply_subscription(self, subscription: UserSubscription, add_associated_permissions: bool = True, remove_existing_subscription_permissions: bool = True, granted_by: Optional[str] = None) -> int:
347
+ def apply_subscription(self, subscription: UserSubscription, add_associated_permissions: bool = True, remove_previous_subscription_permissions: bool = True, granted_by: Optional[str] = None) -> int:
354
348
  """
355
349
  Apply a subscription's benefits to the user status.
356
350
  This updates credits, permissions, and sets the active subscription.
@@ -358,7 +352,7 @@ class UserStatus(BaseDataModel):
358
352
  Args:
359
353
  subscription: The subscription to apply
360
354
  add_associated_permissions: If True, adds IAM permissions from the subscription
361
- remove_existing_subscription_permissions: If True, removes IAM permissions from any existing subscription
355
+ remove_previous_subscription_permissions: If True, removes IAM permissions from any existing subscription
362
356
  granted_by: Who granted this permission (user ID, system process, etc.)
363
357
 
364
358
  Returns:
@@ -370,7 +364,7 @@ class UserStatus(BaseDataModel):
370
364
  permissions_added = 0
371
365
 
372
366
  # Remove existing subscription permissions if requested
373
- if remove_existing_subscription_permissions and self.active_subscription:
367
+ if remove_previous_subscription_permissions and self.active_subscription:
374
368
  # Use the subscription plan_id as the source (which already contains "subscription" and version)
375
369
  source = self.active_subscription.plan_id
376
370
  removed_permissions = self.remove_all_permissions(source=source)
@@ -427,4 +421,59 @@ class UserStatus(BaseDataModel):
427
421
  self.sbscrptn_based_insight_credits_updtd_on = datetime.now(timezone.utc)
428
422
  self.active_subscription = None
429
423
 
430
- return permissions_removed
424
+ return permissions_removed
425
+
426
+ ########################################################################
427
+ ############ ######### Credit Management ######### #############
428
+ ########################################################################
429
+
430
+ def should_update_subscription_credits(self) -> bool:
431
+ """
432
+ Check if subscription-based credits should be updated based on the cycle timing.
433
+
434
+ Credits should be updated if:
435
+ 1. User has an active subscription
436
+ 2. The last update was before the current cycle period started
437
+
438
+ Returns:
439
+ True if credits should be updated, False otherwise
440
+ """
441
+ if not self.active_subscription or not self.active_subscription.is_active():
442
+ return False
443
+
444
+ now = datetime.now(timezone.utc)
445
+ cycle_start = self.active_subscription.cycle_start_date
446
+ update_frequency_hours = self.active_subscription.subscription_based_insight_credits_update_freq_h
447
+
448
+ # Calculate when the next credit update should happen based on cycle start
449
+ # We need to find the most recent cycle boundary that has passed
450
+ hours_since_cycle_start = (now - cycle_start).total_seconds() / 3600
451
+ completed_periods = int(hours_since_cycle_start // update_frequency_hours)
452
+
453
+ if completed_periods == 0:
454
+ # We haven't completed even one period yet
455
+ return False
456
+
457
+ # Calculate when the last period should have ended
458
+ last_period_end = cycle_start + timedelta(hours=completed_periods * update_frequency_hours)
459
+
460
+ # Check if our last update was before this period ended
461
+ return self.sbscrptn_based_insight_credits_updtd_on < last_period_end
462
+
463
+ def update_subscription_credits(self) -> int:
464
+ """
465
+ Update subscription-based credits if needed.
466
+
467
+ Returns:
468
+ Amount of credits added (0 if no update needed)
469
+ """
470
+ if not self.should_update_subscription_credits() or not self.active_subscription:
471
+ return 0
472
+
473
+ final_credits = self.active_subscription.subscription_based_insight_credits_per_update
474
+
475
+ if final_credits > 0:
476
+ self.sbscrptn_based_insight_credits = final_credits
477
+ self.sbscrptn_based_insight_credits_updtd_on = datetime.now(timezone.utc)
478
+
479
+ return final_credits
@@ -372,7 +372,8 @@ class BaseFirestoreService(Generic[T]):
372
372
  limit: Optional[int] = None,
373
373
  start_after: Optional[str] = None,
374
374
  order_by: Optional[str] = None,
375
- filters: Optional[List[FieldFilter]] = None,
375
+ order_direction: str = firestore.Query.ASCENDING,
376
+ filters: Optional[List[tuple]] = None,
376
377
  as_models: bool = True
377
378
  ) -> Union[List[T], List[Dict[str, Any]]]:
378
379
  """
@@ -382,7 +383,8 @@ class BaseFirestoreService(Generic[T]):
382
383
  limit: Maximum number of documents to return
383
384
  start_after: Document ID to start after for pagination
384
385
  order_by: Field to order by
385
- filters: List of field filters
386
+ order_direction: Direction to order by (e.g., "ASCENDING", "DESCENDING")
387
+ filters: List of field filters as tuples (field, operator, value)
386
388
  as_models: Whether to convert documents to Pydantic models
387
389
 
388
390
  Returns:
@@ -402,7 +404,7 @@ class BaseFirestoreService(Generic[T]):
402
404
 
403
405
  # Apply ordering
404
406
  if order_by:
405
- query = query.order_by(order_by)
407
+ query = query.order_by(order_by, direction=order_direction)
406
408
 
407
409
  # Apply pagination
408
410
  if start_after:
@@ -174,7 +174,8 @@ class CatalogSubscriptionPlanService(BaseFirestoreService[SubscriptionPlan]):
174
174
  plan_name: Optional[SubscriptionPlanName] = None,
175
175
  pulse_status: Optional[ObjectOverallStatus] = None,
176
176
  latest_version_only: bool = False,
177
- limit: Optional[int] = None
177
+ limit: Optional[int] = None,
178
+ version_ordering: str = "DESCENDING"
178
179
  ) -> List[SubscriptionPlan]:
179
180
  """
180
181
  List subscription plans with optional filtering.
@@ -184,6 +185,7 @@ class CatalogSubscriptionPlanService(BaseFirestoreService[SubscriptionPlan]):
184
185
  pulse_status: Filter by specific pulse status
185
186
  latest_version_only: Only return the latest version per plan
186
187
  limit: Maximum number of plans to return
188
+ version_ordering: Order direction for version ('ASCENDING' or 'DESCENDING')
187
189
 
188
190
  Returns:
189
191
  List of subscription plans
@@ -191,7 +193,7 @@ class CatalogSubscriptionPlanService(BaseFirestoreService[SubscriptionPlan]):
191
193
  Raises:
192
194
  ServiceError: If listing fails
193
195
  """
194
- self.logger.debug(f"Listing subscription plans - plan_name: {plan_name}, pulse_status: {pulse_status}, latest_version_only: {latest_version_only}")
196
+ self.logger.debug(f"Listing subscription plans - plan_name: {plan_name}, pulse_status: {pulse_status}, latest_version_only: {latest_version_only}, version_ordering: {version_ordering}")
195
197
 
196
198
  # Build query filters
197
199
  filters = []
@@ -200,39 +202,41 @@ class CatalogSubscriptionPlanService(BaseFirestoreService[SubscriptionPlan]):
200
202
  if pulse_status:
201
203
  filters.append(("pulse_status", "==", pulse_status.value))
202
204
 
203
- # If latest_version_only is requested, order by version descending
204
- order_by = None
205
- if latest_version_only:
206
- order_by = "plan_version" # Use plan_version field name
205
+ # Set ordering
206
+ order_by = "plan_version"
207
+ order_direction = firestore.Query.DESCENDING if version_ordering == "DESCENDING" else firestore.Query.ASCENDING
208
+
209
+ # Optimize query if only the latest version of a specific plan is needed
210
+ query_limit = limit
211
+ if latest_version_only and plan_name:
212
+ query_limit = 1
213
+ # Ensure descending order to get the latest
214
+ order_direction = firestore.Query.DESCENDING
207
215
 
208
216
  docs = await self.list_documents(
209
217
  filters=filters,
210
218
  order_by=order_by,
211
- limit=limit
219
+ order_direction=order_direction,
220
+ limit=query_limit
212
221
  )
213
222
 
214
223
  # Convert to SubscriptionPlan models
215
- plans = [SubscriptionPlan.model_validate(doc) if isinstance(doc, dict) else doc for doc in docs]
224
+ plans = [SubscriptionPlan.model_validate(doc) for doc in docs]
225
+
226
+ # If we need the latest of all plans, we fetch all sorted by version
227
+ # and then pick the first one for each plan_name in Python.
228
+ if latest_version_only and not plan_name:
229
+ # This assumes the list is sorted by version descending.
230
+ if order_direction != firestore.Query.DESCENDING:
231
+ self.logger.warning("latest_version_only is True but version_ordering is not DESCENDING. Results may not be the latest.")
216
232
 
217
- # If latest_version_only is requested, group by plan_name and get highest version
218
- if latest_version_only:
219
- # Group by plan_name
220
233
  plan_groups = {}
221
234
  for plan in plans:
222
235
  key = plan.plan_name.value
223
236
  if key not in plan_groups:
224
- plan_groups[key] = []
225
- plan_groups[key].append(plan)
226
-
227
- # Get the latest version for each group
228
- latest_plans = []
229
- for group in plan_groups.values():
230
- if group:
231
- # Sort by plan_version descending and take the first
232
- latest = max(group, key=lambda x: x.plan_version)
233
- latest_plans.append(latest)
234
-
235
- return latest_plans
237
+ plan_groups[key] = plan # First one is the latest due to sorting
238
+
239
+ return list(plan_groups.values())
236
240
 
237
241
  return plans
238
242
 
@@ -7,7 +7,7 @@ These templates are used to configure default settings for user profiles and sta
7
7
 
8
8
  import logging
9
9
  from typing import Dict, List, Optional, Any
10
- from google.cloud.firestore import Client
10
+ from google.cloud.firestore import Client , Query
11
11
  from ipulse_shared_base_ftredge import IAMUserType
12
12
  from ipulse_shared_base_ftredge.enums.enums_status import ObjectOverallStatus
13
13
  from ipulse_shared_core_ftredge.models.catalog.usertype import UserType
@@ -171,7 +171,8 @@ class CatalogUserTypeService(BaseFirestoreService[UserType]):
171
171
  primary_usertype: Optional[IAMUserType] = None,
172
172
  pulse_status: Optional[ObjectOverallStatus] = None,
173
173
  latest_version_only: bool = False,
174
- limit: Optional[int] = None
174
+ limit: Optional[int] = None,
175
+ version_ordering: str = "DESCENDING"
175
176
  ) -> List[UserType]:
176
177
  """
177
178
  List usertypes with optional filtering.
@@ -181,6 +182,7 @@ class CatalogUserTypeService(BaseFirestoreService[UserType]):
181
182
  pulse_status: Filter by specific pulse status
182
183
  latest_version_only: Only return the latest version per usertype
183
184
  limit: Maximum number of usertypes to return
185
+ version_ordering: Order direction for version ('ASCENDING' or 'DESCENDING')
184
186
 
185
187
  Returns:
186
188
  List of usertypes
@@ -188,7 +190,7 @@ class CatalogUserTypeService(BaseFirestoreService[UserType]):
188
190
  Raises:
189
191
  ServiceError: If listing fails
190
192
  """
191
- self.logger.debug(f"Listing usertypes - primary_usertype: {primary_usertype}, pulse_status: {pulse_status}, latest_version_only: {latest_version_only}")
193
+ self.logger.debug(f"Listing usertypes - primary_usertype: {primary_usertype}, pulse_status: {pulse_status}, latest_version_only: {latest_version_only}, version_ordering: {version_ordering}")
192
194
 
193
195
  # Build query filters
194
196
  filters = []
@@ -197,41 +199,41 @@ class CatalogUserTypeService(BaseFirestoreService[UserType]):
197
199
  if pulse_status:
198
200
  filters.append(("pulse_status", "==", pulse_status.value))
199
201
 
200
- # If latest_version_only is requested, order by version descending
201
- order_by = None
202
- if latest_version_only:
203
- order_by = "version"
204
- # We'll need to group by primary_usertype and take the first from each group
205
- # This is more complex, so let's handle it differently
202
+ # Set ordering
203
+ order_by = "version"
204
+ order_direction = Query.DESCENDING if version_ordering == "DESCENDING" else Query.ASCENDING
205
+
206
+ # Optimize query if only the latest version of a specific usertype is needed
207
+ query_limit = limit
208
+ if latest_version_only and primary_usertype:
209
+ query_limit = 1
210
+ # Ensure descending order to get the latest
211
+ order_direction = Query.DESCENDING
206
212
 
207
213
  docs = await self.list_documents(
208
214
  filters=filters,
209
215
  order_by=order_by,
210
- limit=limit
216
+ order_direction=order_direction,
217
+ limit=query_limit
211
218
  )
212
219
 
213
220
  # Convert to UserType models
214
- usertypes = [UserType.model_validate(doc) if isinstance(doc, dict) else doc for doc in docs]
221
+ usertypes = [UserType.model_validate(doc) for doc in docs]
222
+
223
+ # If we need the latest of all usertypes, we fetch all sorted by version
224
+ # and then pick the first one for each primary_usertype in Python.
225
+ if latest_version_only and not primary_usertype:
226
+ # This assumes the list is sorted by version descending.
227
+ if order_direction != Query.DESCENDING:
228
+ self.logger.warning("latest_version_only is True but version_ordering is not DESCENDING. Results may not be the latest.")
215
229
 
216
- # If latest_version_only is requested, group by primary_usertype and get highest version
217
- if latest_version_only:
218
- # Group by primary_usertype
219
230
  usertype_groups = {}
220
231
  for usertype in usertypes:
221
232
  key = usertype.primary_usertype.value
222
233
  if key not in usertype_groups:
223
- usertype_groups[key] = []
224
- usertype_groups[key].append(usertype)
225
-
226
- # Get the latest version for each group
227
- latest_usertypes = []
228
- for group in usertype_groups.values():
229
- if group:
230
- # Sort by version descending and take the first
231
- latest = max(group, key=lambda x: x.version)
232
- latest_usertypes.append(latest)
234
+ usertype_groups[key] = usertype # First one is the latest due to sorting
233
235
 
234
- return latest_usertypes
236
+ return list(usertype_groups.values())
235
237
 
236
238
  return usertypes
237
239
 
@@ -305,3 +307,70 @@ class CatalogUserTypeService(BaseFirestoreService[UserType]):
305
307
  "extra_insight_credits": usertype.default_extra_insight_credits,
306
308
  "voting_credits": usertype.default_voting_credits
307
309
  }
310
+
311
+ async def fetch_catalog_usertype_based_on_email(
312
+ self,
313
+ email: str,
314
+ superadmins: Optional[List[str]] = None,
315
+ admins: Optional[List[str]] = None,
316
+ internal_domains: Optional[List[str]] = None
317
+ ) -> UserType:
318
+ """
319
+ Fetch the actual usertype from catalog based on email domain classification.
320
+
321
+ Args:
322
+ email: User's email address
323
+ superadmins: List of superadmin emails
324
+ admins: List of admin emails
325
+ internal_domains: List of internal domains
326
+
327
+ Returns:
328
+ UserType object from the catalog
329
+
330
+ Raises:
331
+ ServiceError: If no active usertype found for the determined primary_usertype
332
+ """
333
+ if not email:
334
+ primary_usertype = IAMUserType.CUSTOMER
335
+ else:
336
+ superadmins = superadmins or []
337
+ admins = admins or []
338
+ internal_domains = internal_domains or []
339
+
340
+ email_lower = email.lower()
341
+
342
+ if email_lower in [admin.lower() for admin in superadmins]:
343
+ primary_usertype = IAMUserType.SUPERADMIN
344
+ elif email_lower in [admin.lower() for admin in admins]:
345
+ primary_usertype = IAMUserType.ADMIN
346
+ else:
347
+ domain = email_lower.split('@')[-1] if '@' in email_lower else ''
348
+ if domain in internal_domains:
349
+ primary_usertype = IAMUserType.INTERNAL
350
+ else:
351
+ primary_usertype = IAMUserType.CUSTOMER
352
+
353
+ # Get the actual usertype from the catalog
354
+ usertypes = await self.list_usertypes(
355
+ primary_usertype=primary_usertype,
356
+ pulse_status=ObjectOverallStatus.ACTIVE,
357
+ latest_version_only=True,
358
+ limit=1
359
+ )
360
+
361
+ if not usertypes:
362
+ from ipulse_shared_core_ftredge.exceptions import ServiceError
363
+ raise ServiceError(
364
+ error=f"No active usertype found in catalog for primary_usertype '{primary_usertype.value}'",
365
+ resource_type="usertype",
366
+ operation="fetch_catalog_usertype_based_on_email",
367
+ additional_info={
368
+ "email": email,
369
+ "primary_usertype": primary_usertype.value,
370
+ "domain": email.split('@')[-1] if '@' in email else ''
371
+ }
372
+ )
373
+
374
+ usertype = usertypes[0]
375
+ self.logger.info(f"Found usertype '{usertype.id}' for email '{email}' with primary_usertype '{primary_usertype.value}'")
376
+ return usertype