ipulse-shared-core-ftredge 20.0.1__py3-none-any.whl → 23.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ipulse-shared-core-ftredge might be problematic. Click here for more details.

Files changed (48) hide show
  1. ipulse_shared_core_ftredge/cache/shared_cache.py +1 -2
  2. ipulse_shared_core_ftredge/dependencies/auth_firebase_token_validation.py +60 -23
  3. ipulse_shared_core_ftredge/dependencies/authz_for_apis.py +128 -157
  4. ipulse_shared_core_ftredge/exceptions/base_exceptions.py +35 -4
  5. ipulse_shared_core_ftredge/models/__init__.py +3 -7
  6. ipulse_shared_core_ftredge/models/base_data_model.py +17 -19
  7. ipulse_shared_core_ftredge/models/catalog/__init__.py +10 -0
  8. ipulse_shared_core_ftredge/models/catalog/subscriptionplan.py +274 -0
  9. ipulse_shared_core_ftredge/models/catalog/usertype.py +177 -0
  10. ipulse_shared_core_ftredge/models/user/__init__.py +5 -0
  11. ipulse_shared_core_ftredge/models/user/user_permissions.py +66 -0
  12. ipulse_shared_core_ftredge/models/user/user_subscription.py +348 -0
  13. ipulse_shared_core_ftredge/models/{user_auth.py → user/userauth.py} +19 -10
  14. ipulse_shared_core_ftredge/models/{user_profile.py → user/userprofile.py} +53 -21
  15. ipulse_shared_core_ftredge/models/user/userstatus.py +479 -0
  16. ipulse_shared_core_ftredge/monitoring/__init__.py +0 -2
  17. ipulse_shared_core_ftredge/monitoring/tracemon.py +6 -6
  18. ipulse_shared_core_ftredge/services/__init__.py +11 -13
  19. ipulse_shared_core_ftredge/services/base/__init__.py +3 -1
  20. ipulse_shared_core_ftredge/services/base/base_firestore_service.py +77 -16
  21. ipulse_shared_core_ftredge/services/{cache_aware_firestore_service.py → base/cache_aware_firestore_service.py} +46 -32
  22. ipulse_shared_core_ftredge/services/catalog/__init__.py +14 -0
  23. ipulse_shared_core_ftredge/services/catalog/catalog_subscriptionplan_service.py +277 -0
  24. ipulse_shared_core_ftredge/services/catalog/catalog_usertype_service.py +376 -0
  25. ipulse_shared_core_ftredge/services/charging_processors.py +25 -25
  26. ipulse_shared_core_ftredge/services/user/__init__.py +5 -25
  27. ipulse_shared_core_ftredge/services/user/user_core_service.py +536 -510
  28. ipulse_shared_core_ftredge/services/user/user_multistep_operations.py +796 -0
  29. ipulse_shared_core_ftredge/services/user/user_permissions_operations.py +392 -0
  30. ipulse_shared_core_ftredge/services/user/user_subscription_operations.py +488 -0
  31. ipulse_shared_core_ftredge/services/user/userauth_operations.py +928 -0
  32. ipulse_shared_core_ftredge/services/user/userprofile_operations.py +166 -0
  33. ipulse_shared_core_ftredge/services/user/userstatus_operations.py +476 -0
  34. ipulse_shared_core_ftredge/services/{charging_service.py → user_charging_service.py} +9 -9
  35. {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/METADATA +3 -4
  36. ipulse_shared_core_ftredge-23.1.1.dist-info/RECORD +50 -0
  37. ipulse_shared_core_ftredge/models/subscription.py +0 -190
  38. ipulse_shared_core_ftredge/models/user_status.py +0 -495
  39. ipulse_shared_core_ftredge/monitoring/microservmon.py +0 -526
  40. ipulse_shared_core_ftredge/services/user/iam_management_operations.py +0 -326
  41. ipulse_shared_core_ftredge/services/user/subscription_management_operations.py +0 -384
  42. ipulse_shared_core_ftredge/services/user/user_account_operations.py +0 -479
  43. ipulse_shared_core_ftredge/services/user/user_auth_operations.py +0 -305
  44. ipulse_shared_core_ftredge/services/user/user_holistic_operations.py +0 -436
  45. ipulse_shared_core_ftredge-20.0.1.dist-info/RECORD +0 -42
  46. {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/WHEEL +0 -0
  47. {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/licenses/LICENCE +0 -0
  48. {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,166 @@
1
+ """
2
+ User Profile Operations - CRUD operations for UserProfile
3
+ """
4
+ import os
5
+ import logging
6
+ from typing import Dict, Any, Optional
7
+ from google.cloud import firestore
8
+ from pydantic import ValidationError as PydanticValidationError
9
+
10
+ from ...models import UserProfile
11
+ from ...exceptions import ResourceNotFoundError, UserProfileError
12
+ from ..base import BaseFirestoreService
13
+
14
+
15
+ class UserprofileOperations:
16
+ """
17
+ Handles CRUD operations for UserProfile documents
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ firestore_client: firestore.Client,
23
+ logger: Optional[logging.Logger] = None,
24
+ timeout: float = 10.0,
25
+ profile_collection: Optional[str] = None,
26
+ ):
27
+ collection_name = profile_collection or UserProfile.get_collection_name()
28
+ self.db_service = BaseFirestoreService[UserProfile](
29
+ db=firestore_client,
30
+ collection_name=collection_name,
31
+ resource_type="UserProfile",
32
+ model_class=UserProfile,
33
+ logger=logger,
34
+ timeout=timeout
35
+ )
36
+ self.logger = logger or logging.getLogger(__name__)
37
+ self.profile_collection_name = collection_name
38
+
39
+ # Archival configuration
40
+ self.archive_profile_on_delete = os.getenv('ARCHIVE_PROFILE_ON_DELETE', 'true').lower() == 'true'
41
+ self.archive_profile_collection_name = os.getenv(
42
+ 'ARCHIVE_PROFILE_COLLECTION_NAME',
43
+ f"~archive_{self.profile_collection_name}"
44
+ )
45
+
46
+ async def get_userprofile(self, user_uid: str) -> Optional[UserProfile]:
47
+ """Fetches a user profile from Firestore."""
48
+ self.logger.info(f"Fetching user profile for UID: {user_uid}")
49
+ try:
50
+ profile_data = await self.db_service.get_document(f"{UserProfile.OBJ_REF}_{user_uid}", convert_to_model=True)
51
+ if profile_data and isinstance(profile_data, UserProfile):
52
+ return profile_data
53
+ return None
54
+ except ResourceNotFoundError:
55
+ self.logger.warning(f"UserProfile not found for UID: {user_uid}")
56
+ return None
57
+ except Exception as e:
58
+ self.logger.error("Error fetching user profile for %s: %s", user_uid, e, exc_info=True)
59
+ raise UserProfileError(
60
+ detail=f"Failed to fetch user profile: {str(e)}",
61
+ user_uid=user_uid,
62
+ operation="get_userprofile",
63
+ original_error=e
64
+ ) from e
65
+
66
+ async def create_userprofile(self, userprofile: UserProfile, creator_uid: Optional[str] = None) -> UserProfile:
67
+ """Creates a new user profile in Firestore."""
68
+ self.logger.info(f"Creating user profile for UID: {userprofile.user_uid}")
69
+ try:
70
+ doc_id = f"{UserProfile.OBJ_REF}_{userprofile.user_uid}"
71
+ effective_creator_uid = creator_uid or userprofile.user_uid
72
+ await self.db_service.create_document(doc_id, userprofile.model_dump(exclude_none=True), creator_uid=effective_creator_uid)
73
+ self.logger.info(f"Successfully created user profile for UID: {userprofile.user_uid}")
74
+ return userprofile
75
+ except Exception as e:
76
+ self.logger.error(f"Error creating user profile for {userprofile.user_uid}: {e}", exc_info=True)
77
+ raise UserProfileError(
78
+ detail=f"Failed to create user profile: {str(e)}",
79
+ user_uid=userprofile.user_uid,
80
+ operation="create_userprofile",
81
+ original_error=e
82
+ )
83
+
84
+ async def update_userprofile(self, user_uid: str, profile_data: Dict[str, Any], updater_uid: str) -> UserProfile:
85
+ """Updates an existing user profile in Firestore."""
86
+ self.logger.info(f"Updating user profile for UID: {user_uid}")
87
+ try:
88
+ doc_id = f"{UserProfile.OBJ_REF}_{user_uid}"
89
+ await self.db_service.update_document(doc_id, profile_data, updater_uid)
90
+ updated_profile = await self.get_userprofile(user_uid)
91
+ if not updated_profile:
92
+ raise ResourceNotFoundError(
93
+ resource_type="UserProfile",
94
+ resource_id=doc_id
95
+ )
96
+ self.logger.info(f"Successfully updated user profile for UID: {user_uid}")
97
+ return updated_profile
98
+ except ResourceNotFoundError as e:
99
+ self.logger.error(f"Cannot update non-existent user profile for {user_uid}")
100
+ raise e
101
+ except Exception as e:
102
+ self.logger.error(f"Error updating user profile for {user_uid}: {e}", exc_info=True)
103
+ raise UserProfileError(
104
+ detail=f"Failed to update user profile: {str(e)}",
105
+ user_uid=user_uid,
106
+ operation="update_userprofile",
107
+ original_error=e
108
+ )
109
+
110
+ async def delete_userprofile(self, user_uid: str, updater_uid: str = "system_deletion", archive: bool = True) -> bool:
111
+ """Delete (archive and delete) user profile"""
112
+ profile_doc_id = f"{UserProfile.OBJ_REF}_{user_uid}"
113
+ should_archive = archive if archive is not None else self.archive_profile_on_delete
114
+
115
+ try:
116
+ # Get profile data for archival
117
+ profile_data = await self.db_service.get_document(profile_doc_id, convert_to_model=False)
118
+
119
+ if profile_data:
120
+ # Ensure we have a dict for archival
121
+ profile_dict = profile_data if isinstance(profile_data, dict) else profile_data.__dict__
122
+
123
+ # Archive if enabled
124
+ if should_archive:
125
+ await self.db_service.archive_document(
126
+ document_data=profile_dict,
127
+ doc_id=profile_doc_id,
128
+ archive_collection=self.archive_profile_collection_name,
129
+ archived_by=updater_uid
130
+ )
131
+
132
+ # Delete the original document
133
+ await self.db_service.delete_document(profile_doc_id)
134
+ self.logger.info(f"Successfully deleted user profile: {profile_doc_id}")
135
+ return True
136
+ else:
137
+ self.logger.warning(f"User profile {profile_doc_id} not found for deletion")
138
+ return True # Consider non-existent as successfully deleted
139
+
140
+ except Exception as e:
141
+ self.logger.error(f"Failed to delete user profile {profile_doc_id}: {e}", exc_info=True)
142
+ raise UserProfileError(
143
+ detail=f"Failed to delete user profile: {str(e)}",
144
+ user_uid=user_uid,
145
+ operation="delete_userprofile",
146
+ original_error=e
147
+ )
148
+
149
+
150
+
151
+ async def userprofile_exists(self, user_uid: str) -> bool:
152
+ """Check if a user profile exists."""
153
+ return await self.db_service.document_exists(f"{UserProfile.OBJ_REF}_{user_uid}")
154
+
155
+ async def validate_userprofile_data(
156
+ self,
157
+ profile_data: Optional[Dict[str, Any]] = None,
158
+ ) -> tuple[bool, list[str]]:
159
+ """Validate user profile data without creating documents"""
160
+ errors = []
161
+ if profile_data:
162
+ try:
163
+ UserProfile(**profile_data)
164
+ except PydanticValidationError as e:
165
+ errors.append(f"Profile validation error: {str(e)}")
166
+ return len(errors) == 0, errors
@@ -0,0 +1,476 @@
1
+ """
2
+ Userstatus Operations - CRUD operations for Userstatus
3
+ """
4
+ import os
5
+ import logging
6
+ from typing import Dict, Any, Optional, TYPE_CHECKING
7
+
8
+ from google.cloud import firestore
9
+ from pydantic import ValidationError as PydanticValidationError
10
+
11
+ from ...models import UserStatus
12
+ from ...exceptions import ResourceNotFoundError, UserStatusError
13
+ from ..base import BaseFirestoreService
14
+
15
+ # Type checking imports to avoid circular dependencies
16
+ if TYPE_CHECKING:
17
+ from .user_subscription_operations import UsersubscriptionOperations
18
+ from .user_permissions_operations import UserpermissionsOperations
19
+
20
+
21
+ class UserstatusOperations:
22
+ """
23
+ Handles CRUD operations for Userstatus documents
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ firestore_client: firestore.Client,
29
+ logger: Optional[logging.Logger] = None,
30
+ timeout: float = 10.0,
31
+ status_collection: Optional[str] = None,
32
+ subscription_ops: Optional["UsersubscriptionOperations"] = None,
33
+ permissions_ops: Optional["UserpermissionsOperations"] = None
34
+ ):
35
+ self.db = firestore_client
36
+ self.logger = logger or logging.getLogger(__name__)
37
+ self.timeout = timeout
38
+
39
+ # Optional dependencies for comprehensive operations
40
+ self.subscription_ops = subscription_ops
41
+ self.permissions_ops = permissions_ops
42
+
43
+ self.status_collection_name = status_collection or UserStatus.get_collection_name()
44
+
45
+ # Archival configuration
46
+ self.archive_userstatus_on_delete = os.getenv('ARCHIVE_USERSTATUS_ON_DELETE', 'true').lower() == 'true'
47
+ self.archive_userstatus_collection_name = os.getenv(
48
+ 'ARCHIVE_USERSTATUS_COLLECTION_NAME',
49
+ "~archive_core_user_userstatuss"
50
+ )
51
+
52
+ # Initialize DB service
53
+ self._status_db_service = BaseFirestoreService[UserStatus](
54
+ db=self.db,
55
+ collection_name=self.status_collection_name,
56
+ resource_type=UserStatus.OBJ_REF,
57
+ model_class=UserStatus,
58
+ logger=self.logger,
59
+ timeout=self.timeout
60
+ )
61
+
62
+ async def get_userstatus(self, user_uid: str, convert_to_model: bool = True) -> Optional[UserStatus]:
63
+ """Retrieve a user status by UID"""
64
+ userstatus_id = f"{UserStatus.OBJ_REF}_{user_uid}"
65
+
66
+ try:
67
+ userstatus = await self._status_db_service.get_document(
68
+ userstatus_id,
69
+ convert_to_model=convert_to_model
70
+ )
71
+ if userstatus:
72
+ self.logger.debug("Successfully retrieved user status for %s", user_uid)
73
+ # Always return a UserStatus model to match the return type
74
+ if isinstance(userstatus, dict):
75
+ return UserStatus(**userstatus)
76
+ return userstatus
77
+ else:
78
+ self.logger.debug("User status not found for %s", user_uid)
79
+ return None
80
+
81
+ except ResourceNotFoundError:
82
+ self.logger.debug("User status not found for %s", user_uid)
83
+ return None
84
+ except Exception as e:
85
+ self.logger.error("Failed to fetch user status for %s: %s", user_uid, str(e), exc_info=True)
86
+ raise UserStatusError(
87
+ detail=f"Failed to fetch user status: {str(e)}",
88
+ user_uid=user_uid,
89
+ operation="get_userstatus",
90
+ original_error=e
91
+ ) from e
92
+
93
+ async def create_userstatus(self, userstatus: UserStatus, creator_uid: Optional[str] = None) -> UserStatus:
94
+ """Create a new user status"""
95
+ self.logger.info(f"Creating user status for UID: {userstatus.user_uid}")
96
+ try:
97
+ doc_id = f"{UserStatus.OBJ_REF}_{userstatus.user_uid}"
98
+ effective_creator_uid = creator_uid or userstatus.user_uid
99
+ await self._status_db_service.create_document(doc_id, userstatus, effective_creator_uid)
100
+ self.logger.info("Successfully created user status for UID: %s", userstatus.user_uid)
101
+ return userstatus
102
+ except Exception as e:
103
+ self.logger.error("Error creating user status for %s: %s", userstatus.user_uid, e, exc_info=True)
104
+ raise UserStatusError(
105
+ detail=f"Failed to create user status: {str(e)}",
106
+ user_uid=userstatus.user_uid,
107
+ operation="create_userstatus",
108
+ original_error=e
109
+ ) from e
110
+
111
+ async def update_userstatus(self, user_uid: str, status_data: Dict[str, Any], updater_uid: str) -> UserStatus:
112
+ """Update a user status"""
113
+ userstatus_id = f"{UserStatus.OBJ_REF}_{user_uid}"
114
+
115
+ # Remove system fields that shouldn't be updated
116
+ update_data = status_data.copy()
117
+ update_data.pop('user_uid', None)
118
+ update_data.pop('id', None)
119
+ update_data.pop('created_at', None)
120
+ update_data.pop('created_by', None)
121
+
122
+ try:
123
+ updated_doc_dict = await self._status_db_service.update_document(
124
+ doc_id=userstatus_id,
125
+ update_data=update_data,
126
+ updater_uid=updater_uid
127
+ )
128
+ self.logger.info("Userstatus for %s updated successfully by %s", user_uid, updater_uid)
129
+ return UserStatus(**updated_doc_dict)
130
+ except ResourceNotFoundError as exc:
131
+ raise UserStatusError(
132
+ detail="User status not found",
133
+ user_uid=user_uid,
134
+ operation="update_userstatus"
135
+ ) from exc
136
+ except Exception as e:
137
+ self.logger.error("Error updating Userstatus for %s: %s", user_uid, str(e), exc_info=True)
138
+ raise UserStatusError(
139
+ detail=f"Failed to update user status: {str(e)}",
140
+ user_uid=user_uid,
141
+ operation="update_userstatus",
142
+ original_error=e
143
+ ) from e
144
+
145
+ async def delete_userstatus(self, user_uid: str, updater_uid: str = "system_deletion", archive: Optional[bool] = True) -> bool:
146
+ """Delete (archive and delete) user status"""
147
+ status_doc_id = f"{UserStatus.OBJ_REF}_{user_uid}"
148
+ should_archive = archive if archive is not None else self.archive_userstatus_on_delete
149
+
150
+ try:
151
+ # Get status data for archival
152
+ status_data = await self._status_db_service.get_document(status_doc_id, convert_to_model=False)
153
+
154
+ if status_data:
155
+ # Ensure we have a dict for archival
156
+ status_dict = status_data if isinstance(status_data, dict) else status_data.__dict__
157
+
158
+ # Archive if enabled
159
+ if should_archive:
160
+ await self._status_db_service.archive_document(
161
+ document_data=status_dict,
162
+ doc_id=status_doc_id,
163
+ archive_collection=self.archive_userstatus_collection_name,
164
+ archived_by=updater_uid
165
+ )
166
+
167
+ # Delete the original document
168
+ await self._status_db_service.delete_document(status_doc_id)
169
+ self.logger.info("Successfully deleted user status: %s", status_doc_id)
170
+ return True
171
+ else:
172
+ self.logger.warning("User status %s not found for deletion", status_doc_id)
173
+ return True # Consider non-existent as successfully deleted
174
+
175
+ except ResourceNotFoundError:
176
+ self.logger.debug("User status %s not found for deletion (idempotent)", status_doc_id)
177
+ return True # Idempotent - already "deleted"
178
+ except Exception as e:
179
+ self.logger.error("Failed to delete user status %s: %s", status_doc_id, str(e), exc_info=True)
180
+ raise UserStatusError(
181
+ detail=f"Failed to delete user status: {str(e)}",
182
+ user_uid=user_uid,
183
+ operation="delete_userstatus",
184
+ original_error=e
185
+ ) from e
186
+
187
+ async def validate_userstatus_data(
188
+ self,
189
+ status_data: Optional[Dict[str, Any]] = None
190
+ ) -> tuple[bool, list[str]]:
191
+ """Validate user status data without creating documents"""
192
+ errors = []
193
+ if status_data:
194
+ try:
195
+ UserStatus(**status_data)
196
+ except PydanticValidationError as e:
197
+ errors.append(f"Status validation error: {str(e)}")
198
+ return len(errors) == 0, errors
199
+
200
+ async def validate_and_cleanup_user_permissions(
201
+ self, user_uid: str, updater_uid: str, delete_expired: bool = True
202
+ ) -> int:
203
+ """Validate and clean up expired IAM permissions for a user."""
204
+ userstatus = await self.get_userstatus(user_uid)
205
+ if not userstatus:
206
+ self.logger.warning("Userstatus not found for %s, cannot validate permissions.", user_uid)
207
+ return 0
208
+
209
+ removed_count = userstatus.cleanup_expired_permissions()
210
+
211
+ if removed_count > 0 and delete_expired:
212
+ await self.update_userstatus(
213
+ user_uid,
214
+ userstatus.model_dump(exclude_none=True),
215
+ updater_uid=updater_uid
216
+ )
217
+ self.logger.info("Removed %d expired permissions for user %s.", removed_count, user_uid)
218
+
219
+ return removed_count
220
+
221
+ async def userstatus_exists(self, user_uid: str) -> bool:
222
+ """Check if a user status exists."""
223
+ return await self._status_db_service.document_exists(f"{UserStatus.OBJ_REF}_{user_uid}")
224
+
225
+ ######################################################################
226
+ ######################### Comprehensive Review Methods #############
227
+ ######################################################################
228
+
229
+ async def review_and_clean_active_subscription_credits_and_permissions(
230
+ self,
231
+ user_uid: str,
232
+ updater_uid: str = "system_review",
233
+ review_auto_renewal: bool = True,
234
+ apply_fallback: bool = True,
235
+ clean_expired_permissions: bool = True,
236
+ review_credits: bool = True
237
+ ) -> Dict[str, Any]:
238
+ """
239
+ Comprehensive review of user's active subscription, credits, and permissions.
240
+ This method handles:
241
+ 1. Subscription lifecycle (auto-renewal, fallback, expiration)
242
+ 2. Credit management based on subscription cycle timing
243
+ 3. Permission cleanup and management
244
+
245
+ This is designed to be called on every authz request for comprehensive user state management.
246
+
247
+ Args:
248
+ user_uid: User UID to review
249
+ updater_uid: User ID performing the review
250
+ account_for_auto_renewal: Whether to auto-renew expired cycles if auto_renew_end_datetime is valid
251
+ apply_fallback: Whether to apply fallback plans when subscriptions expire
252
+ clean_expired_permissions: Whether to clean expired permissions
253
+ review_credits: Whether to review and update subscription-based credits
254
+
255
+ Returns:
256
+ Dict containing detailed results of the review and actions taken
257
+ """
258
+ from datetime import datetime, timezone
259
+ from ipulse_shared_base_ftredge.enums import SubscriptionStatus
260
+ from ...models import UserSubscription
261
+
262
+ self.logger.info("Starting comprehensive subscription, credits, and permissions review for user %s", user_uid)
263
+
264
+ result = {
265
+ 'user_uid': user_uid,
266
+ 'timestamp': datetime.now(timezone.utc),
267
+ 'actions_taken': [],
268
+ 'subscription_status': None,
269
+ 'subscription_renewed': False,
270
+ 'fallback_applied': False,
271
+ 'subscription_revoked': False,
272
+ 'permissions_cleaned': 0,
273
+ 'permissions_added': 0,
274
+ 'credits_updated': 0,
275
+ 'error': None,
276
+ 'original_subscription': None,
277
+ 'final_subscription': None,
278
+ 'updated_userstatus': None # Will be populated with the updated UserStatus
279
+ }
280
+
281
+ try:
282
+ # Get current user status
283
+ userstatus = await self.get_userstatus(user_uid)
284
+ if not userstatus:
285
+ result['actions_taken'].append('no_userstatus_found')
286
+ return result
287
+
288
+ # Always add the current userstatus to result (will be updated if database changes occur)
289
+ result['updated_userstatus'] = userstatus
290
+
291
+ # Check if there's an active subscription
292
+ if not userstatus.active_subscription:
293
+ result['actions_taken'].append('no_active_subscription')
294
+
295
+ # Clean expired permissions if requested
296
+ if clean_expired_permissions:
297
+ expired_permissions = userstatus.cleanup_expired_permissions()
298
+ if expired_permissions > 0:
299
+ result['permissions_cleaned'] = expired_permissions
300
+ result['actions_taken'].append('cleaned_expired_permissions')
301
+
302
+ # Save changes and update the result with the updated userstatus
303
+ updated_userstatus = await self.update_userstatus(
304
+ user_uid=user_uid,
305
+ status_data=userstatus.model_dump(exclude_none=True),
306
+ updater_uid=f"review:{updater_uid}"
307
+ )
308
+ result['updated_userstatus'] = updated_userstatus
309
+
310
+ return result
311
+
312
+ # Store original subscription for comparison
313
+ result['original_subscription'] = userstatus.active_subscription
314
+ now = datetime.now(timezone.utc)
315
+
316
+ # Use the subscription's is_active() method to check current status
317
+ if userstatus.active_subscription.is_active():
318
+ result['subscription_status'] = str(SubscriptionStatus.ACTIVE)
319
+ result['final_subscription'] = userstatus.active_subscription
320
+ result['actions_taken'].append('subscription_still_active')
321
+
322
+ # Update credits if subscription is active and review_credits is enabled
323
+ if review_credits:
324
+ credits_updated = userstatus.update_subscription_credits()
325
+ if credits_updated > 0:
326
+ result['credits_updated'] = credits_updated
327
+ result['actions_taken'].append('updated_subscription_credits')
328
+
329
+ # Clean expired permissions if requested
330
+ if clean_expired_permissions:
331
+ expired_permissions = userstatus.cleanup_expired_permissions()
332
+ if expired_permissions > 0:
333
+ result['permissions_cleaned'] = expired_permissions
334
+ result['actions_taken'].append('cleaned_expired_permissions_only')
335
+
336
+ # Save changes if any updates were made
337
+ if result['credits_updated'] > 0 or result['permissions_cleaned'] > 0:
338
+ updated_userstatus = await self.update_userstatus(
339
+ user_uid=user_uid,
340
+ status_data=userstatus.model_dump(exclude_none=True),
341
+ updater_uid=f"review:{updater_uid}"
342
+ )
343
+ result['updated_userstatus'] = updated_userstatus
344
+
345
+ return result
346
+
347
+ # Subscription is not active - determine why and what to do
348
+ subscription = userstatus.active_subscription
349
+
350
+ # Check if cycle is expired but auto-renewal is still valid
351
+ if (review_auto_renewal and
352
+ subscription.auto_renew_end_datetime and
353
+ now <= subscription.auto_renew_end_datetime and
354
+ now > subscription.cycle_end_datetime_safe):
355
+
356
+ # Attempt auto-renewal by extending the cycle
357
+ try:
358
+ # Calculate new cycle start date (where the last cycle ended)
359
+ new_cycle_start = subscription.cycle_end_datetime_safe
360
+
361
+ # Create new subscription with updated cycle start date
362
+ subscription_dict = subscription.model_dump()
363
+ subscription_dict.update({
364
+ 'cycle_start_date': new_cycle_start,
365
+ 'cycle_end_datetime': None, # Let the model auto-calculate this
366
+ 'updated_at': now,
367
+ 'updated_by': f"UserstatusOperations.auto_renew:{updater_uid}"
368
+ })
369
+
370
+ renewed_subscription = UserSubscription(**subscription_dict)
371
+
372
+ # Apply the renewed subscription
373
+ userstatus.apply_subscription(
374
+ renewed_subscription,
375
+ add_associated_permissions=True,
376
+ remove_previous_subscription_permissions=True,
377
+ granted_by=f"UserstatusOperations.review.auto_renew:{updater_uid}"
378
+ )
379
+
380
+ result['subscription_renewed'] = True
381
+ result['subscription_status'] = str(userstatus.active_subscription.status)
382
+ result['final_subscription'] = renewed_subscription
383
+ result['actions_taken'].append('auto_renewed_cycle')
384
+
385
+ except (ValueError, UserStatusError) as renewal_error:
386
+ self.logger.error("Auto-renewal failed for user %s: %s", user_uid, renewal_error)
387
+ result['error'] = f"Auto-renewal failed: {str(renewal_error)}"
388
+ result['actions_taken'].append('auto_renewal_failed')
389
+ # Continue to fallback logic
390
+
391
+ # If auto-renewal didn't happen or failed, check for fallback
392
+ if not result['subscription_renewed']:
393
+ if apply_fallback and subscription.fallback_plan_id:
394
+ try:
395
+ # We need subscription_ops to handle the fallback plan logic
396
+ if self.subscription_ops:
397
+ # Import the catalog service to get fallback plan
398
+ from ...services.catalog.catalog_subscriptionplan_service import CatalogSubscriptionPlanService
399
+
400
+ catalog_service = CatalogSubscriptionPlanService(firestore_client=self.db, logger=self.logger)
401
+ fallback_plan = await catalog_service.get_subscriptionplan(subscription.fallback_plan_id)
402
+
403
+ if fallback_plan:
404
+ # Create new subscription from fallback plan
405
+ fallback_subscription = self.subscription_ops.create_subscription_from_subscriptionplan(
406
+ plan=fallback_plan,
407
+ source=f"fallback_from_{subscription.plan_id}:review:{updater_uid}",
408
+ granted_at=now,
409
+ auto_renewal_end=fallback_plan.plan_default_auto_renewal_end
410
+ )
411
+
412
+ # Apply fallback subscription
413
+ permissions_added = userstatus.apply_subscription(
414
+ fallback_subscription,
415
+ add_associated_permissions=True,
416
+ remove_previous_subscription_permissions=True,
417
+ granted_by=f"UserstatusOperations.review.fallback:{updater_uid}"
418
+ )
419
+
420
+ result['fallback_applied'] = True
421
+ result['subscription_status'] = str(userstatus.active_subscription.status)
422
+ result['final_subscription'] = fallback_subscription
423
+ result['permissions_added'] = permissions_added
424
+ result['actions_taken'].append('applied_fallback_plan')
425
+
426
+ else:
427
+ self.logger.warning("Fallback plan %s not found for user %s", subscription.fallback_plan_id, user_uid)
428
+ result['actions_taken'].append('fallback_plan_not_found')
429
+ else:
430
+ self.logger.warning("Cannot apply fallback - subscription_ops not available")
431
+ result['actions_taken'].append('fallback_unavailable_no_subscription_ops')
432
+
433
+ except (ValueError, UserStatusError) as fallback_error:
434
+ self.logger.error("Fallback application failed for user %s: %s", user_uid, fallback_error)
435
+ result['error'] = f"Fallback failed: {str(fallback_error)}"
436
+ result['actions_taken'].append('fallback_failed')
437
+ # Continue to revocation logic
438
+
439
+ # If no renewal or fallback happened, revoke the subscription
440
+ if not result['subscription_renewed'] and not result['fallback_applied']:
441
+ permissions_cleaned = userstatus.revoke_subscription(remove_associated_permissions=True)
442
+ result['subscription_revoked'] = True
443
+ result['subscription_status'] = None
444
+ result['permissions_cleaned'] = permissions_cleaned
445
+ result['actions_taken'].append('subscription_revoked')
446
+
447
+ # Clean up all expired permissions (not just those associated with the subscription)
448
+ if clean_expired_permissions:
449
+ additional_expired = userstatus.cleanup_expired_permissions()
450
+ if additional_expired > 0:
451
+ result['permissions_cleaned'] += additional_expired
452
+ result['actions_taken'].append('cleaned_additional_expired_permissions')
453
+
454
+ # Save all changes to database
455
+ updated_userstatus = await self.update_userstatus(
456
+ user_uid=user_uid,
457
+ status_data=userstatus.model_dump(exclude_none=False), # Include None values for proper updates
458
+ updater_uid=f"review:{updater_uid}"
459
+ )
460
+
461
+ # Add the updated UserStatus to the result
462
+ result['updated_userstatus'] = updated_userstatus
463
+
464
+ self.logger.info(
465
+ "Completed comprehensive review for user %s. Status: %s, Actions: %s",
466
+ user_uid, result['subscription_status'], result['actions_taken']
467
+ )
468
+
469
+ except Exception as e:
470
+ self.logger.error("Comprehensive review failed for user %s: %s", user_uid, e, exc_info=True)
471
+ result['error'] = str(e)
472
+ result['actions_taken'].append('review_failed')
473
+ # Re-raise for proper error handling
474
+ raise
475
+
476
+ return result