ipulse-shared-core-ftredge 20.0.1__py3-none-any.whl → 22.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of ipulse-shared-core-ftredge might be problematic. Click here for more details.
- ipulse_shared_core_ftredge/cache/shared_cache.py +1 -2
- ipulse_shared_core_ftredge/dependencies/authz_for_apis.py +4 -4
- ipulse_shared_core_ftredge/exceptions/base_exceptions.py +23 -0
- ipulse_shared_core_ftredge/models/__init__.py +3 -7
- ipulse_shared_core_ftredge/models/base_data_model.py +17 -19
- ipulse_shared_core_ftredge/models/catalog/__init__.py +10 -0
- ipulse_shared_core_ftredge/models/catalog/subscriptionplan.py +273 -0
- ipulse_shared_core_ftredge/models/catalog/usertype.py +170 -0
- ipulse_shared_core_ftredge/models/user/__init__.py +5 -0
- ipulse_shared_core_ftredge/models/user/user_permissions.py +66 -0
- ipulse_shared_core_ftredge/models/{subscription.py → user/user_subscription.py} +66 -20
- ipulse_shared_core_ftredge/models/{user_auth.py → user/userauth.py} +19 -10
- ipulse_shared_core_ftredge/models/{user_profile.py → user/userprofile.py} +53 -21
- ipulse_shared_core_ftredge/models/user/userstatus.py +430 -0
- ipulse_shared_core_ftredge/monitoring/__init__.py +0 -2
- ipulse_shared_core_ftredge/monitoring/tracemon.py +6 -6
- ipulse_shared_core_ftredge/services/__init__.py +11 -13
- ipulse_shared_core_ftredge/services/base/__init__.py +3 -1
- ipulse_shared_core_ftredge/services/base/base_firestore_service.py +73 -14
- ipulse_shared_core_ftredge/services/{cache_aware_firestore_service.py → base/cache_aware_firestore_service.py} +46 -32
- ipulse_shared_core_ftredge/services/catalog/__init__.py +14 -0
- ipulse_shared_core_ftredge/services/catalog/catalog_subscriptionplan_service.py +273 -0
- ipulse_shared_core_ftredge/services/catalog/catalog_usertype_service.py +307 -0
- ipulse_shared_core_ftredge/services/charging_processors.py +25 -25
- ipulse_shared_core_ftredge/services/user/__init__.py +5 -25
- ipulse_shared_core_ftredge/services/user/firebase_auth_admin_helpers.py +160 -0
- ipulse_shared_core_ftredge/services/user/user_core_service.py +423 -515
- ipulse_shared_core_ftredge/services/user/user_multistep_operations.py +726 -0
- ipulse_shared_core_ftredge/services/user/user_permissions_operations.py +392 -0
- ipulse_shared_core_ftredge/services/user/user_subscription_operations.py +484 -0
- ipulse_shared_core_ftredge/services/user/userauth_operations.py +928 -0
- ipulse_shared_core_ftredge/services/user/userprofile_operations.py +166 -0
- ipulse_shared_core_ftredge/services/user/userstatus_operations.py +212 -0
- ipulse_shared_core_ftredge/services/{charging_service.py → user_charging_service.py} +9 -9
- {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-22.1.1.dist-info}/METADATA +3 -4
- ipulse_shared_core_ftredge-22.1.1.dist-info/RECORD +51 -0
- ipulse_shared_core_ftredge/models/user_status.py +0 -495
- ipulse_shared_core_ftredge/monitoring/microservmon.py +0 -526
- ipulse_shared_core_ftredge/services/user/iam_management_operations.py +0 -326
- ipulse_shared_core_ftredge/services/user/subscription_management_operations.py +0 -384
- ipulse_shared_core_ftredge/services/user/user_account_operations.py +0 -479
- ipulse_shared_core_ftredge/services/user/user_auth_operations.py +0 -305
- ipulse_shared_core_ftredge/services/user/user_holistic_operations.py +0 -436
- ipulse_shared_core_ftredge-20.0.1.dist-info/RECORD +0 -42
- {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-22.1.1.dist-info}/WHEEL +0 -0
- {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-22.1.1.dist-info}/licenses/LICENCE +0 -0
- {ipulse_shared_core_ftredge-20.0.1.dist-info → ipulse_shared_core_ftredge-22.1.1.dist-info}/top_level.txt +0 -0
|
@@ -7,7 +7,7 @@ This provides the foundation for all Firestore-based services.
|
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
9
|
import logging
|
|
10
|
-
from datetime import datetime, timezone
|
|
10
|
+
from datetime import datetime, timezone, date
|
|
11
11
|
from typing import Any, AsyncGenerator, Dict, Generic, List, Optional, TypeVar, Type, Union
|
|
12
12
|
|
|
13
13
|
from google.cloud import firestore
|
|
@@ -20,6 +20,32 @@ from ...exceptions import ResourceNotFoundError, ServiceError, ValidationError a
|
|
|
20
20
|
T = TypeVar('T', bound=BaseModel)
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
def _sanitize_firestore_data(data: Any) -> Any:
|
|
24
|
+
"""
|
|
25
|
+
Recursively sanitize data before sending to Firestore.
|
|
26
|
+
Converts datetime.date objects to datetime.datetime objects since Firestore
|
|
27
|
+
only supports datetime.datetime, not datetime.date.
|
|
28
|
+
"""
|
|
29
|
+
if isinstance(data, date) and not isinstance(data, datetime):
|
|
30
|
+
# Convert date to datetime (start of day in UTC)
|
|
31
|
+
return datetime.combine(data, datetime.min.time()).replace(tzinfo=timezone.utc)
|
|
32
|
+
|
|
33
|
+
if isinstance(data, BaseModel):
|
|
34
|
+
# Convert Pydantic model to dict and sanitize recursively
|
|
35
|
+
return _sanitize_firestore_data(data.model_dump())
|
|
36
|
+
|
|
37
|
+
if isinstance(data, dict):
|
|
38
|
+
# Recurse into dictionaries
|
|
39
|
+
return {k: _sanitize_firestore_data(v) for k, v in data.items()}
|
|
40
|
+
|
|
41
|
+
if isinstance(data, list):
|
|
42
|
+
# Recurse into lists
|
|
43
|
+
return [_sanitize_firestore_data(item) for item in data]
|
|
44
|
+
|
|
45
|
+
# Return everything else as-is (str, int, float, bool, datetime, etc.)
|
|
46
|
+
return data
|
|
47
|
+
|
|
48
|
+
|
|
23
49
|
class BaseFirestoreService(Generic[T]):
|
|
24
50
|
"""
|
|
25
51
|
Base service class for Firestore operations using Pydantic models
|
|
@@ -95,7 +121,7 @@ class BaseFirestoreService(Generic[T]):
|
|
|
95
121
|
additional_info={"validation_errors": e.errors()}
|
|
96
122
|
)
|
|
97
123
|
|
|
98
|
-
async def get_document(self, doc_id: str, convert_to_model: bool = True) -> Union[Dict[str, Any]
|
|
124
|
+
async def get_document(self, doc_id: str, convert_to_model: bool = True) -> Union[T, Dict[str, Any]]:
|
|
99
125
|
"""
|
|
100
126
|
Get a document by ID
|
|
101
127
|
|
|
@@ -104,7 +130,7 @@ class BaseFirestoreService(Generic[T]):
|
|
|
104
130
|
convert_to_model: Whether to convert to Pydantic model
|
|
105
131
|
|
|
106
132
|
Returns:
|
|
107
|
-
Document as
|
|
133
|
+
Document as a model instance or dict.
|
|
108
134
|
|
|
109
135
|
Raises:
|
|
110
136
|
ResourceNotFoundError: If document doesn't exist
|
|
@@ -121,7 +147,21 @@ class BaseFirestoreService(Generic[T]):
|
|
|
121
147
|
)
|
|
122
148
|
|
|
123
149
|
doc_dict = doc.to_dict()
|
|
124
|
-
|
|
150
|
+
if not doc_dict:
|
|
151
|
+
# This case should ideally not be reached if doc.exists is true,
|
|
152
|
+
# but as a safeguard:
|
|
153
|
+
raise ServiceError(
|
|
154
|
+
operation="get_document",
|
|
155
|
+
error=ValueError("Document exists but data is empty."),
|
|
156
|
+
resource_type=self.resource_type,
|
|
157
|
+
resource_id=doc_id
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if convert_to_model and self.model_class:
|
|
161
|
+
return self._convert_to_model(doc_dict, doc_id)
|
|
162
|
+
else:
|
|
163
|
+
doc_dict['id'] = doc_id
|
|
164
|
+
return doc_dict
|
|
125
165
|
|
|
126
166
|
except ResourceNotFoundError:
|
|
127
167
|
raise
|
|
@@ -162,6 +202,9 @@ class BaseFirestoreService(Generic[T]):
|
|
|
162
202
|
else:
|
|
163
203
|
doc_dict = data.copy()
|
|
164
204
|
|
|
205
|
+
# Sanitize data for Firestore (convert date objects to datetime)
|
|
206
|
+
doc_dict = _sanitize_firestore_data(doc_dict)
|
|
207
|
+
|
|
165
208
|
# Ensure ID is set correctly
|
|
166
209
|
doc_dict['id'] = doc_id
|
|
167
210
|
|
|
@@ -234,6 +277,10 @@ class BaseFirestoreService(Generic[T]):
|
|
|
234
277
|
|
|
235
278
|
# Add update timestamp and user
|
|
236
279
|
updates = update_data.copy()
|
|
280
|
+
|
|
281
|
+
# Sanitize data for Firestore (convert date objects to datetime)
|
|
282
|
+
updates = _sanitize_firestore_data(updates)
|
|
283
|
+
|
|
237
284
|
updates['updated_at'] = datetime.now(timezone.utc)
|
|
238
285
|
updates['updated_by'] = updater_uid
|
|
239
286
|
|
|
@@ -310,7 +357,7 @@ class BaseFirestoreService(Generic[T]):
|
|
|
310
357
|
"""
|
|
311
358
|
try:
|
|
312
359
|
doc_ref = self._get_collection().document(doc_id)
|
|
313
|
-
doc =
|
|
360
|
+
doc = doc_ref.get() # Remove await - this is synchronous
|
|
314
361
|
return doc.exists
|
|
315
362
|
except Exception as e:
|
|
316
363
|
raise ServiceError(
|
|
@@ -325,8 +372,9 @@ class BaseFirestoreService(Generic[T]):
|
|
|
325
372
|
limit: Optional[int] = None,
|
|
326
373
|
start_after: Optional[str] = None,
|
|
327
374
|
order_by: Optional[str] = None,
|
|
328
|
-
filters: Optional[List[FieldFilter]] = None
|
|
329
|
-
|
|
375
|
+
filters: Optional[List[FieldFilter]] = None,
|
|
376
|
+
as_models: bool = True
|
|
377
|
+
) -> Union[List[T], List[Dict[str, Any]]]:
|
|
330
378
|
"""
|
|
331
379
|
List documents with optional filtering and pagination
|
|
332
380
|
|
|
@@ -335,9 +383,10 @@ class BaseFirestoreService(Generic[T]):
|
|
|
335
383
|
start_after: Document ID to start after for pagination
|
|
336
384
|
order_by: Field to order by
|
|
337
385
|
filters: List of field filters
|
|
386
|
+
as_models: Whether to convert documents to Pydantic models
|
|
338
387
|
|
|
339
388
|
Returns:
|
|
340
|
-
List of documents as model instances
|
|
389
|
+
List of documents as model instances or dicts
|
|
341
390
|
|
|
342
391
|
Raises:
|
|
343
392
|
ServiceError: If an error occurs during listing
|
|
@@ -348,7 +397,8 @@ class BaseFirestoreService(Generic[T]):
|
|
|
348
397
|
# Apply filters
|
|
349
398
|
if filters:
|
|
350
399
|
for filter_condition in filters:
|
|
351
|
-
|
|
400
|
+
field, operator, value = filter_condition
|
|
401
|
+
query = query.where(field, operator, value)
|
|
352
402
|
|
|
353
403
|
# Apply ordering
|
|
354
404
|
if order_by:
|
|
@@ -373,7 +423,7 @@ class BaseFirestoreService(Generic[T]):
|
|
|
373
423
|
if doc_dict is None:
|
|
374
424
|
continue # Skip documents that don't exist
|
|
375
425
|
|
|
376
|
-
if self.model_class:
|
|
426
|
+
if as_models and self.model_class:
|
|
377
427
|
model_instance = self._convert_to_model(doc_dict, doc.id)
|
|
378
428
|
results.append(model_instance)
|
|
379
429
|
else:
|
|
@@ -412,20 +462,27 @@ class BaseFirestoreService(Generic[T]):
|
|
|
412
462
|
ServiceError: If an error occurs during archival
|
|
413
463
|
"""
|
|
414
464
|
try:
|
|
465
|
+
# Generate unique archive document ID to handle duplicates
|
|
466
|
+
archive_timestamp = datetime.now(timezone.utc)
|
|
467
|
+
timestamp_str = archive_timestamp.strftime("%Y%m%d_%H%M%S_%f")[:-3] # microseconds to milliseconds
|
|
468
|
+
unique_archive_doc_id = f"{doc_id}_{timestamp_str}"
|
|
469
|
+
|
|
415
470
|
# Add archival metadata
|
|
416
471
|
archive_data = document_data.copy()
|
|
417
472
|
archive_data.update({
|
|
418
|
-
"archived_at":
|
|
473
|
+
"archived_at": archive_timestamp,
|
|
419
474
|
"archived_by": archived_by,
|
|
475
|
+
"updated_at": archive_timestamp,
|
|
476
|
+
"updated_by": archived_by,
|
|
420
477
|
"original_collection": self.collection_name,
|
|
421
478
|
"original_doc_id": doc_id
|
|
422
479
|
})
|
|
423
480
|
|
|
424
|
-
# Store in archive collection
|
|
425
|
-
archive_ref = self.db.collection(archive_collection).document(
|
|
481
|
+
# Store in archive collection with unique ID
|
|
482
|
+
archive_ref = self.db.collection(archive_collection).document(unique_archive_doc_id)
|
|
426
483
|
archive_ref.set(archive_data, timeout=self.timeout)
|
|
427
484
|
|
|
428
|
-
self.logger.info(f"Successfully archived {self.resource_type} {doc_id} to {archive_collection}")
|
|
485
|
+
self.logger.info(f"Successfully archived {self.resource_type} {doc_id} to {archive_collection} as {unique_archive_doc_id}")
|
|
429
486
|
return True
|
|
430
487
|
|
|
431
488
|
except Exception as e:
|
|
@@ -518,3 +575,5 @@ class BaseFirestoreService(Generic[T]):
|
|
|
518
575
|
resource_type=self.resource_type,
|
|
519
576
|
resource_id=doc_id
|
|
520
577
|
)
|
|
578
|
+
|
|
579
|
+
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"""Cache-aware Firestore service base class."""
|
|
2
2
|
import time
|
|
3
|
-
from typing import TypeVar, Generic, Dict, Any, List, Optional
|
|
3
|
+
from typing import TypeVar, Generic, Dict, Any, List, Optional, Union, Type
|
|
4
4
|
from google.cloud import firestore
|
|
5
|
-
from .
|
|
6
|
-
from
|
|
7
|
-
from
|
|
8
|
-
from
|
|
5
|
+
from . import BaseFirestoreService
|
|
6
|
+
from ...exceptions import ResourceNotFoundError, ServiceError
|
|
7
|
+
from ...cache.shared_cache import SharedCache
|
|
8
|
+
from ...models import BaseDataModel
|
|
9
9
|
|
|
10
10
|
T = TypeVar('T', bound=BaseDataModel)
|
|
11
11
|
|
|
@@ -20,12 +20,13 @@ class CacheAwareFirestoreService(BaseFirestoreService[T], Generic[T]):
|
|
|
20
20
|
db: firestore.Client,
|
|
21
21
|
collection_name: str,
|
|
22
22
|
resource_type: str,
|
|
23
|
-
|
|
23
|
+
model_class: Optional[Type[T]] = None,
|
|
24
|
+
logger=None,
|
|
24
25
|
document_cache: Optional[SharedCache] = None,
|
|
25
26
|
collection_cache: Optional[SharedCache] = None,
|
|
26
27
|
timeout: float = 30.0
|
|
27
28
|
):
|
|
28
|
-
super().__init__(db, collection_name, resource_type, logger)
|
|
29
|
+
super().__init__(db, collection_name, resource_type, model_class, logger, timeout)
|
|
29
30
|
self.document_cache = document_cache
|
|
30
31
|
self.collection_cache = collection_cache
|
|
31
32
|
self.timeout = timeout
|
|
@@ -36,15 +37,16 @@ class CacheAwareFirestoreService(BaseFirestoreService[T], Generic[T]):
|
|
|
36
37
|
if self.collection_cache:
|
|
37
38
|
self.logger.info(f"Collection cache enabled for {resource_type}: {self.collection_cache.name}")
|
|
38
39
|
|
|
39
|
-
async def get_document(self, doc_id: str) -> Dict[str, Any]:
|
|
40
|
+
async def get_document(self, doc_id: str, convert_to_model: bool = True) -> Union[T, Dict[str, Any]]:
|
|
40
41
|
"""
|
|
41
42
|
Get a document with caching support.
|
|
42
43
|
|
|
43
44
|
Args:
|
|
44
45
|
doc_id: Document ID to fetch
|
|
46
|
+
convert_to_model: Whether to convert to Pydantic model
|
|
45
47
|
|
|
46
48
|
Returns:
|
|
47
|
-
Document
|
|
49
|
+
Document as model instance or dictionary
|
|
48
50
|
|
|
49
51
|
Raises:
|
|
50
52
|
ResourceNotFoundError: If document doesn't exist
|
|
@@ -57,35 +59,29 @@ class CacheAwareFirestoreService(BaseFirestoreService[T], Generic[T]):
|
|
|
57
59
|
|
|
58
60
|
if cached_doc is not None:
|
|
59
61
|
self.logger.debug(f"Cache HIT for document {doc_id} in {cache_check_time:.2f}ms")
|
|
60
|
-
|
|
62
|
+
if convert_to_model and self.model_class:
|
|
63
|
+
return self._convert_to_model(cached_doc, doc_id)
|
|
64
|
+
else:
|
|
65
|
+
cached_doc['id'] = doc_id
|
|
66
|
+
return cached_doc
|
|
61
67
|
else:
|
|
62
68
|
self.logger.debug(f"Cache MISS for document {doc_id} - checking Firestore")
|
|
63
69
|
|
|
64
|
-
# Fetch from Firestore
|
|
65
|
-
|
|
66
|
-
doc_ref = self.db.collection(self.collection_name).document(doc_id)
|
|
67
|
-
doc = doc_ref.get(timeout=self.timeout)
|
|
68
|
-
firestore_time = (time.time() - start_time) * 1000
|
|
69
|
-
|
|
70
|
-
if not doc.exists:
|
|
71
|
-
self.logger.info(f"Document {doc_id} not found in Firestore after {firestore_time:.2f}ms")
|
|
72
|
-
raise ResourceNotFoundError(self.resource_type, doc_id)
|
|
73
|
-
|
|
74
|
-
doc_data = doc.to_dict()
|
|
75
|
-
self.logger.debug(f"Fetched document {doc_id} from Firestore in {firestore_time:.2f}ms")
|
|
76
|
-
|
|
77
|
-
# Cache the result
|
|
78
|
-
if self.document_cache and doc_data:
|
|
79
|
-
self.document_cache.set(doc_id, doc_data)
|
|
80
|
-
self.logger.debug(f"Cached document {doc_id}")
|
|
70
|
+
# Fetch from Firestore using parent method
|
|
71
|
+
return await super().get_document(doc_id, convert_to_model)
|
|
81
72
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
async def get_all_documents(self, cache_key: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
73
|
+
async def get_all_documents(self, cache_key: Optional[str] = None, as_models: bool = True) -> Union[List[T], List[Dict[str, Any]]]:
|
|
85
74
|
"""
|
|
86
75
|
Retrieves all documents from the collection.
|
|
87
76
|
Uses collection_cache if cache_key is provided and cache is available.
|
|
88
77
|
Also populates document_cache for each retrieved document.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
cache_key: Optional cache key for collection-level caching
|
|
81
|
+
as_models: Whether to convert documents to Pydantic models
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
List of documents as model instances or dicts
|
|
89
85
|
"""
|
|
90
86
|
if cache_key and self.collection_cache:
|
|
91
87
|
cached_collection_data = self.collection_cache.get(cache_key)
|
|
@@ -96,6 +92,15 @@ class CacheAwareFirestoreService(BaseFirestoreService[T], Generic[T]):
|
|
|
96
92
|
for doc_data in cached_collection_data:
|
|
97
93
|
if "id" in doc_data and not self.document_cache.get(doc_data["id"]):
|
|
98
94
|
self._cache_document_data(doc_data["id"], doc_data)
|
|
95
|
+
|
|
96
|
+
# Convert to models if requested
|
|
97
|
+
if as_models and self.model_class:
|
|
98
|
+
results = []
|
|
99
|
+
for doc_data in cached_collection_data:
|
|
100
|
+
if "id" in doc_data:
|
|
101
|
+
model_instance = self._convert_to_model(doc_data, doc_data["id"])
|
|
102
|
+
results.append(model_instance)
|
|
103
|
+
return results
|
|
99
104
|
return cached_collection_data
|
|
100
105
|
else:
|
|
101
106
|
self.logger.debug(f"Cache MISS for collection key '{cache_key}' in {self.collection_cache.name} - checking Firestore")
|
|
@@ -127,6 +132,15 @@ class CacheAwareFirestoreService(BaseFirestoreService[T], Generic[T]):
|
|
|
127
132
|
# _cache_document_data expects 'id' to be in doc_data for keying
|
|
128
133
|
self._cache_document_data(doc_data["id"], doc_data)
|
|
129
134
|
|
|
135
|
+
# Convert to models if requested
|
|
136
|
+
if as_models and self.model_class:
|
|
137
|
+
results = []
|
|
138
|
+
for doc_data in docs_data_list:
|
|
139
|
+
if "id" in doc_data:
|
|
140
|
+
model_instance = self._convert_to_model(doc_data, doc_data["id"])
|
|
141
|
+
results.append(model_instance)
|
|
142
|
+
return results
|
|
143
|
+
|
|
130
144
|
return docs_data_list
|
|
131
145
|
|
|
132
146
|
except Exception as e:
|
|
@@ -139,9 +153,9 @@ class CacheAwareFirestoreService(BaseFirestoreService[T], Generic[T]):
|
|
|
139
153
|
self.document_cache.set(doc_id, data)
|
|
140
154
|
self.logger.debug(f"Cached item {doc_id} in {self.document_cache.name}")
|
|
141
155
|
|
|
142
|
-
async def create_document(self, doc_id: str, data: T, creator_uid: str) -> Dict[str, Any]:
|
|
156
|
+
async def create_document(self, doc_id: str, data: Union[T, Dict[str, Any]], creator_uid: str, merge: bool = False) -> Dict[str, Any]:
|
|
143
157
|
"""Create document and invalidate cache."""
|
|
144
|
-
result = await super().create_document(doc_id, data, creator_uid)
|
|
158
|
+
result = await super().create_document(doc_id, data, creator_uid, merge)
|
|
145
159
|
self._invalidate_document_cache(doc_id)
|
|
146
160
|
self._invalidate_all_collection_caches()
|
|
147
161
|
return result
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Catalog Services Module
|
|
3
|
+
|
|
4
|
+
This module provides services for managing catalog data including subscription plans
|
|
5
|
+
and user type templates stored in Firestore.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .catalog_subscriptionplan_service import CatalogSubscriptionPlanService
|
|
9
|
+
from .catalog_usertype_service import CatalogUserTypeService
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"CatalogSubscriptionPlanService",
|
|
13
|
+
"CatalogUserTypeService",
|
|
14
|
+
]
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Subscription Plan Catalog Service
|
|
3
|
+
|
|
4
|
+
This service manages subscription plan templates stored in Firestore.
|
|
5
|
+
These templates are used to configure and create user subscriptions consistently.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Dict, List, Optional, Any
|
|
10
|
+
from google.cloud import firestore
|
|
11
|
+
from google.cloud.firestore import Client
|
|
12
|
+
from ipulse_shared_base_ftredge import SubscriptionPlanName
|
|
13
|
+
from ipulse_shared_base_ftredge.enums.enums_status import ObjectOverallStatus
|
|
14
|
+
from ipulse_shared_core_ftredge.models.catalog.subscriptionplan import SubscriptionPlan
|
|
15
|
+
from ipulse_shared_core_ftredge.services.base.base_firestore_service import BaseFirestoreService
|
|
16
|
+
from ipulse_shared_core_ftredge.exceptions import ServiceError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CatalogSubscriptionPlanService(BaseFirestoreService[SubscriptionPlan]):
|
|
20
|
+
"""
|
|
21
|
+
Service for managing subscription plan catalog configurations.
|
|
22
|
+
|
|
23
|
+
This service provides CRUD operations for subscription plan templates that define
|
|
24
|
+
the structure and defaults for user subscriptions.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
firestore_client: Client,
|
|
30
|
+
logger: Optional[logging.Logger] = None
|
|
31
|
+
):
|
|
32
|
+
"""
|
|
33
|
+
Initialize the Subscription Plan Catalog Service.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
firestore_client: Firestore client instance
|
|
37
|
+
logger: Logger instance (optional)
|
|
38
|
+
"""
|
|
39
|
+
super().__init__(
|
|
40
|
+
db=firestore_client,
|
|
41
|
+
collection_name="papp_core_catalog_subscriptionplans",
|
|
42
|
+
resource_type="subscriptionplan",
|
|
43
|
+
model_class=SubscriptionPlan,
|
|
44
|
+
logger=logger or logging.getLogger(__name__)
|
|
45
|
+
)
|
|
46
|
+
self.archive_collection_name = "~archive_papp_core_catalog_subscriptionplans"
|
|
47
|
+
|
|
48
|
+
async def create_subscriptionplan(
|
|
49
|
+
self,
|
|
50
|
+
subscriptionplan_id: str,
|
|
51
|
+
subscription_plan: SubscriptionPlan,
|
|
52
|
+
creator_uid: str
|
|
53
|
+
) -> SubscriptionPlan:
|
|
54
|
+
"""
|
|
55
|
+
Create a new subscription plan.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
subscriptionplan_id: Unique identifier for the plan
|
|
59
|
+
subscription_plan: Subscription plan data
|
|
60
|
+
creator_uid: UID of the user creating the plan
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Created subscription plan
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ServiceError: If creation fails
|
|
67
|
+
ValidationError: If plan data is invalid
|
|
68
|
+
"""
|
|
69
|
+
self.logger.info(f"Creating subscription plan: {subscriptionplan_id}")
|
|
70
|
+
|
|
71
|
+
# Create the document
|
|
72
|
+
created_doc = await self.create_document(
|
|
73
|
+
doc_id=subscriptionplan_id,
|
|
74
|
+
data=subscription_plan,
|
|
75
|
+
creator_uid=creator_uid
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Convert back to model
|
|
79
|
+
result = SubscriptionPlan.model_validate(created_doc)
|
|
80
|
+
self.logger.info(f"Successfully created subscription plan: {subscriptionplan_id}")
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
async def get_subscriptionplan(self, subscriptionplan_id: str) -> Optional[SubscriptionPlan]:
|
|
84
|
+
"""
|
|
85
|
+
Retrieve a subscription plan by ID.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
subscriptionplan_id: Unique identifier for the plan
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Subscription plan if found, None otherwise
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
ServiceError: If retrieval fails
|
|
95
|
+
"""
|
|
96
|
+
self.logger.debug(f"Retrieving subscription plan: {subscriptionplan_id}")
|
|
97
|
+
doc_data = await self.get_document(subscriptionplan_id)
|
|
98
|
+
if doc_data is None:
|
|
99
|
+
return None
|
|
100
|
+
return SubscriptionPlan.model_validate(doc_data) if isinstance(doc_data, dict) else doc_data
|
|
101
|
+
|
|
102
|
+
async def update_subscriptionplan(
|
|
103
|
+
self,
|
|
104
|
+
subscriptionplan_id: str,
|
|
105
|
+
updates: Dict[str, Any],
|
|
106
|
+
updater_uid: str
|
|
107
|
+
) -> SubscriptionPlan:
|
|
108
|
+
"""
|
|
109
|
+
Update a subscription plan.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
subscriptionplan_id: Unique identifier for the plan
|
|
113
|
+
updates: Fields to update
|
|
114
|
+
updater_uid: UID of the user updating the plan
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Updated subscription plan
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
ServiceError: If update fails
|
|
121
|
+
ResourceNotFoundError: If plan not found
|
|
122
|
+
ValidationError: If update data is invalid
|
|
123
|
+
"""
|
|
124
|
+
self.logger.info(f"Updating subscription plan: {subscriptionplan_id}")
|
|
125
|
+
|
|
126
|
+
updated_doc = await self.update_document(
|
|
127
|
+
doc_id=subscriptionplan_id,
|
|
128
|
+
update_data=updates,
|
|
129
|
+
updater_uid=updater_uid
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
result = SubscriptionPlan.model_validate(updated_doc)
|
|
133
|
+
self.logger.info(f"Successfully updated subscription plan: {subscriptionplan_id}")
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
async def delete_subscriptionplan(
|
|
137
|
+
self,
|
|
138
|
+
subscriptionplan_id: str,
|
|
139
|
+
archive: bool = True
|
|
140
|
+
) -> bool:
|
|
141
|
+
"""
|
|
142
|
+
Delete a subscription plan.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
subscriptionplan_id: Unique identifier for the plan
|
|
146
|
+
archive: Whether to archive the plan before deletion
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
True if deletion was successful
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
ServiceError: If deletion fails
|
|
153
|
+
ResourceNotFoundError: If plan not found
|
|
154
|
+
"""
|
|
155
|
+
self.logger.info(f"Deleting subscription plan: {subscriptionplan_id}")
|
|
156
|
+
|
|
157
|
+
if archive:
|
|
158
|
+
# Get the plan data before deletion for archiving
|
|
159
|
+
template = await self.get_subscriptionplan(subscriptionplan_id)
|
|
160
|
+
if template:
|
|
161
|
+
await self.archive_document(
|
|
162
|
+
document_data=template.model_dump(),
|
|
163
|
+
doc_id=subscriptionplan_id,
|
|
164
|
+
archive_collection=self.archive_collection_name,
|
|
165
|
+
archived_by="system"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
result = await self.delete_document(subscriptionplan_id)
|
|
169
|
+
self.logger.info(f"Successfully deleted subscription plan: {subscriptionplan_id}")
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
async def list_subscriptionplans(
|
|
173
|
+
self,
|
|
174
|
+
plan_name: Optional[SubscriptionPlanName] = None,
|
|
175
|
+
pulse_status: Optional[ObjectOverallStatus] = None,
|
|
176
|
+
latest_version_only: bool = False,
|
|
177
|
+
limit: Optional[int] = None
|
|
178
|
+
) -> List[SubscriptionPlan]:
|
|
179
|
+
"""
|
|
180
|
+
List subscription plans with optional filtering.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
plan_name: Filter by specific plan name (FREE, BASE, PREMIUM)
|
|
184
|
+
pulse_status: Filter by specific pulse status
|
|
185
|
+
latest_version_only: Only return the latest version per plan
|
|
186
|
+
limit: Maximum number of plans to return
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
List of subscription plans
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
ServiceError: If listing fails
|
|
193
|
+
"""
|
|
194
|
+
self.logger.debug(f"Listing subscription plans - plan_name: {plan_name}, pulse_status: {pulse_status}, latest_version_only: {latest_version_only}")
|
|
195
|
+
|
|
196
|
+
# Build query filters
|
|
197
|
+
filters = []
|
|
198
|
+
if plan_name:
|
|
199
|
+
filters.append(("plan_name", "==", plan_name.value))
|
|
200
|
+
if pulse_status:
|
|
201
|
+
filters.append(("pulse_status", "==", pulse_status.value))
|
|
202
|
+
|
|
203
|
+
# If latest_version_only is requested, order by version descending
|
|
204
|
+
order_by = None
|
|
205
|
+
if latest_version_only:
|
|
206
|
+
order_by = "plan_version" # Use plan_version field name
|
|
207
|
+
|
|
208
|
+
docs = await self.list_documents(
|
|
209
|
+
filters=filters,
|
|
210
|
+
order_by=order_by,
|
|
211
|
+
limit=limit
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Convert to SubscriptionPlan models
|
|
215
|
+
plans = [SubscriptionPlan.model_validate(doc) if isinstance(doc, dict) else doc for doc in docs]
|
|
216
|
+
|
|
217
|
+
# If latest_version_only is requested, group by plan_name and get highest version
|
|
218
|
+
if latest_version_only:
|
|
219
|
+
# Group by plan_name
|
|
220
|
+
plan_groups = {}
|
|
221
|
+
for plan in plans:
|
|
222
|
+
key = plan.plan_name.value
|
|
223
|
+
if key not in plan_groups:
|
|
224
|
+
plan_groups[key] = []
|
|
225
|
+
plan_groups[key].append(plan)
|
|
226
|
+
|
|
227
|
+
# Get the latest version for each group
|
|
228
|
+
latest_plans = []
|
|
229
|
+
for group in plan_groups.values():
|
|
230
|
+
if group:
|
|
231
|
+
# Sort by plan_version descending and take the first
|
|
232
|
+
latest = max(group, key=lambda x: x.plan_version)
|
|
233
|
+
latest_plans.append(latest)
|
|
234
|
+
|
|
235
|
+
return latest_plans
|
|
236
|
+
|
|
237
|
+
return plans
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _get_collection(self):
|
|
241
|
+
"""Get the Firestore collection reference."""
|
|
242
|
+
return self.db.collection(self.collection_name)
|
|
243
|
+
|
|
244
|
+
async def subscriptionplan_exists(self, subscriptionplan_id: str) -> bool:
|
|
245
|
+
"""
|
|
246
|
+
Check if a subscription plan exists.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
subscriptionplan_id: Unique identifier for the plan
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
True if plan exists, False otherwise
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
ServiceError: If check fails
|
|
256
|
+
"""
|
|
257
|
+
return await self.document_exists(subscriptionplan_id)
|
|
258
|
+
|
|
259
|
+
async def validate_subscriptionplan_data(self, subscriptionplan_data: Dict[str, Any]) -> tuple[bool, List[str]]:
|
|
260
|
+
"""
|
|
261
|
+
Validate subscription plan data.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
subscriptionplan_data: Plan data to validate
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Tuple of (is_valid, list_of_errors)
|
|
268
|
+
"""
|
|
269
|
+
try:
|
|
270
|
+
SubscriptionPlan.model_validate(subscriptionplan_data)
|
|
271
|
+
return True, []
|
|
272
|
+
except Exception as e:
|
|
273
|
+
return False, [str(e)]
|