ipulse-shared-core-ftredge 20.0.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/cache/shared_cache.py +1 -2
- 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 +35 -4
- ipulse_shared_core_ftredge/models/__init__.py +3 -7
- ipulse_shared_core_ftredge/models/base_data_model.py +17 -19
- ipulse_shared_core_ftredge/models/catalog/__init__.py +10 -0
- ipulse_shared_core_ftredge/models/catalog/subscriptionplan.py +274 -0
- ipulse_shared_core_ftredge/models/catalog/usertype.py +177 -0
- ipulse_shared_core_ftredge/models/user/__init__.py +5 -0
- ipulse_shared_core_ftredge/models/user/user_permissions.py +66 -0
- ipulse_shared_core_ftredge/models/user/user_subscription.py +348 -0
- ipulse_shared_core_ftredge/models/{user_auth.py → user/userauth.py} +19 -10
- ipulse_shared_core_ftredge/models/{user_profile.py → user/userprofile.py} +53 -21
- ipulse_shared_core_ftredge/models/user/userstatus.py +479 -0
- ipulse_shared_core_ftredge/monitoring/__init__.py +0 -2
- ipulse_shared_core_ftredge/monitoring/tracemon.py +6 -6
- ipulse_shared_core_ftredge/services/__init__.py +11 -13
- ipulse_shared_core_ftredge/services/base/__init__.py +3 -1
- ipulse_shared_core_ftredge/services/base/base_firestore_service.py +77 -16
- ipulse_shared_core_ftredge/services/{cache_aware_firestore_service.py → base/cache_aware_firestore_service.py} +46 -32
- ipulse_shared_core_ftredge/services/catalog/__init__.py +14 -0
- ipulse_shared_core_ftredge/services/catalog/catalog_subscriptionplan_service.py +277 -0
- ipulse_shared_core_ftredge/services/catalog/catalog_usertype_service.py +376 -0
- ipulse_shared_core_ftredge/services/charging_processors.py +25 -25
- ipulse_shared_core_ftredge/services/user/__init__.py +5 -25
- ipulse_shared_core_ftredge/services/user/user_core_service.py +536 -510
- ipulse_shared_core_ftredge/services/user/user_multistep_operations.py +796 -0
- ipulse_shared_core_ftredge/services/user/user_permissions_operations.py +392 -0
- ipulse_shared_core_ftredge/services/user/user_subscription_operations.py +488 -0
- ipulse_shared_core_ftredge/services/user/userauth_operations.py +928 -0
- ipulse_shared_core_ftredge/services/user/userprofile_operations.py +166 -0
- ipulse_shared_core_ftredge/services/user/userstatus_operations.py +476 -0
- ipulse_shared_core_ftredge/services/{charging_service.py → user_charging_service.py} +9 -9
- {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/METADATA +3 -4
- ipulse_shared_core_ftredge-23.1.1.dist-info/RECORD +50 -0
- ipulse_shared_core_ftredge/models/subscription.py +0 -190
- ipulse_shared_core_ftredge/models/user_status.py +0 -495
- ipulse_shared_core_ftredge/monitoring/microservmon.py +0 -526
- ipulse_shared_core_ftredge/services/user/iam_management_operations.py +0 -326
- ipulse_shared_core_ftredge/services/user/subscription_management_operations.py +0 -384
- ipulse_shared_core_ftredge/services/user/user_account_operations.py +0 -479
- ipulse_shared_core_ftredge/services/user/user_auth_operations.py +0 -305
- ipulse_shared_core_ftredge/services/user/user_holistic_operations.py +0 -436
- ipulse_shared_core_ftredge-20.0.1.dist-info/RECORD +0 -42
- {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/WHEEL +0 -0
- {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/licenses/LICENCE +0 -0
- {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from dateutil.relativedelta import relativedelta
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Optional, ClassVar, Dict, Any, List
|
|
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, TimeUnit
|
|
7
|
+
from ..base_data_model import BaseDataModel
|
|
8
|
+
from .user_permissions import UserPermission
|
|
9
|
+
# ORIGINAL AUTHOR ="russlan.ramdowar;russlan@ftredge.com"
|
|
10
|
+
# CLASS_ORGIN_DATE=datetime(2024, 2, 12, 20, 5)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DEFAULT_SUBSCRIPTION_PLAN = SubscriptionPlanName.FREE_SUBSCRIPTION
|
|
14
|
+
DEFAULT_SUBSCRIPTION_STATUS = SubscriptionStatus.ACTIVE
|
|
15
|
+
|
|
16
|
+
############################################ !!!!! ALWAYS UPDATE SCHEMA VERSION , IF SCHEMA IS BEING MODIFIED !!! ############################################
|
|
17
|
+
class UserSubscription(BaseDataModel):
|
|
18
|
+
"""
|
|
19
|
+
Represents a single subscription cycle with enhanced flexibility and tracking.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
23
|
+
|
|
24
|
+
VERSION: ClassVar[float] = 3.0 # Incremented version for direct fields instead of computed
|
|
25
|
+
DOMAIN: ClassVar[str] = "_".join(list_enums_as_lower_strings(Layer.PULSE_APP, Module.CORE, Subject.SUBSCRIPTION))
|
|
26
|
+
OBJ_REF: ClassVar[str] = "subscription"
|
|
27
|
+
|
|
28
|
+
# System-managed fields (read-only)
|
|
29
|
+
schema_version: float = Field(
|
|
30
|
+
default=VERSION,
|
|
31
|
+
description="Version of this Class == version of DB Schema",
|
|
32
|
+
frozen=True
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Unique identifier for this specific subscription instance - now auto-generated
|
|
36
|
+
id: Optional[str] = Field(
|
|
37
|
+
default=None, # Will be auto-generated using UUID if not provided
|
|
38
|
+
description="Unique identifier for this subscription instance"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Plan identification
|
|
42
|
+
plan_name: SubscriptionPlanName = Field(
|
|
43
|
+
..., # Required field, no default
|
|
44
|
+
description="Subscription Plan Name"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
plan_version: int = Field(
|
|
48
|
+
..., # Required field, no default
|
|
49
|
+
description="Version of the subscription plan"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Direct field instead of computed
|
|
53
|
+
plan_id: str = Field(
|
|
54
|
+
..., # Required field, no default
|
|
55
|
+
description="Combined plan identifier (plan_name_plan_version)"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Cycle duration fields
|
|
59
|
+
cycle_start_date: datetime = Field(
|
|
60
|
+
..., # Required field, no default
|
|
61
|
+
description="Subscription Cycle Start Date"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Direct field instead of computed - will be auto-calculated
|
|
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
|
+
)
|
|
69
|
+
|
|
70
|
+
# Fields for cycle calculation
|
|
71
|
+
validity_time_length: int = Field(
|
|
72
|
+
..., # Required field, no default
|
|
73
|
+
description="Length of subscription validity period (e.g., 1, 3, 12)"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
validity_time_unit: str = Field(
|
|
77
|
+
..., # Required field, no default
|
|
78
|
+
description="Unit of subscription validity ('minute', 'hour', 'day', 'week', 'month', 'year')"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Renewal and status fields
|
|
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
|
+
)
|
|
86
|
+
|
|
87
|
+
status: SubscriptionStatus = Field(
|
|
88
|
+
..., # Required field, no default
|
|
89
|
+
description="Subscription Status (active, trial, pending_confirmation, etc.)"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# IAM permissions structure - simplified flattened list
|
|
93
|
+
granted_iam_permissions: List[UserPermission] = Field(
|
|
94
|
+
default_factory=list,
|
|
95
|
+
description="IAM permissions granted by this subscription"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
fallback_plan_id: Optional[str] = Field(
|
|
99
|
+
default=None, # Optional field with None default
|
|
100
|
+
description="ID of the plan to fall back to if this subscription expires"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
price_paid_usd: float = Field(
|
|
104
|
+
..., # Required field, no default
|
|
105
|
+
description="Amount paid for this subscription in USD"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
payment_ref: Optional[str] = Field(
|
|
109
|
+
default=None,
|
|
110
|
+
description="Reference to payment transaction"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Credit management fields
|
|
114
|
+
subscription_based_insight_credits_per_update: int = Field(
|
|
115
|
+
default=0,
|
|
116
|
+
description="Number of insight credits to add on each update"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
subscription_based_insight_credits_update_freq_h: int = Field(
|
|
120
|
+
default=24,
|
|
121
|
+
description="Frequency of insight credits update in hours"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
extra_insight_credits_per_cycle: int = Field(
|
|
125
|
+
default=0,
|
|
126
|
+
description="Additional insight credits granted per subscription cycle"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
voting_credits_per_update: int = Field(
|
|
130
|
+
default=0,
|
|
131
|
+
description="Number of voting credits to add on each update"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
voting_credits_update_freq_h: int = Field(
|
|
135
|
+
default=62,
|
|
136
|
+
description="Frequency of voting credits update in hours"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# General metadata for extensibility
|
|
140
|
+
metadata: Dict[str, Any] = Field(
|
|
141
|
+
default_factory=dict,
|
|
142
|
+
description="Additional metadata for the subscription"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
@model_validator(mode='before')
|
|
146
|
+
@classmethod
|
|
147
|
+
def ensure_id_exists(cls, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
148
|
+
"""
|
|
149
|
+
Ensures the id field exists by generating it using UUID if needed.
|
|
150
|
+
"""
|
|
151
|
+
if not isinstance(data, dict):
|
|
152
|
+
return data
|
|
153
|
+
|
|
154
|
+
# If id is already provided and non-empty, leave it alone
|
|
155
|
+
if data.get('id'):
|
|
156
|
+
return data
|
|
157
|
+
|
|
158
|
+
# Generate a UUID-based id if not provided
|
|
159
|
+
data['id'] = str(uuid.uuid4())
|
|
160
|
+
return data
|
|
161
|
+
|
|
162
|
+
@model_validator(mode='before')
|
|
163
|
+
@classmethod
|
|
164
|
+
def auto_calculate_cycle_end_date(cls, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
165
|
+
"""
|
|
166
|
+
Auto-calculate cycle_end_datetime if not provided, based on cycle_start_date,
|
|
167
|
+
validity_time_length, and validity_time_unit.
|
|
168
|
+
"""
|
|
169
|
+
if not isinstance(data, dict):
|
|
170
|
+
return data
|
|
171
|
+
|
|
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
|
+
|
|
179
|
+
cycle_start_date = data.get('cycle_start_date')
|
|
180
|
+
validity_time_length = data.get('validity_time_length')
|
|
181
|
+
validity_time_unit = data.get('validity_time_unit')
|
|
182
|
+
|
|
183
|
+
if cycle_start_date and validity_time_length and validity_time_unit:
|
|
184
|
+
data['cycle_end_datetime'] = cls.calculate_cycle_end_date(
|
|
185
|
+
cycle_start_date, validity_time_length, validity_time_unit
|
|
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
|
+
)
|
|
193
|
+
|
|
194
|
+
return data
|
|
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
|
+
|
|
222
|
+
# Helper method to calculate cycle end date
|
|
223
|
+
@classmethod
|
|
224
|
+
def calculate_cycle_end_date(cls, start_date: datetime, validity_length: int, validity_unit: str) -> datetime:
|
|
225
|
+
"""Calculate the end date based on start date and validity period."""
|
|
226
|
+
if validity_unit == "minute":
|
|
227
|
+
return start_date + relativedelta(minutes=validity_length)
|
|
228
|
+
elif validity_unit == "hour":
|
|
229
|
+
return start_date + relativedelta(hours=validity_length)
|
|
230
|
+
elif validity_unit == "day":
|
|
231
|
+
return start_date + relativedelta(days=validity_length)
|
|
232
|
+
elif validity_unit == "week":
|
|
233
|
+
return start_date + relativedelta(weeks=validity_length)
|
|
234
|
+
elif validity_unit == "year":
|
|
235
|
+
return start_date + relativedelta(years=validity_length)
|
|
236
|
+
else: # Default to months
|
|
237
|
+
return start_date + relativedelta(months=validity_length)
|
|
238
|
+
|
|
239
|
+
# Methods for subscription management
|
|
240
|
+
def is_active(self) -> bool:
|
|
241
|
+
"""Check if the subscription is currently active."""
|
|
242
|
+
now = datetime.now(timezone.utc)
|
|
243
|
+
return (
|
|
244
|
+
self.status == SubscriptionStatus.ACTIVE and
|
|
245
|
+
self.cycle_start_date <= now <= self.cycle_end_datetime_safe
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def is_expired(self) -> bool:
|
|
249
|
+
"""Check if the subscription has expired."""
|
|
250
|
+
now = datetime.now(timezone.utc)
|
|
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
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Time remaining in the specified unit as float
|
|
263
|
+
"""
|
|
264
|
+
now = datetime.now(timezone.utc)
|
|
265
|
+
|
|
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
|
|
320
|
+
|
|
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
|
|
348
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from typing import Optional, Dict, Any, List
|
|
2
2
|
from datetime import datetime
|
|
3
|
-
from pydantic import BaseModel, Field, EmailStr, ConfigDict, field_validator
|
|
3
|
+
from pydantic import BaseModel, Field, EmailStr, ConfigDict, field_validator, model_validator
|
|
4
4
|
|
|
5
5
|
class UserAuth(BaseModel):
|
|
6
6
|
"""Comprehensive authentication model for user credentials and auth operations"""
|
|
@@ -8,17 +8,18 @@ class UserAuth(BaseModel):
|
|
|
8
8
|
|
|
9
9
|
# Core authentication fields
|
|
10
10
|
email: EmailStr = Field(..., description="User's email address")
|
|
11
|
+
display_name: Optional[str] = Field(None, description="User's display name")
|
|
11
12
|
password: Optional[str] = Field(None, min_length=6, description="User's password (for creation/update only)")
|
|
12
13
|
|
|
13
14
|
# Firebase Auth specific fields
|
|
14
|
-
firebase_uid: Optional[str] = Field(None, description="Firebase Auth UID")
|
|
15
|
+
firebase_uid: Optional[str] = Field(default=None, description="Firebase Auth UID")
|
|
15
16
|
provider_id: str = Field(default="password", description="Authentication provider ID")
|
|
16
17
|
email_verified: bool = Field(default=False, description="Whether email is verified")
|
|
17
18
|
disabled: bool = Field(default=False, description="Whether user account is disabled")
|
|
18
19
|
|
|
19
20
|
# Multi-factor authentication
|
|
20
21
|
mfa_enabled: bool = Field(default=False, description="Whether MFA is enabled")
|
|
21
|
-
phone_number: Optional[str] = Field(None, description="Phone number for SMS MFA")
|
|
22
|
+
phone_number: Optional[str] = Field(default=None, description="Phone number for SMS MFA")
|
|
22
23
|
|
|
23
24
|
# Custom claims and metadata
|
|
24
25
|
custom_claims: Dict[str, Any] = Field(default_factory=dict, description="Firebase custom claims")
|
|
@@ -28,14 +29,14 @@ class UserAuth(BaseModel):
|
|
|
28
29
|
provider_data: List[Dict[str, Any]] = Field(default_factory=list, description="Provider-specific data")
|
|
29
30
|
|
|
30
31
|
# Account management
|
|
31
|
-
created_at: Optional[datetime] = Field(None, description="Account creation timestamp")
|
|
32
|
-
last_sign_in: Optional[datetime] = Field(None, description="Last sign-in timestamp")
|
|
33
|
-
last_refresh: Optional[datetime] = Field(None, description="Last token refresh timestamp")
|
|
32
|
+
created_at: Optional[datetime] = Field(default=None, description="Account creation timestamp")
|
|
33
|
+
last_sign_in: Optional[datetime] = Field(default=None, description="Last sign-in timestamp")
|
|
34
|
+
last_refresh: Optional[datetime] = Field(default=None, description="Last token refresh timestamp")
|
|
34
35
|
|
|
35
36
|
# Password management
|
|
36
|
-
password_hash: Optional[str] = Field(None, description="Password hash (internal use only)")
|
|
37
|
-
password_salt: Optional[str] = Field(None, description="Password salt (internal use only)")
|
|
38
|
-
valid_since: Optional[datetime] = Field(None, description="Timestamp since when tokens are valid")
|
|
37
|
+
password_hash: Optional[str] = Field(default=None, description="Password hash (internal use only)")
|
|
38
|
+
password_salt: Optional[str] = Field(default=None, description="Password salt (internal use only)")
|
|
39
|
+
valid_since: Optional[datetime] = Field(default=None, description="Timestamp since when tokens are valid")
|
|
39
40
|
|
|
40
41
|
@field_validator('phone_number')
|
|
41
42
|
@classmethod
|
|
@@ -61,4 +62,12 @@ class UserAuth(BaseModel):
|
|
|
61
62
|
if claim in reserved_claims:
|
|
62
63
|
raise ValueError(f'Custom claim "{claim}" is reserved by Firebase')
|
|
63
64
|
|
|
64
|
-
return v
|
|
65
|
+
return v
|
|
66
|
+
|
|
67
|
+
@model_validator(mode='before')
|
|
68
|
+
@classmethod
|
|
69
|
+
def check_password_fields(cls, values: Dict[str, Any]) -> Dict[str, Any]:
|
|
70
|
+
"""Ensure that password and password_hash are not set simultaneously."""
|
|
71
|
+
if values.get('password') and values.get('password_hash'):
|
|
72
|
+
raise ValueError('Cannot set both password and password_hash')
|
|
73
|
+
return values
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
""" User Profile model for storing personal information and settings. """
|
|
2
|
-
from datetime import date, datetime
|
|
3
|
-
from typing import Set, Optional, ClassVar, Dict, Any, List
|
|
4
|
-
from pydantic import EmailStr, Field, ConfigDict, field_validator, model_validator, computed_field
|
|
5
|
-
from ipulse_shared_base_ftredge import Layer, Module, list_as_lower_strings, Subject
|
|
6
|
-
from .base_data_model import BaseDataModel
|
|
2
|
+
from datetime import date, datetime
|
|
7
3
|
import re # Add re import
|
|
8
|
-
|
|
4
|
+
from typing import Set, Optional, ClassVar, Dict, Any, List
|
|
5
|
+
from pydantic import EmailStr, Field, ConfigDict, model_validator, field_validator
|
|
6
|
+
from ipulse_shared_base_ftredge import Layer, Module, list_enums_as_lower_strings, Subject, IAMUserType
|
|
7
|
+
from ..base_data_model import BaseDataModel
|
|
9
8
|
# ORIGINAL AUTHOR ="Russlan Ramdowar;russlan@ftredge.com"
|
|
10
9
|
# CLASS_ORGIN_DATE=datetime(2024, 2, 12, 20, 5)
|
|
11
10
|
|
|
@@ -18,7 +17,7 @@ class UserProfile(BaseDataModel):
|
|
|
18
17
|
|
|
19
18
|
# Class constants
|
|
20
19
|
VERSION: ClassVar[float] = 5.0 # Incremented version for primary_usertype addition
|
|
21
|
-
DOMAIN: ClassVar[str] = "_".join(
|
|
20
|
+
DOMAIN: ClassVar[str] = "_".join(list_enums_as_lower_strings(Layer.PULSE_APP, Module.CORE, Subject.USER))
|
|
22
21
|
OBJ_REF: ClassVar[str] = "userprofile"
|
|
23
22
|
|
|
24
23
|
schema_version: float = Field(
|
|
@@ -27,26 +26,28 @@ class UserProfile(BaseDataModel):
|
|
|
27
26
|
description="Version of this Class == version of DB Schema"
|
|
28
27
|
)
|
|
29
28
|
|
|
30
|
-
id: str = Field(
|
|
31
|
-
default=
|
|
29
|
+
id: Optional[str] = Field(
|
|
30
|
+
default=None, # Will be auto-generated from user_uid if not provided
|
|
32
31
|
description=f"User Profile ID, format: {OBJ_REF}_user_uid"
|
|
33
32
|
)
|
|
34
33
|
|
|
35
34
|
user_uid: str = Field(
|
|
36
35
|
...,
|
|
37
|
-
|
|
36
|
+
min_length=1,
|
|
37
|
+
description="User UID from Firebase Auth",
|
|
38
|
+
frozen=True
|
|
38
39
|
)
|
|
39
40
|
|
|
40
41
|
# Added primary_usertype field for main role categorization
|
|
41
|
-
primary_usertype:
|
|
42
|
+
primary_usertype: IAMUserType = Field(
|
|
42
43
|
...,
|
|
43
|
-
description="Primary user type
|
|
44
|
+
description="Primary user type from IAMUserType enum"
|
|
44
45
|
)
|
|
45
46
|
|
|
46
47
|
# Renamed usertypes to secondary_usertypes
|
|
47
|
-
secondary_usertypes: List[
|
|
48
|
+
secondary_usertypes: List[IAMUserType] = Field(
|
|
48
49
|
default_factory=list,
|
|
49
|
-
description="List of secondary user types"
|
|
50
|
+
description="List of secondary user types from IAMUserType enum"
|
|
50
51
|
)
|
|
51
52
|
|
|
52
53
|
# Rest of the fields remain the same
|
|
@@ -105,24 +106,38 @@ class UserProfile(BaseDataModel):
|
|
|
105
106
|
|
|
106
107
|
# Remove audit fields as they're inherited from BaseDataModel
|
|
107
108
|
|
|
109
|
+
@field_validator('user_uid')
|
|
110
|
+
@classmethod
|
|
111
|
+
def validate_user_uid(cls, v: str) -> str:
|
|
112
|
+
"""Validate that user_uid is not empty string."""
|
|
113
|
+
if not v or not v.strip():
|
|
114
|
+
raise ValueError("user_uid cannot be empty or whitespace-only")
|
|
115
|
+
return v.strip()
|
|
116
|
+
|
|
108
117
|
@model_validator(mode='before')
|
|
109
118
|
@classmethod
|
|
110
119
|
def ensure_id_exists(cls, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
111
120
|
"""
|
|
112
|
-
Ensures the id field exists
|
|
121
|
+
Ensures the id field exists and matches expected format, or generates it from user_uid.
|
|
113
122
|
This runs BEFORE validation, guaranteeing id will be present for validators.
|
|
114
123
|
"""
|
|
115
124
|
if not isinstance(data, dict):
|
|
116
125
|
return data
|
|
117
126
|
|
|
118
|
-
|
|
119
|
-
if
|
|
120
|
-
return data
|
|
127
|
+
user_uid = data.get('user_uid')
|
|
128
|
+
if not user_uid:
|
|
129
|
+
return data # Let field validation handle missing user_uid
|
|
121
130
|
|
|
122
|
-
|
|
123
|
-
if 'user_uid' in data and data['user_uid']:
|
|
124
|
-
data['id'] = f"{cls.OBJ_REF}_{data['user_uid']}"
|
|
131
|
+
expected_id = f"{cls.OBJ_REF}_{user_uid}"
|
|
125
132
|
|
|
133
|
+
# If id is already provided, validate it matches expected format
|
|
134
|
+
if data.get('id'):
|
|
135
|
+
if data['id'] != expected_id:
|
|
136
|
+
raise ValueError(f"Invalid id format. Expected '{expected_id}', got '{data['id']}'")
|
|
137
|
+
return data
|
|
138
|
+
|
|
139
|
+
# If id is not provided, generate it from user_uid
|
|
140
|
+
data['id'] = expected_id
|
|
126
141
|
return data
|
|
127
142
|
|
|
128
143
|
@model_validator(mode='before')
|
|
@@ -156,4 +171,21 @@ class UserProfile(BaseDataModel):
|
|
|
156
171
|
# Fallback if no email or username provided
|
|
157
172
|
data['username'] = "user"
|
|
158
173
|
|
|
174
|
+
return data
|
|
175
|
+
|
|
176
|
+
@model_validator(mode='before')
|
|
177
|
+
@classmethod
|
|
178
|
+
def convert_datetime_to_date(cls, data: Any) -> Any:
|
|
179
|
+
"""
|
|
180
|
+
Convert datetime objects to date objects for date fields.
|
|
181
|
+
This handles the case where Firestore returns datetime objects
|
|
182
|
+
but the model expects date objects (e.g., dob field).
|
|
183
|
+
"""
|
|
184
|
+
if not isinstance(data, dict):
|
|
185
|
+
return data
|
|
186
|
+
|
|
187
|
+
# Handle dob field specifically
|
|
188
|
+
if 'dob' in data and isinstance(data['dob'], datetime):
|
|
189
|
+
data['dob'] = data['dob'].date()
|
|
190
|
+
|
|
159
191
|
return data
|