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.
- ipulse_shared_core_ftredge/cache/shared_cache.py +1 -2
- ipulse_shared_core_ftredge/dependencies/auth_firebase_token_validation.py +60 -23
- ipulse_shared_core_ftredge/dependencies/authz_for_apis.py +128 -157
- ipulse_shared_core_ftredge/exceptions/base_exceptions.py +35 -4
- 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 +274 -0
- ipulse_shared_core_ftredge/models/catalog/usertype.py +177 -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/user/user_subscription.py +348 -0
- 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 +479 -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 +77 -16
- 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 +277 -0
- ipulse_shared_core_ftredge/services/catalog/catalog_usertype_service.py +376 -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/user_core_service.py +536 -510
- ipulse_shared_core_ftredge/services/user/user_multistep_operations.py +796 -0
- ipulse_shared_core_ftredge/services/user/user_permissions_operations.py +392 -0
- ipulse_shared_core_ftredge/services/user/user_subscription_operations.py +488 -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 +476 -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-23.1.1.dist-info}/METADATA +3 -4
- ipulse_shared_core_ftredge-23.1.1.dist-info/RECORD +50 -0
- ipulse_shared_core_ftredge/models/subscription.py +0 -190
- 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-23.1.1.dist-info}/WHEEL +0 -0
- {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-23.1.1.dist-info}/licenses/LICENCE +0 -0
- {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
|