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
@@ -1,7 +1,6 @@
1
1
  from datetime import datetime, timezone
2
2
  from typing import Any
3
3
  from typing import ClassVar
4
- from typing import Optional, Dict
5
4
  from pydantic import BaseModel, Field, ConfigDict, field_validator
6
5
  import dateutil.parser
7
6
 
@@ -21,9 +20,9 @@ class BaseDataModel(BaseModel):
21
20
  frozen=True # Keep schema version frozen for data integrity
22
21
  )
23
22
 
24
- # Audit fields - now mutable for updates
25
- created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
26
- created_by: str = Field(...)
23
+ # Audit fields - created fields are frozen after creation, updated fields are mutable
24
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), frozen=True)
25
+ created_by: str = Field(..., frozen=True)
27
26
  updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
28
27
  updated_by: str = Field(...)
29
28
 
@@ -35,22 +34,21 @@ class BaseDataModel(BaseModel):
35
34
  @field_validator('created_at', 'updated_at', mode='before')
36
35
  @classmethod
37
36
  def parse_datetime(cls, v: Any) -> datetime:
38
- if isinstance(v, datetime): # If Firestore already gave a datetime object
39
- return v # Just use it, no parsing needed
40
- if isinstance(v, str): # If it's a string (e.g. from an API request, not Firestore direct)
37
+ """
38
+ Ensures that datetime fields are properly parsed into datetime objects.
39
+ Handles both datetime objects (from Firestore) and ISO format strings (from APIs).
40
+ """
41
+ if isinstance(v, datetime):
42
+ # If it's already a datetime object (including Firestore's DatetimeWithNanoseconds),
43
+ # return it directly.
44
+ return v
45
+
46
+ if isinstance(v, str):
47
+ # If it's a string, parse it into a datetime object.
41
48
  try:
42
49
  return dateutil.parser.isoparse(v)
43
50
  except (TypeError, ValueError) as e:
44
51
  raise ValueError(f"Invalid datetime string format: {v} - {e}")
