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.
- ipulse_shared_core_ftredge/dependencies/auth_firebase_token_validation.py +60 -23
- ipulse_shared_core_ftredge/dependencies/authz_for_apis.py +128 -157
- ipulse_shared_core_ftredge/exceptions/base_exceptions.py +12 -4
- ipulse_shared_core_ftredge/models/catalog/subscriptionplan.py +4 -3
- ipulse_shared_core_ftredge/models/catalog/usertype.py +8 -1
- ipulse_shared_core_ftredge/models/user/user_subscription.py +142 -30
- ipulse_shared_core_ftredge/models/user/userstatus.py +63 -14
- ipulse_shared_core_ftredge/services/base/base_firestore_service.py +5 -3
- ipulse_shared_core_ftredge/services/catalog/catalog_subscriptionplan_service.py +27 -23
- ipulse_shared_core_ftredge/services/catalog/catalog_usertype_service.py +94 -25
- ipulse_shared_core_ftredge/services/user/user_core_service.py +141 -23
- ipulse_shared_core_ftredge/services/user/user_multistep_operations.py +144 -74
- ipulse_shared_core_ftredge/services/user/user_subscription_operations.py +24 -20
- ipulse_shared_core_ftredge/services/user/userstatus_operations.py +268 -4
- {ipulse_shared_core_ftredge-22.1.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/METADATA +1 -1
- {ipulse_shared_core_ftredge-22.1.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/RECORD +19 -20
- ipulse_shared_core_ftredge/services/user/firebase_auth_admin_helpers.py +0 -160
- {ipulse_shared_core_ftredge-22.1.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/WHEEL +0 -0
- {ipulse_shared_core_ftredge-22.1.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/licenses/LICENCE +0 -0
- {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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
description="
|
|
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
|
|
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
|
|
173
|
-
if '
|
|
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['
|
|
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.
|
|
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.
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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] =
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
204
|
-
order_by =
|
|
205
|
-
if
|
|
206
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
#
|
|
201
|
-
order_by =
|
|
202
|
-
if
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|