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.

Files changed (48) hide show
  1. ipulse_shared_core_ftredge/cache/shared_cache.py +1 -2
  2. ipulse_shared_core_ftredge/dependencies/auth_firebase_token_validation.py +60 -23
  3. ipulse_shared_core_ftredge/dependencies/authz_for_apis.py +128 -157
  4. ipulse_shared_core_ftredge/exceptions/base_exceptions.py +35 -4
  5. ipulse_shared_core_ftredge/models/__init__.py +3 -7
  6. ipulse_shared_core_ftredge/models/base_data_model.py +17 -19
  7. ipulse_shared_core_ftredge/models/catalog/__init__.py +10 -0
  8. ipulse_shared_core_ftredge/models/catalog/subscriptionplan.py +274 -0
  9. ipulse_shared_core_ftredge/models/catalog/usertype.py +177 -0
  10. ipulse_shared_core_ftredge/models/user/__init__.py +5 -0
  11. ipulse_shared_core_ftredge/models/user/user_permissions.py +66 -0
  12. ipulse_shared_core_ftredge/models/user/user_subscription.py +348 -0
  13. ipulse_shared_core_ftredge/models/{user_auth.py → user/userauth.py} +19 -10
  14. ipulse_shared_core_ftredge/models/{user_profile.py → user/userprofile.py} +53 -21
  15. ipulse_shared_core_ftredge/models/user/userstatus.py +479 -0
  16. ipulse_shared_core_ftredge/monitoring/__init__.py +0 -2
  17. ipulse_shared_core_ftredge/monitoring/tracemon.py +6 -6
  18. ipulse_shared_core_ftredge/services/__init__.py +11 -13
  19. ipulse_shared_core_ftredge/services/base/__init__.py +3 -1
  20. ipulse_shared_core_ftredge/services/base/base_firestore_service.py +77 -16
  21. ipulse_shared_core_ftredge/services/{cache_aware_firestore_service.py → base/cache_aware_firestore_service.py} +46 -32
  22. ipulse_shared_core_ftredge/services/catalog/__init__.py +14 -0
  23. ipulse_shared_core_ftredge/services/catalog/catalog_subscriptionplan_service.py +277 -0
  24. ipulse_shared_core_ftredge/services/catalog/catalog_usertype_service.py +376 -0
  25. ipulse_shared_core_ftredge/services/charging_processors.py +25 -25
  26. ipulse_shared_core_ftredge/services/user/__init__.py +5 -25
  27. ipulse_shared_core_ftredge/services/user/user_core_service.py +536 -510
  28. ipulse_shared_core_ftredge/services/user/user_multistep_operations.py +796 -0
  29. ipulse_shared_core_ftredge/services/user/user_permissions_operations.py +392 -0
  30. ipulse_shared_core_ftredge/services/user/user_subscription_operations.py +488 -0
  31. ipulse_shared_core_ftredge/services/user/userauth_operations.py +928 -0
  32. ipulse_shared_core_ftredge/services/user/userprofile_operations.py +166 -0
  33. ipulse_shared_core_ftredge/services/user/userstatus_operations.py +476 -0
  34. ipulse_shared_core_ftredge/services/{charging_service.py → user_charging_service.py} +9 -9
  35. {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/METADATA +3 -4
  36. ipulse_shared_core_ftredge-23.1.1.dist-info/RECORD +50 -0
  37. ipulse_shared_core_ftredge/models/subscription.py +0 -190
  38. ipulse_shared_core_ftredge/models/user_status.py +0 -495
  39. ipulse_shared_core_ftredge/monitoring/microservmon.py +0 -526
  40. ipulse_shared_core_ftredge/services/user/iam_management_operations.py +0 -326
  41. ipulse_shared_core_ftredge/services/user/subscription_management_operations.py +0 -384
  42. ipulse_shared_core_ftredge/services/user/user_account_operations.py +0 -479
  43. ipulse_shared_core_ftredge/services/user/user_auth_operations.py +0 -305
  44. ipulse_shared_core_ftredge/services/user/user_holistic_operations.py +0 -436
  45. ipulse_shared_core_ftredge-20.0.1.dist-info/RECORD +0 -42
  46. {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/WHEEL +0 -0
  47. {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/licenses/LICENCE +0 -0
  48. {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, timezone
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(list_as_lower_strings(Layer.PULSE_APP, Module.CORE.name, Subject.USER.name))
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="", # Will be auto-generated from user_uid if not provided
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
- description="User UID from Firebase Auth"
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: str = Field(
42
+ primary_usertype: IAMUserType = Field(
42
43
  ...,
43
- description="Primary user type (e.g., customer, internal, admin, superadmin)"
44
+ description="Primary user type from IAMUserType enum"
44
45
  )
45
46
 
46
47
  # Renamed usertypes to secondary_usertypes
47
- secondary_usertypes: List[str] = Field(
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 by generating it from user_uid if needed.
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
- # If id is already in the data, leave it alone
119
- if 'id' in data and data['id']:
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
- # If user_uid exists but id doesn't, generate id from user_uid
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