ipulse-shared-core-ftredge 19.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.

Files changed (47) hide show
  1. ipulse_shared_core_ftredge/cache/shared_cache.py +1 -2
  2. ipulse_shared_core_ftredge/dependencies/authz_for_apis.py +4 -4
  3. ipulse_shared_core_ftredge/exceptions/base_exceptions.py +23 -0
  4. ipulse_shared_core_ftredge/models/__init__.py +3 -7
  5. ipulse_shared_core_ftredge/models/base_data_model.py +17 -19
  6. ipulse_shared_core_ftredge/models/catalog/__init__.py +10 -0
  7. ipulse_shared_core_ftredge/models/catalog/subscriptionplan.py +273 -0
  8. ipulse_shared_core_ftredge/models/catalog/usertype.py +170 -0
  9. ipulse_shared_core_ftredge/models/user/__init__.py +5 -0
  10. ipulse_shared_core_ftredge/models/user/user_permissions.py +66 -0
  11. ipulse_shared_core_ftredge/models/{subscription.py → user/user_subscription.py} +66 -20
  12. ipulse_shared_core_ftredge/models/{user_auth.py → user/userauth.py} +19 -10
  13. ipulse_shared_core_ftredge/models/{user_profile.py → user/userprofile.py} +53 -21
  14. ipulse_shared_core_ftredge/models/user/userstatus.py +430 -0
  15. ipulse_shared_core_ftredge/monitoring/__init__.py +2 -2
  16. ipulse_shared_core_ftredge/monitoring/tracemon.py +320 -0
  17. ipulse_shared_core_ftredge/services/__init__.py +11 -13
  18. ipulse_shared_core_ftredge/services/base/__init__.py +3 -1
  19. ipulse_shared_core_ftredge/services/base/base_firestore_service.py +73 -14
  20. ipulse_shared_core_ftredge/services/{cache_aware_firestore_service.py → base/cache_aware_firestore_service.py} +46 -32
  21. ipulse_shared_core_ftredge/services/catalog/__init__.py +14 -0
  22. ipulse_shared_core_ftredge/services/catalog/catalog_subscriptionplan_service.py +273 -0
  23. ipulse_shared_core_ftredge/services/catalog/catalog_usertype_service.py +307 -0
  24. ipulse_shared_core_ftredge/services/charging_processors.py +25 -25
  25. ipulse_shared_core_ftredge/services/user/__init__.py +5 -25
  26. ipulse_shared_core_ftredge/services/user/firebase_auth_admin_helpers.py +160 -0
  27. ipulse_shared_core_ftredge/services/user/user_core_service.py +423 -515
  28. ipulse_shared_core_ftredge/services/user/user_multistep_operations.py +726 -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 +484 -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 +212 -0
  34. ipulse_shared_core_ftredge/services/{charging_service.py → user_charging_service.py} +9 -9
  35. {ipulse_shared_core_ftredge-19.0.1.dist-info → ipulse_shared_core_ftredge-22.1.1.dist-info}/METADATA +3 -4
  36. ipulse_shared_core_ftredge-22.1.1.dist-info/RECORD +51 -0
  37. ipulse_shared_core_ftredge/models/user_status.py +0 -495
  38. ipulse_shared_core_ftredge/monitoring/microservmon.py +0 -483
  39. ipulse_shared_core_ftredge/services/user/iam_management_operations.py +0 -326
  40. ipulse_shared_core_ftredge/services/user/subscription_management_operations.py +0 -384
  41. ipulse_shared_core_ftredge/services/user/user_account_operations.py +0 -479
  42. ipulse_shared_core_ftredge/services/user/user_auth_operations.py +0 -305
  43. ipulse_shared_core_ftredge/services/user/user_holistic_operations.py +0 -436
  44. ipulse_shared_core_ftredge-19.0.1.dist-info/RECORD +0 -41
  45. {ipulse_shared_core_ftredge-19.0.1.dist-info → ipulse_shared_core_ftredge-22.1.1.dist-info}/WHEEL +0 -0
  46. {ipulse_shared_core_ftredge-19.0.1.dist-info → ipulse_shared_core_ftredge-22.1.1.dist-info}/licenses/LICENCE +0 -0
  47. {ipulse_shared_core_ftredge-19.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