geek-cafe-saas-sdk 0.6.0__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 geek-cafe-saas-sdk might be problematic. Click here for more details.
- geek_cafe_saas_sdk/__init__.py +9 -0
- geek_cafe_saas_sdk/core/__init__.py +11 -0
- geek_cafe_saas_sdk/core/audit_mixin.py +33 -0
- geek_cafe_saas_sdk/core/error_codes.py +132 -0
- geek_cafe_saas_sdk/core/service_errors.py +19 -0
- geek_cafe_saas_sdk/core/service_result.py +121 -0
- geek_cafe_saas_sdk/decorators/__init__.py +64 -0
- geek_cafe_saas_sdk/decorators/auth.py +373 -0
- geek_cafe_saas_sdk/decorators/core.py +358 -0
- geek_cafe_saas_sdk/domains/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/analytics/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/analytics/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/analytics/models/__init__.py +9 -0
- geek_cafe_saas_sdk/domains/analytics/models/website_analytics.py +219 -0
- geek_cafe_saas_sdk/domains/analytics/models/website_analytics_summary.py +220 -0
- geek_cafe_saas_sdk/domains/analytics/services/__init__.py +11 -0
- geek_cafe_saas_sdk/domains/analytics/services/website_analytics_service.py +232 -0
- geek_cafe_saas_sdk/domains/analytics/services/website_analytics_summary_service.py +212 -0
- geek_cafe_saas_sdk/domains/analytics/services/website_analytics_tally_service.py +610 -0
- geek_cafe_saas_sdk/domains/auth/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/auth/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/create/app.py +41 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/delete/app.py +41 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/list/app.py +36 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/auth/models/__init__.py +13 -0
- geek_cafe_saas_sdk/domains/auth/models/permission.py +134 -0
- geek_cafe_saas_sdk/domains/auth/models/resource_permission.py +245 -0
- geek_cafe_saas_sdk/domains/auth/models/role.py +213 -0
- geek_cafe_saas_sdk/domains/auth/models/user.py +285 -0
- geek_cafe_saas_sdk/domains/auth/services/__init__.py +16 -0
- geek_cafe_saas_sdk/domains/auth/services/authorization_service.py +376 -0
- geek_cafe_saas_sdk/domains/auth/services/permission_registry.py +464 -0
- geek_cafe_saas_sdk/domains/auth/services/resource_permission_service.py +408 -0
- geek_cafe_saas_sdk/domains/auth/services/user_service.py +274 -0
- geek_cafe_saas_sdk/domains/communities/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/communities/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/create/app.py +41 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/delete/app.py +41 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/list/app.py +36 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/communities/models/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/communities/models/community.py +326 -0
- geek_cafe_saas_sdk/domains/communities/models/community_member.py +227 -0
- geek_cafe_saas_sdk/domains/communities/services/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/communities/services/community_member_service.py +412 -0
- geek_cafe_saas_sdk/domains/communities/services/community_service.py +479 -0
- geek_cafe_saas_sdk/domains/events/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/events/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/events/handlers/attendees/app.py +67 -0
- geek_cafe_saas_sdk/domains/events/handlers/cancel/app.py +66 -0
- geek_cafe_saas_sdk/domains/events/handlers/check_in/app.py +60 -0
- geek_cafe_saas_sdk/domains/events/handlers/create/app.py +93 -0
- geek_cafe_saas_sdk/domains/events/handlers/delete/app.py +42 -0
- geek_cafe_saas_sdk/domains/events/handlers/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/events/handlers/invite/app.py +98 -0
- geek_cafe_saas_sdk/domains/events/handlers/list/app.py +125 -0
- geek_cafe_saas_sdk/domains/events/handlers/publish/app.py +49 -0
- geek_cafe_saas_sdk/domains/events/handlers/rsvp/app.py +83 -0
- geek_cafe_saas_sdk/domains/events/handlers/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/events/models/__init__.py +3 -0
- geek_cafe_saas_sdk/domains/events/models/event.py +681 -0
- geek_cafe_saas_sdk/domains/events/models/event_attendee.py +324 -0
- geek_cafe_saas_sdk/domains/events/services/__init__.py +9 -0
- geek_cafe_saas_sdk/domains/events/services/event_attendee_service.py +571 -0
- geek_cafe_saas_sdk/domains/events/services/event_service.py +684 -0
- geek_cafe_saas_sdk/domains/files/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/files/models/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/files/models/directory.py +258 -0
- geek_cafe_saas_sdk/domains/files/models/file.py +312 -0
- geek_cafe_saas_sdk/domains/files/models/file_share.py +268 -0
- geek_cafe_saas_sdk/domains/files/models/file_version.py +216 -0
- geek_cafe_saas_sdk/domains/files/services/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/files/services/directory_service.py +701 -0
- geek_cafe_saas_sdk/domains/files/services/file_share_service.py +663 -0
- geek_cafe_saas_sdk/domains/files/services/file_system_service.py +575 -0
- geek_cafe_saas_sdk/domains/files/services/file_version_service.py +739 -0
- geek_cafe_saas_sdk/domains/files/services/s3_file_service.py +501 -0
- geek_cafe_saas_sdk/domains/messaging/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/create/app.py +86 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/delete/app.py +65 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/get/app.py +64 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/list/app.py +97 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/update/app.py +149 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/create/app.py +67 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/delete/app.py +65 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/get/app.py +64 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/list/app.py +102 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/update/app.py +127 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/create/app.py +94 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/delete/app.py +66 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/get/app.py +67 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/list/app.py +95 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/update/app.py +156 -0
- geek_cafe_saas_sdk/domains/messaging/models/__init__.py +13 -0
- geek_cafe_saas_sdk/domains/messaging/models/chat_channel.py +337 -0
- geek_cafe_saas_sdk/domains/messaging/models/chat_channel_member.py +180 -0
- geek_cafe_saas_sdk/domains/messaging/models/chat_message.py +426 -0
- geek_cafe_saas_sdk/domains/messaging/models/contact_thread.py +392 -0
- geek_cafe_saas_sdk/domains/messaging/services/__init__.py +11 -0
- geek_cafe_saas_sdk/domains/messaging/services/chat_channel_service.py +700 -0
- geek_cafe_saas_sdk/domains/messaging/services/chat_message_service.py +491 -0
- geek_cafe_saas_sdk/domains/messaging/services/contact_thread_service.py +497 -0
- geek_cafe_saas_sdk/domains/tenancy/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/activate/app.py +52 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/active/app.py +37 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/cancel/app.py +55 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/list/app.py +44 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/record_payment/app.py +56 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/me/app.py +37 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/signup/app.py +61 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/tenancy/models/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/tenancy/models/subscription.py +440 -0
- geek_cafe_saas_sdk/domains/tenancy/models/tenant.py +258 -0
- geek_cafe_saas_sdk/domains/tenancy/services/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/tenancy/services/subscription_service.py +557 -0
- geek_cafe_saas_sdk/domains/tenancy/services/tenant_service.py +575 -0
- geek_cafe_saas_sdk/domains/voting/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/voting/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/create/app.py +128 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/delete/app.py +41 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/list/app.py +38 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/summerize/README.md +3 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/voting/models/__init__.py +9 -0
- geek_cafe_saas_sdk/domains/voting/models/vote.py +231 -0
- geek_cafe_saas_sdk/domains/voting/models/vote_summary.py +193 -0
- geek_cafe_saas_sdk/domains/voting/services/__init__.py +11 -0
- geek_cafe_saas_sdk/domains/voting/services/vote_service.py +264 -0
- geek_cafe_saas_sdk/domains/voting/services/vote_summary_service.py +198 -0
- geek_cafe_saas_sdk/domains/voting/services/vote_tally_service.py +533 -0
- geek_cafe_saas_sdk/lambda_handlers/README.md +404 -0
- geek_cafe_saas_sdk/lambda_handlers/__init__.py +67 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/__init__.py +25 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/api_key_handler.py +129 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/authorized_secure_handler.py +218 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/base_handler.py +185 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/handler_factory.py +256 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/public_handler.py +53 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/secure_handler.py +89 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/service_pool.py +94 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/create/app.py +79 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/delete/app.py +76 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/get/app.py +74 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/list/app.py +75 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/move/app.py +79 -0
- geek_cafe_saas_sdk/lambda_handlers/files/delete/app.py +121 -0
- geek_cafe_saas_sdk/lambda_handlers/files/download/app.py +187 -0
- geek_cafe_saas_sdk/lambda_handlers/files/get/app.py +127 -0
- geek_cafe_saas_sdk/lambda_handlers/files/list/app.py +108 -0
- geek_cafe_saas_sdk/lambda_handlers/files/share/app.py +83 -0
- geek_cafe_saas_sdk/lambda_handlers/files/shares/list/app.py +84 -0
- geek_cafe_saas_sdk/lambda_handlers/files/shares/revoke/app.py +76 -0
- geek_cafe_saas_sdk/lambda_handlers/files/update/app.py +143 -0
- geek_cafe_saas_sdk/lambda_handlers/files/upload/app.py +151 -0
- geek_cafe_saas_sdk/middleware/__init__.py +36 -0
- geek_cafe_saas_sdk/middleware/auth.py +85 -0
- geek_cafe_saas_sdk/middleware/authorization.py +523 -0
- geek_cafe_saas_sdk/middleware/cors.py +63 -0
- geek_cafe_saas_sdk/middleware/error_handling.py +114 -0
- geek_cafe_saas_sdk/middleware/validation.py +80 -0
- geek_cafe_saas_sdk/models/__init__.py +20 -0
- geek_cafe_saas_sdk/models/base_model.py +233 -0
- geek_cafe_saas_sdk/services/__init__.py +18 -0
- geek_cafe_saas_sdk/services/database_service.py +441 -0
- geek_cafe_saas_sdk/utilities/__init__.py +88 -0
- geek_cafe_saas_sdk/utilities/cognito_utility.py +568 -0
- geek_cafe_saas_sdk/utilities/custom_exceptions.py +183 -0
- geek_cafe_saas_sdk/utilities/datetime_utility.py +410 -0
- geek_cafe_saas_sdk/utilities/dictionary_utility.py +78 -0
- geek_cafe_saas_sdk/utilities/dynamodb_utils.py +151 -0
- geek_cafe_saas_sdk/utilities/environment_loader.py +149 -0
- geek_cafe_saas_sdk/utilities/environment_variables.py +228 -0
- geek_cafe_saas_sdk/utilities/http_body_parameters.py +44 -0
- geek_cafe_saas_sdk/utilities/http_path_parameters.py +60 -0
- geek_cafe_saas_sdk/utilities/http_status_code.py +63 -0
- geek_cafe_saas_sdk/utilities/jwt_utility.py +234 -0
- geek_cafe_saas_sdk/utilities/lambda_event_utility.py +776 -0
- geek_cafe_saas_sdk/utilities/logging_utility.py +64 -0
- geek_cafe_saas_sdk/utilities/message_query_helper.py +340 -0
- geek_cafe_saas_sdk/utilities/response.py +209 -0
- geek_cafe_saas_sdk/utilities/string_functions.py +180 -0
- geek_cafe_saas_sdk-0.6.0.dist-info/METADATA +397 -0
- geek_cafe_saas_sdk-0.6.0.dist-info/RECORD +194 -0
- geek_cafe_saas_sdk-0.6.0.dist-info/WHEEL +4 -0
- geek_cafe_saas_sdk-0.6.0.dist-info/licenses/LICENSE +47 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FileShareService for permission-based file sharing.
|
|
3
|
+
|
|
4
|
+
Geek Cafe, LLC
|
|
5
|
+
MIT License. See Project Root for the license information.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Dict, Any, Optional, List
|
|
9
|
+
from boto3.dynamodb.conditions import Key
|
|
10
|
+
from boto3_assist.dynamodb.dynamodb import DynamoDB
|
|
11
|
+
from geek_cafe_saas_sdk.services.database_service import DatabaseService
|
|
12
|
+
from geek_cafe_saas_sdk.core.service_result import ServiceResult
|
|
13
|
+
from geek_cafe_saas_sdk.core.service_errors import ValidationError, NotFoundError, AccessDeniedError
|
|
14
|
+
from geek_cafe_saas_sdk.core.error_codes import ErrorCode
|
|
15
|
+
from geek_cafe_saas_sdk.domains.files.models.file_share import FileShare
|
|
16
|
+
from geek_cafe_saas_sdk.domains.files.models.file import File
|
|
17
|
+
import datetime as dt
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FileShareService(DatabaseService[FileShare]):
|
|
21
|
+
"""
|
|
22
|
+
File share service for permission-based file sharing.
|
|
23
|
+
|
|
24
|
+
Handles:
|
|
25
|
+
- Creating file shares with permissions
|
|
26
|
+
- Access validation
|
|
27
|
+
- Share expiration
|
|
28
|
+
- Share revocation
|
|
29
|
+
- Permission management (view, download, edit)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def create(
|
|
33
|
+
self,
|
|
34
|
+
tenant_id: str,
|
|
35
|
+
user_id: str,
|
|
36
|
+
file_id: str,
|
|
37
|
+
shared_with_user_id: str,
|
|
38
|
+
permission: str = "view",
|
|
39
|
+
expires_at: Optional[float] = None,
|
|
40
|
+
message: Optional[str] = None,
|
|
41
|
+
**kwargs
|
|
42
|
+
) -> ServiceResult[FileShare]:
|
|
43
|
+
"""
|
|
44
|
+
Create a file share.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
tenant_id: Tenant ID
|
|
48
|
+
user_id: User ID (file owner creating the share)
|
|
49
|
+
file_id: File ID to share
|
|
50
|
+
shared_with_user_id: User ID to share with
|
|
51
|
+
permission: Permission level (view, download, edit)
|
|
52
|
+
expires_at: Optional expiration timestamp
|
|
53
|
+
message: Optional message for recipient
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
ServiceResult with FileShare model
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
# Validate permission
|
|
60
|
+
valid_permissions = ["view", "download", "edit"]
|
|
61
|
+
if permission not in valid_permissions:
|
|
62
|
+
raise ValidationError(
|
|
63
|
+
f"Invalid permission: {permission}. Must be one of {valid_permissions}",
|
|
64
|
+
"permission"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Cannot share with self
|
|
68
|
+
if shared_with_user_id == user_id:
|
|
69
|
+
raise ValidationError(
|
|
70
|
+
"Cannot share file with yourself",
|
|
71
|
+
"shared_with_user_id"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Get the file to verify ownership
|
|
75
|
+
file_result = self._get_file(tenant_id, file_id, user_id)
|
|
76
|
+
if not file_result.success:
|
|
77
|
+
return ServiceResult.error_result(
|
|
78
|
+
message="File not found or you do not have permission to share it",
|
|
79
|
+
error_code=ErrorCode.ACCESS_DENIED
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
file = file_result.data
|
|
83
|
+
|
|
84
|
+
# Only owner can share
|
|
85
|
+
if file.owner_id != user_id:
|
|
86
|
+
raise AccessDeniedError("Only the file owner can share this file")
|
|
87
|
+
|
|
88
|
+
# Check for existing share
|
|
89
|
+
existing_share = self._get_existing_share(
|
|
90
|
+
tenant_id, file_id, shared_with_user_id
|
|
91
|
+
)
|
|
92
|
+
if existing_share:
|
|
93
|
+
raise ValidationError(
|
|
94
|
+
"File is already shared with this user",
|
|
95
|
+
"shared_with_user_id"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Create FileShare model
|
|
99
|
+
share = FileShare()
|
|
100
|
+
share.prep_for_save()
|
|
101
|
+
share.tenant_id = tenant_id
|
|
102
|
+
share.file_id = file_id
|
|
103
|
+
share.shared_by = user_id
|
|
104
|
+
share.shared_with_user_id = shared_with_user_id
|
|
105
|
+
share.permission_level = permission
|
|
106
|
+
share.expires_at = expires_at
|
|
107
|
+
share.message = message
|
|
108
|
+
share.status = "active"
|
|
109
|
+
share.access_count = 0
|
|
110
|
+
|
|
111
|
+
# Save to DynamoDB
|
|
112
|
+
pk = f"FILE#{tenant_id}#{file_id}"
|
|
113
|
+
sk = f"SHARE#{share.share_id}"
|
|
114
|
+
|
|
115
|
+
item = share.to_dictionary()
|
|
116
|
+
item["pk"] = pk
|
|
117
|
+
item["sk"] = sk
|
|
118
|
+
|
|
119
|
+
# GSI1: Shares by file
|
|
120
|
+
item["gsi1_pk"] = f"FILE#{tenant_id}#{file_id}"
|
|
121
|
+
item["gsi1_sk"] = f"USER#{shared_with_user_id}"
|
|
122
|
+
|
|
123
|
+
# GSI2: Shares with user
|
|
124
|
+
item["gsi2_pk"] = f"TENANT#{tenant_id}#USER#{shared_with_user_id}"
|
|
125
|
+
item["gsi2_sk"] = f"FILE#{file_id}#{share.created_utc_ts}"
|
|
126
|
+
|
|
127
|
+
self.dynamodb.save(
|
|
128
|
+
item=item,
|
|
129
|
+
table_name=self.table_name
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return ServiceResult.success_result(share)
|
|
133
|
+
|
|
134
|
+
except (ValidationError, AccessDeniedError) as e:
|
|
135
|
+
return ServiceResult.error_result(
|
|
136
|
+
message=str(e),
|
|
137
|
+
error_code=ErrorCode.VALIDATION_ERROR if isinstance(e, ValidationError) else ErrorCode.ACCESS_DENIED
|
|
138
|
+
)
|
|
139
|
+
except Exception as e:
|
|
140
|
+
return ServiceResult.exception_result(
|
|
141
|
+
exception=e,
|
|
142
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
143
|
+
context="FileShareService.create"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def get_by_id(
|
|
147
|
+
self,
|
|
148
|
+
resource_id: str,
|
|
149
|
+
tenant_id: str,
|
|
150
|
+
user_id: str,
|
|
151
|
+
file_id: Optional[str] = None
|
|
152
|
+
) -> ServiceResult[FileShare]:
|
|
153
|
+
"""
|
|
154
|
+
Get share by ID.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
resource_id: Share ID
|
|
158
|
+
tenant_id: Tenant ID
|
|
159
|
+
user_id: User ID (for access control)
|
|
160
|
+
file_id: File ID (required)
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
ServiceResult with FileShare model
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
if not file_id:
|
|
167
|
+
raise ValidationError("file_id is required", "file_id")
|
|
168
|
+
|
|
169
|
+
pk = f"FILE#{tenant_id}#{file_id}"
|
|
170
|
+
sk = f"SHARE#{resource_id}"
|
|
171
|
+
|
|
172
|
+
result = self.dynamodb.get(
|
|
173
|
+
table_name=self.table_name,
|
|
174
|
+
key={"pk": pk, "sk": sk}
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if not result or 'Item' not in result:
|
|
178
|
+
raise NotFoundError(f"Share not found: {resource_id}")
|
|
179
|
+
|
|
180
|
+
share = FileShare()
|
|
181
|
+
share.map(result['Item'])
|
|
182
|
+
|
|
183
|
+
# Access control: user must be sharer or sharee
|
|
184
|
+
if share.shared_by != user_id and share.shared_with_user_id != user_id:
|
|
185
|
+
raise AccessDeniedError("You do not have access to this share")
|
|
186
|
+
|
|
187
|
+
return ServiceResult.success_result(share)
|
|
188
|
+
|
|
189
|
+
except (NotFoundError, ValidationError) as e:
|
|
190
|
+
return ServiceResult.error_result(
|
|
191
|
+
message=str(e),
|
|
192
|
+
error_code=ErrorCode.NOT_FOUND if isinstance(e, NotFoundError) else ErrorCode.VALIDATION_ERROR
|
|
193
|
+
)
|
|
194
|
+
except AccessDeniedError as e:
|
|
195
|
+
return ServiceResult.error_result(
|
|
196
|
+
message=str(e),
|
|
197
|
+
error_code=ErrorCode.ACCESS_DENIED
|
|
198
|
+
)
|
|
199
|
+
except Exception as e:
|
|
200
|
+
return ServiceResult.exception_result(
|
|
201
|
+
exception=e,
|
|
202
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
203
|
+
context="FileShareService.get_by_id"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def update(
|
|
207
|
+
self,
|
|
208
|
+
resource_id: str,
|
|
209
|
+
tenant_id: str,
|
|
210
|
+
user_id: str,
|
|
211
|
+
updates: Dict[str, Any]
|
|
212
|
+
) -> ServiceResult[FileShare]:
|
|
213
|
+
"""
|
|
214
|
+
Update share (permission or expiration).
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
resource_id: Share ID
|
|
218
|
+
tenant_id: Tenant ID
|
|
219
|
+
user_id: User ID (must be sharer)
|
|
220
|
+
updates: Dictionary of fields to update
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
ServiceResult with updated FileShare model
|
|
224
|
+
"""
|
|
225
|
+
try:
|
|
226
|
+
file_id = updates.get('file_id')
|
|
227
|
+
if not file_id:
|
|
228
|
+
raise ValidationError("file_id is required in updates", "file_id")
|
|
229
|
+
|
|
230
|
+
# Get existing share
|
|
231
|
+
get_result = self.get_by_id(resource_id, tenant_id, user_id, file_id=file_id)
|
|
232
|
+
if not get_result.success:
|
|
233
|
+
return get_result
|
|
234
|
+
|
|
235
|
+
share = get_result.data
|
|
236
|
+
|
|
237
|
+
# Only sharer can update
|
|
238
|
+
if share.shared_by != user_id:
|
|
239
|
+
raise AccessDeniedError("Only the person who shared can update this share")
|
|
240
|
+
|
|
241
|
+
# Apply updates (only allowed fields)
|
|
242
|
+
allowed_fields = ["permission_level", "expires_at", "message"]
|
|
243
|
+
|
|
244
|
+
for field, value in updates.items():
|
|
245
|
+
if field == "permission_level":
|
|
246
|
+
valid_permissions = ["view", "download", "edit"]
|
|
247
|
+
if value not in valid_permissions:
|
|
248
|
+
raise ValidationError(
|
|
249
|
+
f"Invalid permission: {value}",
|
|
250
|
+
"permission_level"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if field in allowed_fields:
|
|
254
|
+
setattr(share, field, value)
|
|
255
|
+
|
|
256
|
+
share.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
257
|
+
|
|
258
|
+
# Save to DynamoDB
|
|
259
|
+
pk = f"FILE#{tenant_id}#{file_id}"
|
|
260
|
+
sk = f"SHARE#{resource_id}"
|
|
261
|
+
|
|
262
|
+
item = share.to_dictionary()
|
|
263
|
+
item["pk"] = pk
|
|
264
|
+
item["sk"] = sk
|
|
265
|
+
|
|
266
|
+
# Preserve GSI keys
|
|
267
|
+
item["gsi1_pk"] = f"FILE#{tenant_id}#{file_id}"
|
|
268
|
+
item["gsi1_sk"] = f"USER#{share.shared_with_user_id}"
|
|
269
|
+
item["gsi2_pk"] = f"TENANT#{tenant_id}#USER#{share.shared_with_user_id}"
|
|
270
|
+
item["gsi2_sk"] = f"FILE#{file_id}#{share.created_utc_ts}"
|
|
271
|
+
|
|
272
|
+
self.dynamodb.save(
|
|
273
|
+
item=item,
|
|
274
|
+
table_name=self.table_name
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return ServiceResult.success_result(share)
|
|
278
|
+
|
|
279
|
+
except (ValidationError, AccessDeniedError) as e:
|
|
280
|
+
return ServiceResult.error_result(
|
|
281
|
+
message=str(e),
|
|
282
|
+
error_code=ErrorCode.VALIDATION_ERROR if isinstance(e, ValidationError) else ErrorCode.ACCESS_DENIED
|
|
283
|
+
)
|
|
284
|
+
except Exception as e:
|
|
285
|
+
return ServiceResult.exception_result(
|
|
286
|
+
exception=e,
|
|
287
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
288
|
+
context="FileShareService.update"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def delete(
|
|
292
|
+
self,
|
|
293
|
+
resource_id: str,
|
|
294
|
+
tenant_id: str,
|
|
295
|
+
user_id: str,
|
|
296
|
+
file_id: str
|
|
297
|
+
) -> ServiceResult[bool]:
|
|
298
|
+
"""
|
|
299
|
+
Revoke a share.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
resource_id: Share ID
|
|
303
|
+
tenant_id: Tenant ID
|
|
304
|
+
user_id: User ID (must be sharer)
|
|
305
|
+
file_id: File ID
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
ServiceResult with success boolean
|
|
309
|
+
"""
|
|
310
|
+
try:
|
|
311
|
+
# Get existing share
|
|
312
|
+
get_result = self.get_by_id(resource_id, tenant_id, user_id, file_id=file_id)
|
|
313
|
+
if not get_result.success:
|
|
314
|
+
return get_result
|
|
315
|
+
|
|
316
|
+
share = get_result.data
|
|
317
|
+
|
|
318
|
+
# Only sharer can revoke
|
|
319
|
+
if share.shared_by != user_id:
|
|
320
|
+
raise AccessDeniedError("Only the person who shared can revoke this share")
|
|
321
|
+
|
|
322
|
+
# Mark as revoked
|
|
323
|
+
share.status = "revoked"
|
|
324
|
+
share.revoked_at = dt.datetime.now(dt.UTC).timestamp()
|
|
325
|
+
|
|
326
|
+
pk = f"FILE#{tenant_id}#{file_id}"
|
|
327
|
+
sk = f"SHARE#{resource_id}"
|
|
328
|
+
|
|
329
|
+
item = share.to_dictionary()
|
|
330
|
+
item["pk"] = pk
|
|
331
|
+
item["sk"] = sk
|
|
332
|
+
|
|
333
|
+
# Preserve GSI keys
|
|
334
|
+
item["gsi1_pk"] = f"FILE#{tenant_id}#{file_id}"
|
|
335
|
+
item["gsi1_sk"] = f"USER#{share.shared_with_user_id}"
|
|
336
|
+
item["gsi2_pk"] = f"TENANT#{tenant_id}#USER#{share.shared_with_user_id}"
|
|
337
|
+
item["gsi2_sk"] = f"FILE#{file_id}#{share.created_utc_ts}"
|
|
338
|
+
|
|
339
|
+
self.dynamodb.save(
|
|
340
|
+
item=item,
|
|
341
|
+
table_name=self.table_name
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
return ServiceResult.success_result(True)
|
|
345
|
+
|
|
346
|
+
except AccessDeniedError as e:
|
|
347
|
+
return ServiceResult.error_result(
|
|
348
|
+
message=str(e),
|
|
349
|
+
error_code=ErrorCode.ACCESS_DENIED
|
|
350
|
+
)
|
|
351
|
+
except Exception as e:
|
|
352
|
+
return ServiceResult.exception_result(
|
|
353
|
+
exception=e,
|
|
354
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
355
|
+
context="FileShareService.delete"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def list_shares_by_file(
|
|
359
|
+
self,
|
|
360
|
+
tenant_id: str,
|
|
361
|
+
file_id: str,
|
|
362
|
+
user_id: str,
|
|
363
|
+
limit: int = 50
|
|
364
|
+
) -> ServiceResult[List[FileShare]]:
|
|
365
|
+
"""
|
|
366
|
+
List all shares for a file.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
tenant_id: Tenant ID
|
|
370
|
+
file_id: File ID
|
|
371
|
+
user_id: User ID (must be file owner)
|
|
372
|
+
limit: Maximum number of results
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
ServiceResult with list of FileShare models
|
|
376
|
+
"""
|
|
377
|
+
try:
|
|
378
|
+
# Verify user owns the file
|
|
379
|
+
file_result = self._get_file(tenant_id, file_id, user_id)
|
|
380
|
+
if not file_result.success:
|
|
381
|
+
return ServiceResult.error_result(
|
|
382
|
+
message="File not found or access denied",
|
|
383
|
+
error_code=ErrorCode.ACCESS_DENIED
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# Query GSI1
|
|
387
|
+
gsi1_pk = f"FILE#{tenant_id}#{file_id}"
|
|
388
|
+
|
|
389
|
+
results = self.dynamodb.query(
|
|
390
|
+
key=Key('gsi1_pk').eq(gsi1_pk) & Key('gsi1_sk').begins_with("USER#"),
|
|
391
|
+
table_name=self.table_name,
|
|
392
|
+
index_name="gsi1",
|
|
393
|
+
limit=limit
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
shares = []
|
|
397
|
+
for item in results.get('Items', []):
|
|
398
|
+
share = FileShare()
|
|
399
|
+
share.map(item)
|
|
400
|
+
|
|
401
|
+
# Include active and expired shares, exclude revoked
|
|
402
|
+
if share.status != "revoked":
|
|
403
|
+
shares.append(share)
|
|
404
|
+
|
|
405
|
+
return ServiceResult.success_result(shares)
|
|
406
|
+
|
|
407
|
+
except Exception as e:
|
|
408
|
+
return ServiceResult.exception_result(
|
|
409
|
+
exception=e,
|
|
410
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
411
|
+
context="FileShareService.list_shares_by_file"
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
def list_shares_with_user(
|
|
415
|
+
self,
|
|
416
|
+
tenant_id: str,
|
|
417
|
+
user_id: str,
|
|
418
|
+
limit: int = 50
|
|
419
|
+
) -> ServiceResult[List[FileShare]]:
|
|
420
|
+
"""
|
|
421
|
+
List all files shared with a user.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
tenant_id: Tenant ID
|
|
425
|
+
user_id: User ID
|
|
426
|
+
limit: Maximum number of results
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
ServiceResult with list of FileShare models
|
|
430
|
+
"""
|
|
431
|
+
try:
|
|
432
|
+
# Query GSI2
|
|
433
|
+
gsi2_pk = f"TENANT#{tenant_id}#USER#{user_id}"
|
|
434
|
+
|
|
435
|
+
results = self.dynamodb.query(
|
|
436
|
+
key=Key('gsi2_pk').eq(gsi2_pk) & Key('gsi2_sk').begins_with("FILE#"),
|
|
437
|
+
table_name=self.table_name,
|
|
438
|
+
index_name="gsi2",
|
|
439
|
+
limit=limit
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
shares = []
|
|
443
|
+
for item in results.get('Items', []):
|
|
444
|
+
share = FileShare()
|
|
445
|
+
share.map(item)
|
|
446
|
+
|
|
447
|
+
# Only include active, non-expired shares
|
|
448
|
+
if share.is_active:
|
|
449
|
+
shares.append(share)
|
|
450
|
+
|
|
451
|
+
return ServiceResult.success_result(shares)
|
|
452
|
+
|
|
453
|
+
except Exception as e:
|
|
454
|
+
return ServiceResult.exception_result(
|
|
455
|
+
exception=e,
|
|
456
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
457
|
+
context="FileShareService.list_shares_with_user"
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def check_access(
|
|
461
|
+
self,
|
|
462
|
+
tenant_id: str,
|
|
463
|
+
file_id: str,
|
|
464
|
+
user_id: str,
|
|
465
|
+
required_permission: str = "view"
|
|
466
|
+
) -> ServiceResult[Dict[str, Any]]:
|
|
467
|
+
"""
|
|
468
|
+
Check if user has access to a file.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
tenant_id: Tenant ID
|
|
472
|
+
file_id: File ID
|
|
473
|
+
user_id: User ID
|
|
474
|
+
required_permission: Required permission level
|
|
475
|
+
|
|
476
|
+
Returns:
|
|
477
|
+
ServiceResult with access info (has_access, permission, reason)
|
|
478
|
+
"""
|
|
479
|
+
try:
|
|
480
|
+
# Get file
|
|
481
|
+
file_result = self._get_file_any_user(tenant_id, file_id)
|
|
482
|
+
if not file_result.success:
|
|
483
|
+
return ServiceResult.success_result({
|
|
484
|
+
"has_access": False,
|
|
485
|
+
"permission": None,
|
|
486
|
+
"reason": "file_not_found"
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
file = file_result.data
|
|
490
|
+
|
|
491
|
+
# Check if user is owner
|
|
492
|
+
if file.owner_id == user_id:
|
|
493
|
+
return ServiceResult.success_result({
|
|
494
|
+
"has_access": True,
|
|
495
|
+
"permission": "owner",
|
|
496
|
+
"reason": "owner"
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
# Check for active share
|
|
500
|
+
share = self._get_existing_share(tenant_id, file_id, user_id)
|
|
501
|
+
if not share:
|
|
502
|
+
return ServiceResult.success_result({
|
|
503
|
+
"has_access": False,
|
|
504
|
+
"permission": None,
|
|
505
|
+
"reason": "no_share"
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
# Check if share is active
|
|
509
|
+
if not share.is_active:
|
|
510
|
+
reason = "expired" if share.is_expired else "revoked"
|
|
511
|
+
return ServiceResult.success_result({
|
|
512
|
+
"has_access": False,
|
|
513
|
+
"permission": None,
|
|
514
|
+
"reason": reason
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
# Check permission level
|
|
518
|
+
permission_levels = {"view": 1, "download": 2, "edit": 3}
|
|
519
|
+
user_level = permission_levels.get(share.permission_level, 0)
|
|
520
|
+
required_level = permission_levels.get(required_permission, 1)
|
|
521
|
+
|
|
522
|
+
has_access = user_level >= required_level
|
|
523
|
+
|
|
524
|
+
# Increment access count if accessing
|
|
525
|
+
if has_access:
|
|
526
|
+
self._increment_access_count(tenant_id, file_id, share.share_id)
|
|
527
|
+
|
|
528
|
+
return ServiceResult.success_result({
|
|
529
|
+
"has_access": has_access,
|
|
530
|
+
"permission": share.permission_level,
|
|
531
|
+
"reason": "granted" if has_access else "insufficient_permission"
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
except Exception as e:
|
|
535
|
+
return ServiceResult.exception_result(
|
|
536
|
+
exception=e,
|
|
537
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
538
|
+
context="FileShareService.check_access"
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
# Helper methods
|
|
542
|
+
|
|
543
|
+
def _get_file(self, tenant_id: str, file_id: str, user_id: str) -> ServiceResult[File]:
|
|
544
|
+
"""Get file with access control."""
|
|
545
|
+
try:
|
|
546
|
+
pk = f"FILE#{tenant_id}#{file_id}"
|
|
547
|
+
sk = "METADATA"
|
|
548
|
+
|
|
549
|
+
result = self.dynamodb.get(
|
|
550
|
+
table_name=self.table_name,
|
|
551
|
+
key={"pk": pk, "sk": sk}
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
if not result or 'Item' not in result:
|
|
555
|
+
raise NotFoundError(f"File not found: {file_id}")
|
|
556
|
+
|
|
557
|
+
file = File()
|
|
558
|
+
file.map(result['Item'])
|
|
559
|
+
|
|
560
|
+
if file.owner_id != user_id:
|
|
561
|
+
raise AccessDeniedError("You do not have access to this file")
|
|
562
|
+
|
|
563
|
+
return ServiceResult.success_result(file)
|
|
564
|
+
|
|
565
|
+
except (NotFoundError, AccessDeniedError) as e:
|
|
566
|
+
return ServiceResult.error_result(
|
|
567
|
+
message=str(e),
|
|
568
|
+
error_code=ErrorCode.NOT_FOUND if isinstance(e, NotFoundError) else ErrorCode.ACCESS_DENIED
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
def _get_file_any_user(self, tenant_id: str, file_id: str) -> ServiceResult[File]:
|
|
572
|
+
"""Get file without access control (for internal use)."""
|
|
573
|
+
try:
|
|
574
|
+
pk = f"FILE#{tenant_id}#{file_id}"
|
|
575
|
+
sk = "METADATA"
|
|
576
|
+
|
|
577
|
+
result = self.dynamodb.get(
|
|
578
|
+
table_name=self.table_name,
|
|
579
|
+
key={"pk": pk, "sk": sk}
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
if not result or 'Item' not in result:
|
|
583
|
+
raise NotFoundError(f"File not found: {file_id}")
|
|
584
|
+
|
|
585
|
+
file = File()
|
|
586
|
+
file.map(result['Item'])
|
|
587
|
+
|
|
588
|
+
return ServiceResult.success_result(file)
|
|
589
|
+
|
|
590
|
+
except NotFoundError as e:
|
|
591
|
+
return ServiceResult.error_result(
|
|
592
|
+
message=str(e),
|
|
593
|
+
error_code=ErrorCode.NOT_FOUND
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
def _get_existing_share(
|
|
597
|
+
self,
|
|
598
|
+
tenant_id: str,
|
|
599
|
+
file_id: str,
|
|
600
|
+
shared_with_user_id: str
|
|
601
|
+
) -> Optional[FileShare]:
|
|
602
|
+
"""Check if share already exists."""
|
|
603
|
+
try:
|
|
604
|
+
gsi1_pk = f"FILE#{tenant_id}#{file_id}"
|
|
605
|
+
gsi1_sk = f"USER#{shared_with_user_id}"
|
|
606
|
+
|
|
607
|
+
results = self.dynamodb.query(
|
|
608
|
+
key=Key('gsi1_pk').eq(gsi1_pk) & Key('gsi1_sk').eq(gsi1_sk),
|
|
609
|
+
table_name=self.table_name,
|
|
610
|
+
index_name="gsi1",
|
|
611
|
+
limit=1
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
items = results.get('Items', [])
|
|
615
|
+
if items:
|
|
616
|
+
share = FileShare()
|
|
617
|
+
share.map(items[0])
|
|
618
|
+
return share
|
|
619
|
+
|
|
620
|
+
return None
|
|
621
|
+
|
|
622
|
+
except Exception:
|
|
623
|
+
return None
|
|
624
|
+
|
|
625
|
+
def _increment_access_count(
|
|
626
|
+
self,
|
|
627
|
+
tenant_id: str,
|
|
628
|
+
file_id: str,
|
|
629
|
+
share_id: str
|
|
630
|
+
) -> None:
|
|
631
|
+
"""Increment share access count."""
|
|
632
|
+
try:
|
|
633
|
+
pk = f"FILE#{tenant_id}#{file_id}"
|
|
634
|
+
sk = f"SHARE#{share_id}"
|
|
635
|
+
|
|
636
|
+
result = self.dynamodb.get(
|
|
637
|
+
table_name=self.table_name,
|
|
638
|
+
key={"pk": pk, "sk": sk}
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
if result and 'Item' in result:
|
|
642
|
+
share = FileShare()
|
|
643
|
+
share.map(result['Item'])
|
|
644
|
+
|
|
645
|
+
share.access_count += 1
|
|
646
|
+
share.last_accessed_at = dt.datetime.now(dt.UTC).timestamp()
|
|
647
|
+
|
|
648
|
+
item = share.to_dictionary()
|
|
649
|
+
item["pk"] = pk
|
|
650
|
+
item["sk"] = sk
|
|
651
|
+
|
|
652
|
+
# Preserve GSI keys
|
|
653
|
+
item["gsi1_pk"] = result['Item'].get('gsi1_pk')
|
|
654
|
+
item["gsi1_sk"] = result['Item'].get('gsi1_sk')
|
|
655
|
+
item["gsi2_pk"] = result['Item'].get('gsi2_pk')
|
|
656
|
+
item["gsi2_sk"] = result['Item'].get('gsi2_sk')
|
|
657
|
+
|
|
658
|
+
self.dynamodb.save(
|
|
659
|
+
item=item,
|
|
660
|
+
table_name=self.table_name
|
|
661
|
+
)
|
|
662
|
+
except Exception:
|
|
663
|
+
pass # Best effort
|