ipulse-shared-core-ftredge 20.0.1__py3-none-any.whl → 22.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/authz_for_apis.py +4 -4
- ipulse_shared_core_ftredge/exceptions/base_exceptions.py +23 -0
- 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 +273 -0
- ipulse_shared_core_ftredge/models/catalog/usertype.py +170 -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/{subscription.py → user/user_subscription.py} +66 -20
- 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 +430 -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 +73 -14
- 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 +273 -0
- ipulse_shared_core_ftredge/services/catalog/catalog_usertype_service.py +307 -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/firebase_auth_admin_helpers.py +160 -0
- ipulse_shared_core_ftredge/services/user/user_core_service.py +423 -515
- ipulse_shared_core_ftredge/services/user/user_multistep_operations.py +726 -0
- ipulse_shared_core_ftredge/services/user/user_permissions_operations.py +392 -0
- ipulse_shared_core_ftredge/services/user/user_subscription_operations.py +484 -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 +212 -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-22.1.1.dist-info}/METADATA +3 -4
- ipulse_shared_core_ftredge-22.1.1.dist-info/RECORD +51 -0
- 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-22.1.1.dist-info}/WHEEL +0 -0
- {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-22.1.1.dist-info}/licenses/LICENCE +0 -0
- {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-22.1.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
"""
|
|
2
|
+
User Multistep Operations - Complete user lifecycle operations
|
|
3
|
+
|
|
4
|
+
Handles complete user creation and deletion operations that span across
|
|
5
|
+
Firebase Auth, UserProfile, and UserStatus in coordinated transactions.
|
|
6
|
+
"""
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Dict, Any, Optional, List, Tuple, cast
|
|
10
|
+
from ipulse_shared_base_ftredge.enums import ApprovalStatus
|
|
11
|
+
from ...models import UserProfile, UserStatus, UserAuth, UserType
|
|
12
|
+
from .userauth_operations import UserauthOperations
|
|
13
|
+
from .userprofile_operations import UserprofileOperations
|
|
14
|
+
from .userstatus_operations import UserstatusOperations
|
|
15
|
+
from .user_subscription_operations import UsersubscriptionOperations
|
|
16
|
+
from .user_permissions_operations import UserpermissionsOperations
|
|
17
|
+
from ..catalog.catalog_usertype_service import CatalogUserTypeService
|
|
18
|
+
from ..catalog.catalog_subscriptionplan_service import CatalogSubscriptionPlanService
|
|
19
|
+
|
|
20
|
+
from ...exceptions import (
|
|
21
|
+
UserCreationError
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UsermultistepOperations:
|
|
26
|
+
"""
|
|
27
|
+
Handles complete user lifecycle operations including coordinated creation and deletion
|
|
28
|
+
of Firebase Auth users, UserProfile, and UserStatus documents.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
userprofile_ops: UserprofileOperations,
|
|
34
|
+
userstatus_ops: UserstatusOperations,
|
|
35
|
+
userauth_ops: UserauthOperations,
|
|
36
|
+
usersubscription_ops: UsersubscriptionOperations,
|
|
37
|
+
useriam_ops: UserpermissionsOperations,
|
|
38
|
+
catalog_usertype_service: CatalogUserTypeService,
|
|
39
|
+
catalog_subscriptionplan_service: CatalogSubscriptionPlanService,
|
|
40
|
+
logger: Optional[logging.Logger] = None
|
|
41
|
+
):
|
|
42
|
+
self.userprofile_ops = userprofile_ops
|
|
43
|
+
self.userstatus_ops = userstatus_ops
|
|
44
|
+
self.userauth_ops = userauth_ops
|
|
45
|
+
self.usersubscription_ops = usersubscription_ops
|
|
46
|
+
self.useriam_ops = useriam_ops
|
|
47
|
+
self.catalog_usertype_service = catalog_usertype_service
|
|
48
|
+
self.catalog_subscriptionplan_service = catalog_subscriptionplan_service
|
|
49
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _rollback_user_creation(
|
|
57
|
+
self,
|
|
58
|
+
user_uid: Optional[str],
|
|
59
|
+
profile_created: bool,
|
|
60
|
+
status_created: bool,
|
|
61
|
+
error_context: str
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Rollback user creation on failure."""
|
|
64
|
+
if not user_uid:
|
|
65
|
+
self.logger.error("Rollback cannot proceed: user_uid is None. Context: %s", error_context)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
self.logger.warning("Rolling back user creation for UID: %s. Context: %s", user_uid, error_context)
|
|
69
|
+
|
|
70
|
+
# Attempt to clean up Firestore documents if they were created
|
|
71
|
+
if profile_created:
|
|
72
|
+
try:
|
|
73
|
+
await self.userprofile_ops.delete_userprofile(user_uid, "rollback", archive=False)
|
|
74
|
+
self.logger.info("Successfully deleted orphaned UserProfile for: %s", user_uid)
|
|
75
|
+
except Exception as del_prof_e:
|
|
76
|
+
self.logger.error("Failed to delete orphaned UserProfile for %s: %s", user_uid, del_prof_e)
|
|
77
|
+
if status_created:
|
|
78
|
+
try:
|
|
79
|
+
await self.userstatus_ops.delete_userstatus(user_uid, "rollback", archive=False)
|
|
80
|
+
self.logger.info("Successfully deleted orphaned UserStatus for: %s", user_uid)
|
|
81
|
+
except Exception as del_stat_e:
|
|
82
|
+
self.logger.error("Failed to delete orphaned UserStatus for %s: %s", user_uid, del_stat_e)
|
|
83
|
+
|
|
84
|
+
# Attempt to delete the orphaned Firebase Auth user
|
|
85
|
+
try:
|
|
86
|
+
await self.userauth_ops.delete_userauth(user_uid)
|
|
87
|
+
self.logger.info("Successfully deleted orphaned Firebase Auth user: %s", user_uid)
|
|
88
|
+
except Exception as delete_e:
|
|
89
|
+
self.logger.error("Failed to delete orphaned Firebase Auth user %s: %s", user_uid, delete_e, exc_info=True)
|
|
90
|
+
|
|
91
|
+
def _validate_usertype_consistency(
|
|
92
|
+
self,
|
|
93
|
+
userprofile: UserProfile,
|
|
94
|
+
custom_claims: Optional[Dict[str, Any]] = None
|
|
95
|
+
) -> None:
|
|
96
|
+
"""
|
|
97
|
+
Validate usertype consistency between UserProfile and custom claims.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
userprofile: UserProfile model to validate
|
|
101
|
+
custom_claims: Custom claims to validate against
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
UserCreationError: If usertypes are inconsistent
|
|
105
|
+
"""
|
|
106
|
+
if not custom_claims:
|
|
107
|
+
return # No claims to validate against
|
|
108
|
+
|
|
109
|
+
userauth_primary_usertype = custom_claims.get("primary_usertype")
|
|
110
|
+
userauth_secondary_usertypes = custom_claims.get("secondary_usertypes", [])
|
|
111
|
+
|
|
112
|
+
# Convert to strings for comparison
|
|
113
|
+
userprofile_primary_str = str(userprofile.primary_usertype)
|
|
114
|
+
userprofile_secondary_strs = [str(ut) for ut in userprofile.secondary_usertypes]
|
|
115
|
+
|
|
116
|
+
# Validate primary usertype consistency
|
|
117
|
+
if userauth_primary_usertype and userauth_primary_usertype != userprofile_primary_str:
|
|
118
|
+
raise UserCreationError(
|
|
119
|
+
f"Primary usertype mismatch between UserProfile ({userprofile_primary_str}) "
|
|
120
|
+
f"and custom claims ({userauth_primary_usertype})"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Validate secondary usertypes consistency
|
|
124
|
+
if userauth_secondary_usertypes and set(userauth_secondary_usertypes) != set(userprofile_secondary_strs):
|
|
125
|
+
raise UserCreationError(
|
|
126
|
+
f"Secondary usertypes mismatch between UserProfile ({userprofile_secondary_strs}) "
|
|
127
|
+
f"and custom claims ({userauth_secondary_usertypes})"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Complete User Creation Methods - New Strategic API
|
|
131
|
+
|
|
132
|
+
async def create_user_from_models(
|
|
133
|
+
self,
|
|
134
|
+
userprofile: UserProfile,
|
|
135
|
+
userstatus: UserStatus,
|
|
136
|
+
userauth: Optional[UserAuth] = None,
|
|
137
|
+
custom_claims: Optional[Dict[str, Any]] = None
|
|
138
|
+
) -> Tuple[str, UserProfile, UserStatus]:
|
|
139
|
+
"""
|
|
140
|
+
Create a complete user from ready UserAuth, UserProfile, and UserStatus models.
|
|
141
|
+
|
|
142
|
+
This method efficiently commits pre-configured models to database.
|
|
143
|
+
|
|
144
|
+
For new user creation (when userauth is provided):
|
|
145
|
+
- Creates Firebase Auth user first to get the actual UID
|
|
146
|
+
- Creates new UserProfile and UserStatus models with the Firebase UID
|
|
147
|
+
- Original models serve as templates
|
|
148
|
+
|
|
149
|
+
For existing user (when userauth is None):
|
|
150
|
+
- Models should already have all subscription and permission configuration applied
|
|
151
|
+
- Uses the user_uid from the models to work with existing Firebase Auth user
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
userprofile: Complete UserProfile model (template for new user, or ready for existing user)
|
|
155
|
+
userstatus: Complete UserStatus model (template for new user, or ready for existing user)
|
|
156
|
+
userauth: Optional UserAuth model. If provided, creates new Firebase Auth user
|
|
157
|
+
custom_claims: Optional custom claims to set (will be merged with userauth.custom_claims)
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Tuple of (user_uid, userprofile, userstatus)
|
|
161
|
+
"""
|
|
162
|
+
profile_created = False
|
|
163
|
+
status_created = False
|
|
164
|
+
firebase_user_uid = None
|
|
165
|
+
|
|
166
|
+
# Validate that UserProfile and UserStatus have matching user_uid
|
|
167
|
+
if userprofile.user_uid != userstatus.user_uid:
|
|
168
|
+
raise UserCreationError(f"UserProfile and UserStatus user_uid mismatch: {userprofile.user_uid} != {userstatus.user_uid}")
|
|
169
|
+
|
|
170
|
+
# Validate usertype consistency between UserProfile and UserAuth if userauth is provided
|
|
171
|
+
try:
|
|
172
|
+
# Step 1: Handle Firebase Auth user creation or validation
|
|
173
|
+
if userauth:
|
|
174
|
+
# Creating new user - Firebase will generate UID
|
|
175
|
+
# Merge custom claims into UserAuth model
|
|
176
|
+
if custom_claims:
|
|
177
|
+
userauth.custom_claims.update(custom_claims)
|
|
178
|
+
|
|
179
|
+
# Validate usertype consistency
|
|
180
|
+
self._validate_usertype_consistency(userprofile, userauth.custom_claims)
|
|
181
|
+
|
|
182
|
+
# Create Firebase Auth user with all configuration
|
|
183
|
+
self.logger.info("Creating Firebase Auth user with custom claims for email: %s", userauth.email)
|
|
184
|
+
firebase_user_uid = await self.userauth_ops.create_userauth(userauth)
|
|
185
|
+
|
|
186
|
+
# Create new models with the Firebase UID, using original models as templates
|
|
187
|
+
userprofile_data = userprofile.model_dump()
|
|
188
|
+
userprofile_data['user_uid'] = firebase_user_uid
|
|
189
|
+
# Remove id so it gets auto-generated from user_uid
|
|
190
|
+
userprofile_data.pop('id', None)
|
|
191
|
+
final_userprofile = UserProfile(**userprofile_data)
|
|
192
|
+
|
|
193
|
+
userstatus_data = userstatus.model_dump()
|
|
194
|
+
userstatus_data['user_uid'] = firebase_user_uid
|
|
195
|
+
# Remove id so it gets auto-generated from user_uid
|
|
196
|
+
userstatus_data.pop('id', None)
|
|
197
|
+
final_userstatus = UserStatus(**userstatus_data)
|
|
198
|
+
|
|
199
|
+
user_uid = firebase_user_uid
|
|
200
|
+
|
|
201
|
+
else:
|
|
202
|
+
# Working with existing user - use models as-is
|
|
203
|
+
user_uid = userprofile.user_uid
|
|
204
|
+
final_userprofile = userprofile
|
|
205
|
+
final_userstatus = userstatus
|
|
206
|
+
|
|
207
|
+
# Validate existing user and apply custom claims if provided
|
|
208
|
+
if not await self.userauth_ops.userauth_exists(user_uid):
|
|
209
|
+
raise UserCreationError(f"Firebase Auth user {user_uid} does not exist")
|
|
210
|
+
|
|
211
|
+
if custom_claims:
|
|
212
|
+
# Check if custom_claims contain usertype information
|
|
213
|
+
claims_have_usertype_info = (
|
|
214
|
+
"primary_usertype" in custom_claims or
|
|
215
|
+
"secondary_usertypes" in custom_claims
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if claims_have_usertype_info:
|
|
219
|
+
# Validate usertype consistency with provided custom claims
|
|
220
|
+
self._validate_usertype_consistency(userprofile, custom_claims)
|
|
221
|
+
else:
|
|
222
|
+
# Custom claims don't have usertype info, validate against existing auth claims
|
|
223
|
+
existing_userauth = await self.userauth_ops.get_userauth(user_uid, get_model=True)
|
|
224
|
+
if existing_userauth and existing_userauth.custom_claims:
|
|
225
|
+
self._validate_usertype_consistency(userprofile, existing_userauth.custom_claims)
|
|
226
|
+
|
|
227
|
+
await self.userauth_ops.set_userauth_custom_claims(user_uid, custom_claims)
|
|
228
|
+
else:
|
|
229
|
+
# No custom claims provided - validate against existing userauth custom claims
|
|
230
|
+
existing_userauth = await self.userauth_ops.get_userauth(user_uid, get_model=True)
|
|
231
|
+
if existing_userauth and existing_userauth.custom_claims:
|
|
232
|
+
self._validate_usertype_consistency(userprofile, existing_userauth.custom_claims)
|
|
233
|
+
|
|
234
|
+
# Step 2: Create UserProfile and UserStatus in database (2 operations only)
|
|
235
|
+
self.logger.info("Creating UserProfile for user: %s", user_uid)
|
|
236
|
+
await self.userprofile_ops.create_userprofile(final_userprofile)
|
|
237
|
+
profile_created = True
|
|
238
|
+
|
|
239
|
+
self.logger.info("Creating UserStatus for user: %s (with %d IAM permissions)",
|
|
240
|
+
user_uid, len(final_userstatus.iam_permissions))
|
|
241
|
+
await self.userstatus_ops.create_userstatus(final_userstatus)
|
|
242
|
+
status_created = True
|
|
243
|
+
|
|
244
|
+
# Step 3: Fetch final state to return
|
|
245
|
+
final_profile = await self.userprofile_ops.get_userprofile(user_uid)
|
|
246
|
+
final_status = await self.userstatus_ops.get_userstatus(user_uid)
|
|
247
|
+
|
|
248
|
+
if not final_profile or not final_status:
|
|
249
|
+
raise UserCreationError("Failed to retrieve user documents after creation.")
|
|
250
|
+
|
|
251
|
+
self.logger.info("Successfully created user from ready models: %s", user_uid)
|
|
252
|
+
return user_uid, final_profile, final_status
|
|
253
|
+
|
|
254
|
+
except Exception as e:
|
|
255
|
+
error_context = f"User creation from models failed: {e}"
|
|
256
|
+
# Use firebase_user_uid if available, otherwise fall back to the original user_uid
|
|
257
|
+
cleanup_uid = firebase_user_uid or userprofile.user_uid
|
|
258
|
+
await self._rollback_user_creation(cleanup_uid, profile_created, status_created, error_context)
|
|
259
|
+
raise UserCreationError(f"Failed to create user from models: {str(e)}") from e
|
|
260
|
+
|
|
261
|
+
async def create_user_from_manual_usertype(
|
|
262
|
+
self,
|
|
263
|
+
userprofile: UserProfile,
|
|
264
|
+
usertype: UserType,
|
|
265
|
+
userauth: Optional[UserAuth] = None,
|
|
266
|
+
custom_claims: Optional[Dict[str, Any]] = None,
|
|
267
|
+
user_approval_status: ApprovalStatus = ApprovalStatus.PENDING,
|
|
268
|
+
user_notes: str = "Created with manual usertype configuration",
|
|
269
|
+
extra_insight_credits_override: Optional[int] = None,
|
|
270
|
+
voting_credits_override: Optional[int] = None,
|
|
271
|
+
subscriptionplan_id_override: Optional[str] = None,
|
|
272
|
+
creator_uid: Optional[str] = None
|
|
273
|
+
) -> Tuple[str, UserProfile, UserStatus]:
|
|
274
|
+
"""
|
|
275
|
+
Create a complete user with manual UserType configuration.
|
|
276
|
+
|
|
277
|
+
This method builds UserStatus from usertype defaults and applies subscription/permissions
|
|
278
|
+
in memory before committing to database. Organizations are always taken from usertype.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
userprofile: Complete UserProfile model (mandatory)
|
|
282
|
+
usertype: Manual UserType configuration (mandatory)
|
|
283
|
+
userauth: Optional UserAuth model. If not provided, assumes user exists
|
|
284
|
+
custom_claims: Optional custom claims to set
|
|
285
|
+
user_approval_status: User approval status (for custom claims)
|
|
286
|
+
user_notes: User notes to set in custom claims
|
|
287
|
+
extra_insight_credits: Override extra credits from usertype
|
|
288
|
+
voting_credits: Override voting credits from usertype
|
|
289
|
+
subscription_plan_id: Override subscription plan from usertype default
|
|
290
|
+
creator_uid: Who is creating this user
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Tuple of (user_uid, userprofile, userstatus)
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
# Always use organizations from usertype
|
|
297
|
+
final_organizations = set(usertype.default_organizations)
|
|
298
|
+
final_extra_credits = extra_insight_credits_override if extra_insight_credits_override is not None else usertype.default_extra_insight_credits
|
|
299
|
+
final_voting_credits = voting_credits_override if voting_credits_override is not None else usertype.default_voting_credits
|
|
300
|
+
|
|
301
|
+
# Build initial UserStatus from usertype defaults
|
|
302
|
+
userstatus = UserStatus(
|
|
303
|
+
user_uid=userprofile.user_uid,
|
|
304
|
+
organizations_uids=final_organizations,
|
|
305
|
+
iam_permissions=usertype.granted_iam_permissions or [],
|
|
306
|
+
extra_insight_credits=final_extra_credits,
|
|
307
|
+
voting_credits=final_voting_credits,
|
|
308
|
+
metadata={},
|
|
309
|
+
created_by=creator_uid or f"system_manual_usertype_{userprofile.user_uid}",
|
|
310
|
+
updated_by=creator_uid or f"system_manual_usertype_{userprofile.user_uid}"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# Apply subscription to UserStatus in memory if plan specified
|
|
314
|
+
plan_to_apply = subscriptionplan_id_override or usertype.default_subscription_plan_if_unpaid
|
|
315
|
+
if plan_to_apply:
|
|
316
|
+
# For now, we'll apply subscription after UserStatus is created
|
|
317
|
+
# Future enhancement: Apply subscription directly to UserStatus in memory
|
|
318
|
+
self.logger.info("Subscription plan %s will be applied after user creation", plan_to_apply)
|
|
319
|
+
|
|
320
|
+
# Generate custom claims if not provided
|
|
321
|
+
if not custom_claims:
|
|
322
|
+
custom_claims = {
|
|
323
|
+
"primary_usertype": str(userprofile.primary_usertype),
|
|
324
|
+
"secondary_usertypes": [str(ut) for ut in userprofile.secondary_usertypes],
|
|
325
|
+
"organizations_uids": list(final_organizations),
|
|
326
|
+
"user_approval_status": str(user_approval_status),
|
|
327
|
+
"user_notes": user_notes
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
# Create user from ready models
|
|
331
|
+
return await self.create_user_from_models(
|
|
332
|
+
userprofile=userprofile,
|
|
333
|
+
userstatus=userstatus,
|
|
334
|
+
userauth=userauth,
|
|
335
|
+
custom_claims=custom_claims
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
except Exception as e:
|
|
339
|
+
self.logger.error("Failed to create user from manual usertype: %s", e)
|
|
340
|
+
raise UserCreationError(f"Failed to create user from manual usertype: {str(e)}") from e
|
|
341
|
+
|
|
342
|
+
async def create_user_from_catalog_usertype(
|
|
343
|
+
self,
|
|
344
|
+
usertype_id: str,
|
|
345
|
+
userprofile: UserProfile,
|
|
346
|
+
userauth: Optional[UserAuth] = None,
|
|
347
|
+
custom_claims: Optional[Dict[str, Any]] = None,
|
|
348
|
+
user_approval_status: ApprovalStatus = ApprovalStatus.PENDING,
|
|
349
|
+
user_notes: str = "Created from catalog usertype configuration",
|
|
350
|
+
extra_insight_credits_override: Optional[int] = None,
|
|
351
|
+
voting_credits_override: Optional[int] = None,
|
|
352
|
+
subscriptionplan_id_override: Optional[str] = None,
|
|
353
|
+
creator_uid: Optional[str] = None
|
|
354
|
+
) -> Tuple[str, UserProfile, UserStatus]:
|
|
355
|
+
"""
|
|
356
|
+
Create a complete user based on UserType catalog configuration.
|
|
357
|
+
|
|
358
|
+
This method fetches UserType from catalog and creates a user with
|
|
359
|
+
appropriate defaults, allowing selective overrides. Organizations are always taken from usertype.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
usertype_id: ID of the UserType configuration to fetch from catalog (mandatory)
|
|
363
|
+
userprofile: Complete UserProfile model (mandatory)
|
|
364
|
+
userauth: Optional UserAuth model. If not provided, assumes user exists
|
|
365
|
+
custom_claims: Optional custom claims to set
|
|
366
|
+
user_approval_status: User approval status (for custom claims)
|
|
367
|
+
user_notes: User notes to set in custom claims
|
|
368
|
+
extra_insight_credits: Override extra credits from usertype
|
|
369
|
+
voting_credits: Override voting credits from usertype
|
|
370
|
+
subscriptionplan_id: Override subscription plan from usertype default
|
|
371
|
+
creator_uid: Who is creating this user
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Tuple of (user_uid, userprofile, userstatus)
|
|
375
|
+
"""
|
|
376
|
+
try:
|
|
377
|
+
# Step 1: Fetch UserType configuration from catalog
|
|
378
|
+
self.logger.info("Fetching usertype configuration for: %s", usertype_id)
|
|
379
|
+
usertype_config = await self.catalog_usertype_service.get_usertype(usertype_id)
|
|
380
|
+
if not usertype_config:
|
|
381
|
+
raise UserCreationError(f"UserType {usertype_id} not found in catalog")
|
|
382
|
+
|
|
383
|
+
# Step 2: Create user using manual usertype method
|
|
384
|
+
return await self.create_user_from_manual_usertype(
|
|
385
|
+
userprofile=userprofile,
|
|
386
|
+
usertype=usertype_config,
|
|
387
|
+
userauth=userauth,
|
|
388
|
+
custom_claims=custom_claims,
|
|
389
|
+
user_approval_status=user_approval_status,
|
|
390
|
+
user_notes=user_notes,
|
|
391
|
+
extra_insight_credits_override=extra_insight_credits_override,
|
|
392
|
+
voting_credits_override=voting_credits_override,
|
|
393
|
+
subscriptionplan_id_override=subscriptionplan_id_override,
|
|
394
|
+
creator_uid=creator_uid
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
except Exception as e:
|
|
398
|
+
self.logger.error("Failed to create user from catalog usertype %s: %s", usertype_id, e)
|
|
399
|
+
raise UserCreationError(f"Failed to create user from catalog usertype {usertype_id}: {str(e)}") from e
|
|
400
|
+
|
|
401
|
+
# Complete User Deletion
|
|
402
|
+
|
|
403
|
+
async def delete_user(
|
|
404
|
+
self,
|
|
405
|
+
user_uid: str,
|
|
406
|
+
delete_auth_user: bool = True,
|
|
407
|
+
delete_profile: bool = True,
|
|
408
|
+
delete_status: bool = True,
|
|
409
|
+
updater_uid: str = "system_deletion",
|
|
410
|
+
archive: bool = True
|
|
411
|
+
) -> Dict[str, Any]:
|
|
412
|
+
"""
|
|
413
|
+
Delete a user holistically, including their auth, profile, and status.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
user_uid: The UID of the user to delete.
|
|
417
|
+
delete_auth_user: Whether to delete the Firebase Auth user.
|
|
418
|
+
delete_profile: Whether to delete the UserProfile document.
|
|
419
|
+
delete_status: Whether to delete the UserStatus document.
|
|
420
|
+
updater_uid: The identifier of the entity performing the deletion.
|
|
421
|
+
archive: Whether to archive documents before deletion. Defaults to True.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
A dictionary with the results of the deletion operations.
|
|
425
|
+
"""
|
|
426
|
+
results = {
|
|
427
|
+
"auth_deleted_successfully": not delete_auth_user,
|
|
428
|
+
"profile_deleted_successfully": not delete_profile,
|
|
429
|
+
"status_deleted_successfully": not delete_status,
|
|
430
|
+
"errors": []
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
# Delete UserProfile
|
|
434
|
+
if delete_profile:
|
|
435
|
+
try:
|
|
436
|
+
results["profile_deleted_successfully"] = await self.userprofile_ops.delete_userprofile(
|
|
437
|
+
user_uid, updater_uid, archive=archive
|
|
438
|
+
)
|
|
439
|
+
except Exception as e:
|
|
440
|
+
error_msg = f"Failed to delete user profile for {user_uid}: {e}"
|
|
441
|
+
self.logger.error(error_msg, exc_info=True)
|
|
442
|
+
results["errors"].append(error_msg)
|
|
443
|
+
|
|
444
|
+
# Delete UserStatus
|
|
445
|
+
if delete_status:
|
|
446
|
+
try:
|
|
447
|
+
results["status_deleted_successfully"] = await self.userstatus_ops.delete_userstatus(
|
|
448
|
+
user_uid, updater_uid, archive=archive
|
|
449
|
+
)
|
|
450
|
+
except Exception as e:
|
|
451
|
+
error_msg = f"Failed to delete user status for {user_uid}: {e}"
|
|
452
|
+
self.logger.error(error_msg, exc_info=True)
|
|
453
|
+
results["errors"].append(error_msg)
|
|
454
|
+
|
|
455
|
+
# Delete Firebase Auth user
|
|
456
|
+
if delete_auth_user:
|
|
457
|
+
try:
|
|
458
|
+
# Assuming delete_userauth also accepts an archive flag for consistency
|
|
459
|
+
results["auth_deleted_successfully"] = await self.userauth_ops.delete_userauth(user_uid, archive=archive)
|
|
460
|
+
except Exception as e:
|
|
461
|
+
error_msg = f"Failed to delete Firebase Auth user {user_uid}: {e}"
|
|
462
|
+
self.logger.error(error_msg, exc_info=True)
|
|
463
|
+
results["errors"].append(error_msg)
|
|
464
|
+
|
|
465
|
+
return results
|
|
466
|
+
|
|
467
|
+
async def batch_delete_users(
|
|
468
|
+
self,
|
|
469
|
+
user_uids: List[str],
|
|
470
|
+
delete_auth_user: bool,
|
|
471
|
+
delete_profile: bool = True,
|
|
472
|
+
delete_status: bool = True,
|
|
473
|
+
updater_uid: str = "system_batch_deletion",
|
|
474
|
+
archive: bool = True
|
|
475
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
476
|
+
"""
|
|
477
|
+
Batch delete multiple users holistically.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
user_uids: A list of user UIDs to delete.
|
|
481
|
+
delete_auth_user: Whether to delete the Firebase Auth users.
|
|
482
|
+
delete_profile: Whether to delete the UserProfile documents.
|
|
483
|
+
delete_status: Whether to delete the UserStatus documents.
|
|
484
|
+
updater_uid: The identifier of the entity performing the deletion.
|
|
485
|
+
archive: Overrides the default archival behavior for all users in the batch.
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
A dictionary where keys are user UIDs and values are deletion result dictionaries.
|
|
489
|
+
"""
|
|
490
|
+
batch_results = {}
|
|
491
|
+
for user_uid in user_uids:
|
|
492
|
+
batch_results[user_uid] = await self.delete_user(
|
|
493
|
+
user_uid=user_uid,
|
|
494
|
+
delete_auth_user=delete_auth_user,
|
|
495
|
+
delete_profile=delete_profile,
|
|
496
|
+
delete_status=delete_status,
|
|
497
|
+
updater_uid=updater_uid,
|
|
498
|
+
archive=archive
|
|
499
|
+
)
|
|
500
|
+
return batch_results
|
|
501
|
+
|
|
502
|
+
# Document-level batch operations
|
|
503
|
+
|
|
504
|
+
async def batch_delete_user_core_docs(
|
|
505
|
+
self,
|
|
506
|
+
user_uids: List[str],
|
|
507
|
+
updater_uid: str = "system_batch_deletion"
|
|
508
|
+
) -> Dict[str, Tuple[bool, bool, Optional[str]]]:
|
|
509
|
+
"""Batch delete multiple users' documents (profile and status only)"""
|
|
510
|
+
batch_results: Dict[str, Tuple[bool, bool, Optional[str]]] = {}
|
|
511
|
+
|
|
512
|
+
# Process sequentially to avoid overwhelming the database
|
|
513
|
+
for user_uid in user_uids:
|
|
514
|
+
self.logger.info("Batch deletion: Processing user_uid: %s", user_uid)
|
|
515
|
+
item_deleted_by = f"{updater_uid}_batch_item_{user_uid}"
|
|
516
|
+
|
|
517
|
+
try:
|
|
518
|
+
# Use delete_user but only for documents, not auth
|
|
519
|
+
result = await self.delete_user(
|
|
520
|
+
user_uid=user_uid,
|
|
521
|
+
delete_auth_user=False, # Only delete documents
|
|
522
|
+
delete_profile=True,
|
|
523
|
+
delete_status=True,
|
|
524
|
+
updater_uid=item_deleted_by
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
batch_results[user_uid] = (
|
|
528
|
+
result["profile_deleted_successfully"],
|
|
529
|
+
result["status_deleted_successfully"],
|
|
530
|
+
result["errors"][0] if result["errors"] else None
|
|
531
|
+
)
|
|
532
|
+
except Exception as e:
|
|
533
|
+
self.logger.error(f"Batch deletion failed for user {user_uid}: {e}", exc_info=True)
|
|
534
|
+
batch_results[user_uid] = (False, False, str(e))
|
|
535
|
+
|
|
536
|
+
return batch_results
|
|
537
|
+
|
|
538
|
+
# Utility Methods
|
|
539
|
+
|
|
540
|
+
async def user_exists_fully(self, user_uid: str) -> Dict[str, bool]:
|
|
541
|
+
"""Check if complete user exists (Auth, Profile, Status)"""
|
|
542
|
+
return {
|
|
543
|
+
"auth_exists": await self.userauth_ops.userauth_exists(user_uid),
|
|
544
|
+
"profile_exists": (await self.userprofile_ops.get_userprofile(user_uid)) is not None,
|
|
545
|
+
"status_exists": (await self.userstatus_ops.get_userstatus(user_uid)) is not None
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async def validate_user_fully_enabled(
|
|
549
|
+
self,
|
|
550
|
+
user_uid: str,
|
|
551
|
+
email_verified_must: bool = True,
|
|
552
|
+
approved_must: bool = True,
|
|
553
|
+
active_subscription_must: bool = True,
|
|
554
|
+
valid_permissions_must: bool = True
|
|
555
|
+
) -> Dict[str, Any]:
|
|
556
|
+
"""
|
|
557
|
+
Validate complete user integrity and operational readiness
|
|
558
|
+
|
|
559
|
+
This method performs comprehensive validation to ensure a user is:
|
|
560
|
+
- Complete (auth, profile, status exist)
|
|
561
|
+
- Consistent (matching UIDs and usertypes across components)
|
|
562
|
+
- Enabled (auth enabled, approved status)
|
|
563
|
+
- Operational (active subscription, valid permissions)
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
user_uid: The UID of the user to validate
|
|
567
|
+
email_verified_must: If True, email must be verified for full enablement (default: True)
|
|
568
|
+
approved_must: If True, approval status must be APPROVED for full enablement (default: True)
|
|
569
|
+
active_subscription_must: If True, active subscription required for full enablement (default: True)
|
|
570
|
+
valid_permissions_must: If True, valid permissions required for full enablement (default: True)
|
|
571
|
+
|
|
572
|
+
Returns:
|
|
573
|
+
Dict with validation results including status, errors, and detailed checks
|
|
574
|
+
"""
|
|
575
|
+
validation_results = {
|
|
576
|
+
"user_uid": user_uid,
|
|
577
|
+
"exists": {"auth_exists": False, "profile_exists": False, "status_exists": False},
|
|
578
|
+
"is_complete": False,
|
|
579
|
+
"missing_components": [],
|
|
580
|
+
"validation_errors": [],
|
|
581
|
+
"is_fully_enabled": False,
|
|
582
|
+
"detailed_checks": {
|
|
583
|
+
"auth_enabled": False,
|
|
584
|
+
"email_verified": False,
|
|
585
|
+
"approval_status_approved": False,
|
|
586
|
+
"uid_consistency": False,
|
|
587
|
+
"usertype_consistency": False,
|
|
588
|
+
"has_active_subscription": False,
|
|
589
|
+
"has_valid_permissions": False
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
try:
|
|
594
|
+
# Get all user components in parallel for efficiency
|
|
595
|
+
userauth_result, userprofile_result, userstatus_result = await asyncio.gather(
|
|
596
|
+
self.userauth_ops.get_userauth(user_uid, get_model=True),
|
|
597
|
+
self.userprofile_ops.get_userprofile(user_uid),
|
|
598
|
+
self.userstatus_ops.get_userstatus(user_uid),
|
|
599
|
+
return_exceptions=True
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
# Handle exceptions and determine existence
|
|
603
|
+
validation_results["exists"]["auth_exists"] = not isinstance(userauth_result, Exception) and userauth_result is not None
|
|
604
|
+
validation_results["exists"]["profile_exists"] = not isinstance(userprofile_result, Exception) and userprofile_result is not None
|
|
605
|
+
validation_results["exists"]["status_exists"] = not isinstance(userstatus_result, Exception) and userstatus_result is not None
|
|
606
|
+
|
|
607
|
+
validation_results["is_complete"] = all(validation_results["exists"].values())
|
|
608
|
+
validation_results["missing_components"] = [k for k, v in validation_results["exists"].items() if not v]
|
|
609
|
+
|
|
610
|
+
# If user is not complete, skip detailed validations
|
|
611
|
+
if not validation_results["is_complete"]:
|
|
612
|
+
validation_results["validation_errors"].append("User is incomplete - missing components")
|
|
613
|
+
return validation_results
|
|
614
|
+
|
|
615
|
+
# If we have exceptions instead of models, handle them
|
|
616
|
+
if isinstance(userauth_result, Exception):
|
|
617
|
+
validation_results["validation_errors"].append(f"Auth retrieval error: {str(userauth_result)}")
|
|
618
|
+
return validation_results
|
|
619
|
+
if isinstance(userprofile_result, Exception):
|
|
620
|
+
validation_results["validation_errors"].append(f"Profile retrieval error: {str(userprofile_result)}")
|
|
621
|
+
return validation_results
|
|
622
|
+
if isinstance(userstatus_result, Exception):
|
|
623
|
+
validation_results["validation_errors"].append(f"Status retrieval error: {str(userstatus_result)}")
|
|
624
|
+
return validation_results
|
|
625
|
+
|
|
626
|
+
# Additional null checks - should not happen if exists checks passed, but for safety
|
|
627
|
+
if not userauth_result or not userprofile_result or not userstatus_result:
|
|
628
|
+
validation_results["validation_errors"].append("Retrieved user components are null despite existence checks passing")
|
|
629
|
+
return validation_results
|
|
630
|
+
|
|
631
|
+
# Type narrow the results to the actual model types after validation
|
|
632
|
+
userauth_record = cast(UserAuth, userauth_result) # Now known to be UserAuth
|
|
633
|
+
userprofile = cast(UserProfile, userprofile_result) # Now known to be UserProfile
|
|
634
|
+
userstatus = cast(UserStatus, userstatus_result) # Now known to be UserStatus
|
|
635
|
+
|
|
636
|
+
# Now perform detailed validations with valid models
|
|
637
|
+
|
|
638
|
+
# 1. Auth enabled validation (uses the UserAuth model disabled field)
|
|
639
|
+
validation_results["detailed_checks"]["auth_enabled"] = not userauth_record.disabled
|
|
640
|
+
if userauth_record.disabled:
|
|
641
|
+
validation_results["validation_errors"].append("Firebase Auth user is disabled")
|
|
642
|
+
|
|
643
|
+
# 2. Email verification validation
|
|
644
|
+
validation_results["detailed_checks"]["email_verified"] = userauth_record.email_verified
|
|
645
|
+
if email_verified_must and not userauth_record.email_verified:
|
|
646
|
+
validation_results["validation_errors"].append("User email is not verified")
|
|
647
|
+
|
|
648
|
+
# 3. UID consistency validation
|
|
649
|
+
auth_uid = getattr(userauth_record, 'uid', None) or getattr(userauth_record, 'firebase_uid', None)
|
|
650
|
+
uids_consistent = (
|
|
651
|
+
auth_uid == user_uid and
|
|
652
|
+
userprofile.user_uid == user_uid and
|
|
653
|
+
userstatus.user_uid == user_uid
|
|
654
|
+
)
|
|
655
|
+
validation_results["detailed_checks"]["uid_consistency"] = uids_consistent
|
|
656
|
+
if not uids_consistent:
|
|
657
|
+
validation_results["validation_errors"].append(
|
|
658
|
+
f"UID inconsistency detected - Auth: {auth_uid}, "
|
|
659
|
+
f"Profile: {userprofile.user_uid}, Status: {userstatus.user_uid}"
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
# 4. Usertype consistency validation
|
|
663
|
+
userauth_claims = userauth_record.custom_claims or {}
|
|
664
|
+
userauth_primary = userauth_claims.get("primary_usertype")
|
|
665
|
+
userauth_secondary = userauth_claims.get("secondary_usertypes", [])
|
|
666
|
+
|
|
667
|
+
userprofile_primary_str = str(userprofile.primary_usertype)
|
|
668
|
+
userprofile_secondary_strs = [str(ut) for ut in userprofile.secondary_usertypes]
|
|
669
|
+
|
|
670
|
+
usertypes_consistent = (
|
|
671
|
+
userauth_primary == userprofile_primary_str and
|
|
672
|
+
set(userauth_secondary) == set(userprofile_secondary_strs)
|
|
673
|
+
)
|
|
674
|
+
validation_results["detailed_checks"]["usertype_consistency"] = usertypes_consistent
|
|
675
|
+
if not usertypes_consistent:
|
|
676
|
+
validation_results["validation_errors"].append(
|
|
677
|
+
f"Usertype inconsistency - Auth primary: {userauth_primary}, "
|
|
678
|
+
f"Profile primary: {userprofile_primary_str}, "
|
|
679
|
+
f"Auth secondary: {userauth_secondary}, "
|
|
680
|
+
f"Profile secondary: {userprofile_secondary_strs}"
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
# 5. Approval status validation
|
|
684
|
+
user_approval_status = userauth_claims.get("user_approval_status")
|
|
685
|
+
approval_approved = user_approval_status == "APPROVED"
|
|
686
|
+
validation_results["detailed_checks"]["approval_status_approved"] = approval_approved
|
|
687
|
+
if approved_must and not approval_approved:
|
|
688
|
+
validation_results["validation_errors"].append(
|
|
689
|
+
f"User approval status is not APPROVED (current: {user_approval_status})"
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
# 6. Active subscription validation - use UserStatus methods
|
|
693
|
+
has_active_subscription = userstatus.is_subscription_active()
|
|
694
|
+
validation_results["detailed_checks"]["has_active_subscription"] = has_active_subscription
|
|
695
|
+
if active_subscription_must and not has_active_subscription:
|
|
696
|
+
validation_results["validation_errors"].append("User has no active subscription")
|
|
697
|
+
|
|
698
|
+
# 7. Valid permissions validation - use UserStatus get_valid_permissions method
|
|
699
|
+
valid_permissions = userstatus.get_valid_permissions()
|
|
700
|
+
has_valid_permissions = len(valid_permissions) > 0
|
|
701
|
+
|
|
702
|
+
validation_results["detailed_checks"]["has_valid_permissions"] = has_valid_permissions
|
|
703
|
+
if valid_permissions_must and not has_valid_permissions:
|
|
704
|
+
validation_results["validation_errors"].append("User has no valid (non-expired) IAM permissions")
|
|
705
|
+
|
|
706
|
+
# Overall validation result - only consider checks that are required based on flags
|
|
707
|
+
required_checks = []
|
|
708
|
+
required_checks.append(validation_results["detailed_checks"]["auth_enabled"]) # Always required
|
|
709
|
+
required_checks.append(validation_results["detailed_checks"]["uid_consistency"]) # Always required
|
|
710
|
+
required_checks.append(validation_results["detailed_checks"]["usertype_consistency"]) # Always required
|
|
711
|
+
|
|
712
|
+
if email_verified_must:
|
|
713
|
+
required_checks.append(validation_results["detailed_checks"]["email_verified"])
|
|
714
|
+
if approved_must:
|
|
715
|
+
required_checks.append(validation_results["detailed_checks"]["approval_status_approved"])
|
|
716
|
+
if active_subscription_must:
|
|
717
|
+
required_checks.append(validation_results["detailed_checks"]["has_active_subscription"])
|
|
718
|
+
if valid_permissions_must:
|
|
719
|
+
required_checks.append(validation_results["detailed_checks"]["has_valid_permissions"])
|
|
720
|
+
|
|
721
|
+
validation_results["is_fully_enabled"] = all(required_checks)
|
|
722
|
+
|
|
723
|
+
except Exception as e:
|
|
724
|
+
validation_results["validation_errors"].append(f"Validation process error: {str(e)}")
|
|
725
|
+
|
|
726
|
+
return validation_results
|