45
- # Firestore might send google.api_core.datetime_helpers.DatetimeWithNanoseconds
46
- # which is a subclass of datetime.datetime, so isinstance(v, datetime) should catch it.
47
- # If for some reason it's a different type not caught by isinstance(v, datetime)
48
- # but has isoformat(), perhaps try that, but it's unlikely with current Firestore client.
49
- # For example, if v is some custom timestamp object from an older library:
50
- if hasattr(v, 'isoformat'): # Fallback for unknown datetime-like objects
51
- try:
52
- return dateutil.parser.isoparse(v.isoformat())
53
- except Exception as e:
54
- raise ValueError(f"Could not parse datetime-like object: {v} - {e}")
55
-
56
- raise ValueError(f"Unsupported type for datetime parsing: {type(v)} value: {v}")
52
+
53
+ # If the type is not a datetime or a string, it's an unsupported format.
54
+ raise ValueError(f"Unsupported type for datetime parsing: {type(v)}")
@@ -0,0 +1,10 @@
1
+ """
2
+ Models for default configurations
3
+ """
4
+
5
+ from .subscriptionplan import SubscriptionPlan, ProrationMethod, PlanUpgradePath
6
+ from .usertype import UserType
7
+ __all__ = [
8
+ "UserType",
9
+ "SubscriptionPlan"
10
+ ]
@@ -0,0 +1,274 @@
1
+ """
2
+ Subscription Plan Defaults Model
3
+
4
+ This module defines the configuration templates for subscription plans that are stored in Firestore.
5
+ These templates are used to create actual user subscriptions with consistent settings.
6
+ """
7
+
8
+ from typing import Dict, Any, Optional, ClassVar, List
9
+ from enum import StrEnum
10
+ from datetime import datetime, timezone, timedelta
11
+ from pydantic import Field, ConfigDict, field_validator,model_validator, BaseModel
12
+ from ipulse_shared_base_ftredge import (Layer, Module, list_enums_as_lower_strings,
13
+ Subject, SubscriptionPlanName,ObjectOverallStatus,
14
+ SubscriptionStatus, TimeUnit)
15
+ from ..base_data_model import BaseDataModel
16
+ from ..user.user_permissions import UserPermission
17
+
18
+
19
+ class ProrationMethod(StrEnum):
20
+ """Methods for handling proration when upgrading plans."""
21
+ IMMEDIATE = "immediate"
22
+ PRORATED = "prorated"
23
+ END_OF_CYCLE = "end_of_cycle"
24
+
25
+
26
+ class Proration(BaseModel):
27
+ """Defines the proration behavior for subscription changes."""
28
+ pro_rata_billing: bool = Field(
29
+ default=True,
30
+ description="If true, charge a pro-rated amount for the remaining time in the current billing cycle."
31
+ )
32
+ proration_date: Optional[int] = Field(
33
+ default=None,
34
+ description="The specific date to use for proration calculations, if applicable."
35
+ )
36
+
37
+
38
+ class PlanUpgradePath(BaseModel):
39
+ """Represents an upgrade path from a source plan to the plan where this path is defined."""
40
+
41
+ price_usd: float = Field(
42
+ ...,
43
+ ge=0,
44
+ description="Price for upgrading to this plan in USD"
45
+ )
46
+
47
+ proration_method: ProrationMethod = Field(
48
+ ...,
49
+ description="How to handle proration when upgrading"
50
+ )
51
+
52
+
53
+ ############################################ !!!!! ALWAYS UPDATE SCHEMA VERSION IF SCHEMA IS BEING MODIFIED !!! ############################################
54
+ class SubscriptionPlan(BaseDataModel):
55
+ """
56
+ Configuration template for subscription plans stored in Firestore.
57
+ These templates define the default settings applied when creating user subscriptions.
58
+ """
59
+
60
+ model_config = ConfigDict(extra="forbid")
61
+
62
+ VERSION: ClassVar[float] = 1.0
63
+ DOMAIN: ClassVar[str] = "_".join(list_enums_as_lower_strings(Layer.PULSE_APP, Module.CORE, Subject.CATALOG))
64
+ OBJ_REF: ClassVar[str] = "subscriptionplan"
65
+
66
+ # System-managed fields
67
+ schema_version: float = Field(
68
+ default=VERSION,
69
+ description="Version of this Class == version of DB Schema",
70
+ frozen=True
71
+ )
72
+
73
+ id: Optional[str] = Field(
74
+ default=None,
75
+ description="Unique identifier for this plan template (e.g., 'free_subscription_1'). Auto-generated if not provided.",
76
+ frozen=True
77
+ )
78
+
79
+ plan_name: SubscriptionPlanName = Field(
80
+ ...,
81
+ description="Subscription plan type (FREE, BASE, PREMIUM)",
82
+ frozen=True
83
+ )
84
+
85
+ plan_version: int = Field(
86
+ ...,
87
+ ge=1,
88
+ description="Version of this plan template",
89
+ frozen=True
90
+ )
91
+
92
+ pulse_status: ObjectOverallStatus = Field(
93
+ default=ObjectOverallStatus.ACTIVE,
94
+ description="Overall status of this subscription plan configuration"
95
+ )
96
+
97
+ # Display information
98
+ display_name: str = Field(
99
+ ...,
100
+ min_length=1,
101
+ description="Human-readable plan name",
102
+ frozen=True
103
+ )
104
+
105
+ description: str = Field(
106
+ ...,
107
+ min_length=1,
108
+ description="Description of what this plan includes",
109
+ frozen=True
110
+ )
111
+
112
+ granted_iam_permissions: List[UserPermission] = Field(
113
+ default_factory=list,
114
+ description="List of all IAM permission granted by this plan",
115
+ frozen=True
116
+ )
117
+
118
+ # Credit configuration
119
+ subscription_based_insight_credits_per_update: int = Field(
120
+ ...,
121
+ ge=0,
122
+ description="Number of insight credits added per update cycle",
123
+ frozen=True
124
+ )
125
+
126
+ subscription_based_insight_credits_update_freq_h: int = Field(
127
+ ...,
128
+ gt=0,
129
+ description="How often insight credits are updated (in hours)",
130
+ frozen=True
131
+ )
132
+
133
+ extra_insight_credits_per_cycle: int = Field(
134
+ ...,
135
+ ge=0,
136
+ description="Bonus insight credits granted per subscription cycle",
137
+ frozen=True
138
+ )
139
+
140
+ voting_credits_per_update: int = Field(
141
+ ...,
142
+ ge=0,
143
+ description="Number of voting credits added per update cycle",
144
+ frozen=True
145
+ )
146
+
147
+ voting_credits_update_freq_h: int = Field(
148
+ ...,
149
+ gt=0,
150
+ description="How often voting credits are updated (in hours)",
151
+ frozen=True
152
+ )
153
+
154
+ # Plan cycle configuration
155
+ plan_validity_cycle_length: int = Field(
156
+ ...,
157
+ gt=0,
158
+ description="Length of each subscription cycle (e.g., 1, 3, 12)",
159
+ frozen=True
160
+ )
161
+
162
+ plan_validity_cycle_unit: TimeUnit = Field(
163
+ ...,
164
+ description="Unit for the cycle length (month, year, etc.)",
165
+ frozen=True
166
+ )
167
+
168
+ # Pricing
169
+ plan_per_cycle_price_usd: float = Field(
170
+ ...,
171
+ ge=0,
172
+ description="Price per subscription cycle in USD",
173
+ frozen=True
174
+ )
175
+
176
+ # Features and customization
177
+ plan_extra_features: Dict[str, Any] = Field(
178
+ default_factory=dict,
179
+ description="Additional features enabled by this plan",
180
+ frozen=True
181
+ )
182
+
183
+ # Upgrade paths
184
+ plan_upgrade_paths: Dict[str, PlanUpgradePath] = Field(
185
+ default_factory=dict,
186
+ description="Defines valid upgrade paths TO this plan FROM other plans (source_plan_id -> upgrade_details)",
187
+ frozen=True
188
+ )
189
+
190
+ # Default settings
191
+ plan_default_auto_renewal_end: Optional[datetime] = Field(
192
+ default=None,
193
+ description="Default auto-renewal setting for new subscriptions",
194
+ frozen=True
195
+ )
196
+
197
+ plan_default_status: SubscriptionStatus = Field(
198
+ ...,
199
+ description="Default status for new subscriptions with this plan",
200
+ frozen=True
201
+ )
202
+
203
+ # Fallback configuration
204
+ fallback_plan_id_if_current_plan_expired: Optional[str] = Field(
205
+ None,
206
+ description="Plan to fall back to when this plan expires (None for no fallback)",
207
+ frozen=True
208
+ )
209
+
210
+ @model_validator(mode='before')
211
+ @classmethod
212
+ def set_id_if_not_provided(cls, data: Any) -> Any:
213
+ """Generate an ID from plan_name and plan_version if not provided."""
214
+ if isinstance(data, dict):
215
+ plan_name = data.get('plan_name')
216
+ plan_version = data.get('plan_version')
217
+ provided_id = data.get('id')
218
+
219
+ if plan_name and plan_version is not None:
220
+ plan_name_str = str(plan_name)
221
+ expected_id = f"{plan_name_str}_{plan_version}"
222
+
223
+ if provided_id is None:
224
+ # Auto-generate ID
225
+ data['id'] = expected_id
226
+ else:
227
+ # Validate provided ID matches expected format
228
+ if provided_id != expected_id:
229
+ raise ValueError(
230
+ f"Invalid ID format. Expected '{expected_id}' based on "
231
+ f"plan_name='{plan_name_str}' and plan_version={plan_version}, "
232
+ f"but got '{provided_id}'. ID must follow format: {{plan_name}}_{{plan_version}}"
233
+ )
234
+ return data
235
+
236
+
237
+ @field_validator('granted_iam_permissions')
238
+ @classmethod
239
+ def validate_iam_permissions(cls, v: List[UserPermission]) -> List[UserPermission]:
240
+ """Validate IAM permissions structure."""
241
+ if not isinstance(v, list):
242
+ raise ValueError("granted_iam_permissions must be a list")
243
+
244
+ for i, permission in enumerate(v):
245
+ if not isinstance(permission, UserPermission):
246
+ raise ValueError(f"Permission at index {i} must be a UserPermission instance")
247
+
248
+ return v
249
+
250
+ @field_validator('plan_upgrade_paths')
251
+ @classmethod
252
+ def validate_upgrade_paths(cls, v: Dict[str, PlanUpgradePath]) -> Dict[str, PlanUpgradePath]:
253
+ """Validate upgrade paths."""
254
+ for source_plan_id, upgrade_path in v.items():
255
+ if not isinstance(source_plan_id, str) or not source_plan_id.strip():
256
+ raise ValueError(f"Source plan ID must be a non-empty string, got: {source_plan_id}")
257
+
258
+ if not isinstance(upgrade_path, PlanUpgradePath):
259
+ raise ValueError(f"Upgrade path for '{source_plan_id}' must be a PlanUpgradePath instance")
260
+
261
+ return v
262
+
263
+ def get_cycle_duration_hours(self) -> int:
264
+ """Calculate the total duration of one cycle in hours."""
265
+ unit_to_hours = {
266
+ TimeUnit.MINUTE: 1/60,
267
+ TimeUnit.HOUR: 1,
268
+ TimeUnit.DAY: 24,
269
+ TimeUnit.WEEK: 24 * 7,
270
+ TimeUnit.MONTH: 24 * 30, # Approximate
271
+ TimeUnit.YEAR: 24 * 365, # Approximate
272
+ }
273
+ multiplier = unit_to_hours.get(self.plan_validity_cycle_unit, 24 * 30)
274
+ return int(self.plan_validity_cycle_length * multiplier)
@@ -0,0 +1,177 @@
1
+ """
2
+ User Defaults Model
3
+
4
+ This module defines the configuration templates for user type defaults that are stored in Firestore.
5
+ These templates are used to create user profiles and statuses with consistent default settings
6
+ based on their user type (superadmin, admin, internal, authenticated, anonymous).
7
+ """
8
+
9
+ from typing import Dict, Any, Optional, ClassVar, List
10
+ from datetime import datetime
11
+ from pydantic import Field, ConfigDict, field_validator, model_validator
12
+ from ipulse_shared_base_ftredge import Layer, Module, list_enums_as_lower_strings, Subject, ObjectOverallStatus
13
+ from ipulse_shared_base_ftredge.enums.enums_iam import IAMUserType
14
+ from ipulse_shared_core_ftredge.models.base_data_model import BaseDataModel
15
+ from ipulse_shared_core_ftredge.models.user.user_permissions import UserPermission
16
+
17
+ # ORIGINAL AUTHOR ="russlan.ramdowar;russlan@ftredge.com"
18
+ # CLASS_ORIGIN_DATE="2025-06-27"
19
+
20
+
21
+ ############################################ !!!!! ALWAYS UPDATE SCHEMA VERSION IF SCHEMA IS BEING MODIFIED !!! ############################################
22
+ class UserType(BaseDataModel):
23
+ """
24
+ Configuration template for user type defaults stored in Firestore.
25
+ These templates define the default settings applied when creating users of specific types.
26
+ """
27
+
28
+ model_config = ConfigDict(extra="forbid")
29
+
30
+ VERSION: ClassVar[float] = 1.0
31
+ DOMAIN: ClassVar[str] = "_".join(list_enums_as_lower_strings(Layer.PULSE_APP, Module.CORE.name, Subject.CATALOG.name))
32
+ OBJ_REF: ClassVar[str] = "usertype"
33
+
34
+ # System-managed fields
35
+ schema_version: float = Field(
36
+ default=VERSION,
37
+ description="Version of this Class == version of DB Schema",
38
+ frozen=True
39
+ )
40
+
41
+ id: Optional[str] = Field(
42
+ default=None,
43
+ description="Unique identifier for this user type template (e.g., 'superadmin_1', 'authenticated_1'). Auto-generated if not provided.",
44
+ frozen=True
45
+ )
46
+
47
+ version: int = Field(
48
+ ...,
49
+ ge=1,
50
+ description="Version of this user type template",
51
+ frozen=True
52
+ )
53
+
54
+ pulse_status: ObjectOverallStatus = Field(
55
+ default=ObjectOverallStatus.ACTIVE,
56
+ description="Overall status of this user type configuration"
57
+ )
58
+
59
+ # User type configuration
60
+ primary_usertype: IAMUserType = Field(
61
+ ...,
62
+ description="Primary user type for this configuration template",
63
+ frozen=True
64
+ )
65
+
66
+ secondary_usertypes: List[IAMUserType] = Field(
67
+ default_factory=list,
68
+ description="Secondary user types automatically assigned to users of this primary type",
69
+ frozen=True
70
+ )
71
+
72
+ # Organization defaults
73
+ default_organizations: List[str] = Field(
74
+ default_factory=list,
75
+ description="Default organization UIDs for users of this type",
76
+ frozen=True
77
+ )
78
+
79
+ # IAM permissions structure - simplified flattened list
80
+ granted_iam_permissions: List[UserPermission] = Field(
81
+ default_factory=list,
82
+ description="Default IAM permissions granted to users of this type.",
83
+ frozen=True
84
+ )
85
+
86
+ default_extra_insight_credits: int = Field(
87
+ default=0,
88
+ ge=0,
89
+ description="Default extra insight credits for users of this type",
90
+ frozen=True
91
+ )
92
+
93
+ default_voting_credits: int = Field(
94
+ default=0,
95
+ ge=0,
96
+ description="Default voting credits for users of this type",
97
+ frozen=True
98
+ )
99
+
100
+ # Subscription defaults
101
+ default_subscriptionplan_if_unpaid: Optional[str] = Field(
102
+ default=None,
103
+ description="Default subscription plan ID to assign if user has no active subscription",
104
+ frozen=True
105
+ )
106
+
107
+ default_subscriptionplan_auto_renewal_end: Optional[datetime] = Field(
108
+ default=None,
109
+ description="Default auto-renewal end date to apply when assigning default_subscriptionplan_if_unpaid",
110
+ frozen=True
111
+ )
112
+
113
+ # Additional metadata
114
+ metadata: Dict[str, Any] = Field(
115
+ default_factory=dict,
116
+ description="Additional metadata for this user type configuration",
117
+ frozen=True
118
+ )
119
+
120
+ @model_validator(mode='before')
121
+ @classmethod
122
+ def set_id_if_not_provided(cls, data: Any) -> Any:
123
+ """Generate an ID from primary_usertype and version if not provided."""
124
+ if isinstance(data, dict):
125
+ primary_usertype = data.get('primary_usertype')
126
+ version = data.get('version')
127
+ provided_id = data.get('id')
128
+
129
+ if primary_usertype and version is not None:
130
+ primary_usertype_str = str(primary_usertype)
131
+ expected_id = f"{primary_usertype_str}_{version}"
132
+
133
+ if provided_id is None:
134
+ # Auto-generate ID
135
+ data['id'] = expected_id
136
+ else:
137
+ # Validate provided ID matches expected format
138
+ if provided_id != expected_id:
139
+ raise ValueError(
140
+ f"Invalid ID format. Expected '{expected_id}' based on "
141
+ f"primary_usertype='{primary_usertype_str}' and version={version}, "
142
+ f"but got '{provided_id}'. ID must follow format: {{primary_usertype}}_{{version}}"
143
+ )
144
+ return data
145
+
146
+ @property
147
+ def usertype_id(self) -> str:
148
+ """Get the ID as a non-optional string. ID is always set after validation."""
149
+ if self.id is None:
150
+ raise ValueError("UserType ID is not set - this should not happen after model validation")
151
+ return self.id
152
+
153
+ @field_validator('granted_iam_permissions')
154
+ @classmethod
155
+ def validate_iam_permissions(cls, v: List[UserPermission]) -> List[UserPermission]:
156
+ """Validate IAM permissions structure."""
157
+ if not isinstance(v, list):
158
+ raise ValueError("granted_iam_permissions must be a list")
159
+
160
+ for i, permission in enumerate(v):
161
+ if not isinstance(permission, UserPermission):
162
+ raise ValueError(f"Permission at index {i} must be a UserPermission instance")
163
+
164
+ return v
165
+
166
+ @field_validator('secondary_usertypes')
167
+ @classmethod
168
+ def validate_secondary_usertypes(cls, v: List[IAMUserType]) -> List[IAMUserType]:
169
+ """Validate secondary user types list."""
170
+ # Remove duplicates while preserving order
171
+ seen = set()
172
+ unique_list = []
173
+ for user_type in v:
174
+ if user_type not in seen:
175
+ seen.add(user_type)
176
+ unique_list.append(user_type)
177
+ return unique_list
@@ -0,0 +1,5 @@
1
+ from .userprofile import UserProfile
2
+ from .user_subscription import UserSubscription
3
+ from .user_permissions import UserPermission
4
+ from .userstatus import UserStatus
5
+ from .userauth import UserAuth
@@ -0,0 +1,66 @@
1
+
2
+ """ User IAM model for tracking user permissions and access rights. """
3
+ from datetime import datetime, timezone
4
+ from typing import Dict, Optional, Any
5
+ from pydantic import BaseModel, Field, ConfigDict
6
+ from ipulse_shared_base_ftredge.enums.enums_iam import IAMUnit
7
+
8
+ class UserPermission(BaseModel):
9
+ """
10
+ A single permission assignment with full context.
11
+ Flattened structure for easier management and querying.
12
+ """
13
+ model_config = ConfigDict(frozen=False)
14
+
15
+ domain: str = Field(
16
+ ...,
17
+ description="The domain for this permission (e.g., 'papp', 'papp/oracle')"
18
+ )
19
+ iam_unit_type: IAMUnit = Field(
20
+ ...,
21
+ description="Type of IAM assignment (GROUP, ROLE, etc.)"
22
+ )
23
+ permission_ref: str = Field(
24
+ ...,
25
+ description="The permission reference/name (e.g., 'sysadmin_group', 'analyst')"
26
+ )
27
+ source: str = Field(
28
+ ...,
29
+ description="Source of this assignment (e.g., subscription plan ID, 'manual_grant')"
30
+ )
31
+ expires_at: Optional[datetime] = Field(
32
+ default=None,
33
+ description="When this assignment expires (None for permanent)"
34
+ )
35
+ granted_at: datetime = Field(
36
+ default_factory=lambda: datetime.now(timezone.utc),
37
+ description="When this permission was granted"
38
+ )
39
+ granted_by: Optional[str] = Field(
40
+ default=None,
41
+ description="Who granted this permission (user ID or system identifier)"
42
+ )
43
+ metadata: Dict[str, Any] = Field(
44
+ default_factory=dict,
45
+ description="Additional metadata for this permission assignment"
46
+ )
47
+
48
+ def is_valid(self) -> bool:
49
+ """Check if the permission is currently valid (not expired)."""
50
+ if self.expires_at is None:
51
+ return True
52
+
53
+ # Ensure both datetimes are timezone-aware for comparison
54
+ current_time = datetime.now(timezone.utc)
55
+ expires_time = self.expires_at
56
+
57
+ # If expires_at is timezone-naive, assume it's UTC
58
+ if expires_time.tzinfo is None:
59
+ expires_time = expires_time.replace(tzinfo=timezone.utc)
60
+
61
+ return current_time <= expires_time
62
+
63
+ def __str__(self) -> str:
64
+ """Human-readable representation of the permission."""
65
+ expiry = f" (expires: {self.expires_at})" if self.expires_at else " (permanent)"
66
+ return f"{self.domain}/{self.iam_unit_type}/{self.permission_ref} from {self.source}{expiry}"