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,739 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FileVersionService for explicit file version management.
|
|
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_version import FileVersion
|
|
16
|
+
from geek_cafe_saas_sdk.domains.files.models.file import File
|
|
17
|
+
from geek_cafe_saas_sdk.domains.files.services.s3_file_service import S3FileService
|
|
18
|
+
import datetime as dt
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FileVersionService(DatabaseService[FileVersion]):
|
|
23
|
+
"""
|
|
24
|
+
File version service for explicit version management.
|
|
25
|
+
|
|
26
|
+
Handles:
|
|
27
|
+
- Creating new versions of files
|
|
28
|
+
- Version history listing
|
|
29
|
+
- Version restoration
|
|
30
|
+
- Version comparison
|
|
31
|
+
- Version cleanup (retention policy)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
dynamodb: Optional[DynamoDB] = None,
|
|
38
|
+
table_name: Optional[str] = None,
|
|
39
|
+
s3_service: Optional[S3FileService] = None,
|
|
40
|
+
default_bucket: Optional[str] = None,
|
|
41
|
+
max_versions: Optional[int] = None
|
|
42
|
+
):
|
|
43
|
+
"""
|
|
44
|
+
Initialize FileVersionService.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
dynamodb: DynamoDB instance
|
|
48
|
+
table_name: DynamoDB table name
|
|
49
|
+
s3_service: S3FileService instance
|
|
50
|
+
default_bucket: Default S3 bucket
|
|
51
|
+
max_versions: Maximum versions to retain (default: 25)
|
|
52
|
+
"""
|
|
53
|
+
super().__init__(dynamodb=dynamodb, table_name=table_name)
|
|
54
|
+
self.s3_service = s3_service or S3FileService(default_bucket=default_bucket)
|
|
55
|
+
self.default_bucket = default_bucket or os.getenv("S3_FILE_BUCKET")
|
|
56
|
+
self.max_versions = max_versions or int(os.getenv("FILE_MAX_VERSIONS", "25"))
|
|
57
|
+
|
|
58
|
+
def create(
|
|
59
|
+
self,
|
|
60
|
+
tenant_id: str,
|
|
61
|
+
user_id: str,
|
|
62
|
+
file_id: str,
|
|
63
|
+
file_data: bytes,
|
|
64
|
+
change_description: Optional[str] = None,
|
|
65
|
+
**kwargs
|
|
66
|
+
) -> ServiceResult[FileVersion]:
|
|
67
|
+
"""
|
|
68
|
+
Create a new version of a file.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
tenant_id: Tenant ID
|
|
72
|
+
user_id: User ID (who is creating the version)
|
|
73
|
+
file_id: File ID
|
|
74
|
+
file_data: New file content bytes
|
|
75
|
+
change_description: Optional description of changes
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
ServiceResult with FileVersion model
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
# Get the original file
|
|
82
|
+
file_result = self._get_file(tenant_id, file_id, user_id)
|
|
83
|
+
if not file_result.success:
|
|
84
|
+
return file_result
|
|
85
|
+
|
|
86
|
+
file = file_result.data
|
|
87
|
+
|
|
88
|
+
# Verify file uses explicit versioning
|
|
89
|
+
if file.versioning_strategy != "explicit":
|
|
90
|
+
raise ValidationError(
|
|
91
|
+
"File does not use explicit versioning strategy",
|
|
92
|
+
"versioning_strategy"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Get current version number
|
|
96
|
+
current_version_num = self._get_latest_version_number(tenant_id, file_id)
|
|
97
|
+
new_version_num = current_version_num + 1
|
|
98
|
+
|
|
99
|
+
# Create FileVersion model
|
|
100
|
+
version = FileVersion()
|
|
101
|
+
version.prep_for_save()
|
|
102
|
+
version.tenant_id = tenant_id
|
|
103
|
+
version.file_id = file_id
|
|
104
|
+
version.version_number = new_version_num
|
|
105
|
+
version.created_by = user_id
|
|
106
|
+
version.change_description = change_description
|
|
107
|
+
version.file_size = len(file_data)
|
|
108
|
+
version.mime_type = file.mime_type
|
|
109
|
+
version.checksum = self._calculate_checksum(file_data)
|
|
110
|
+
version.is_current = True
|
|
111
|
+
version.status = "active"
|
|
112
|
+
|
|
113
|
+
# Build S3 key for this version
|
|
114
|
+
s3_key = f"{tenant_id}/files/{file_id}/versions/{version.version_id}/{file.file_name}"
|
|
115
|
+
version.s3_bucket = self.default_bucket
|
|
116
|
+
version.s3_key = s3_key
|
|
117
|
+
|
|
118
|
+
# Upload to S3
|
|
119
|
+
upload_result = self.s3_service.upload_file(
|
|
120
|
+
file_data=file_data,
|
|
121
|
+
key=s3_key,
|
|
122
|
+
bucket=self.default_bucket
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if not upload_result.success:
|
|
126
|
+
return ServiceResult.error_result(
|
|
127
|
+
message=f"Failed to upload version to S3: {upload_result.message}",
|
|
128
|
+
error_code=upload_result.error_code
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Mark previous version as not current
|
|
132
|
+
if file.current_version_id:
|
|
133
|
+
self._mark_version_as_not_current(tenant_id, file_id, file.current_version_id)
|
|
134
|
+
|
|
135
|
+
# Save version metadata to DynamoDB
|
|
136
|
+
pk = f"FILE#{tenant_id}#{file_id}"
|
|
137
|
+
sk = f"VERSION#{version.version_id}"
|
|
138
|
+
|
|
139
|
+
item = version.to_dictionary()
|
|
140
|
+
item["pk"] = pk
|
|
141
|
+
item["sk"] = sk
|
|
142
|
+
|
|
143
|
+
# GSI1: Versions by file (ordered by version number)
|
|
144
|
+
item["gsi1_pk"] = f"FILE#{tenant_id}#{file_id}"
|
|
145
|
+
item["gsi1_sk"] = f"VERSION#{new_version_num:010d}" # Pad for sorting
|
|
146
|
+
|
|
147
|
+
self.dynamodb.save(
|
|
148
|
+
item=item,
|
|
149
|
+
table_name=self.table_name
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Update file record with new current version
|
|
153
|
+
self._update_file_current_version(
|
|
154
|
+
tenant_id,
|
|
155
|
+
file_id,
|
|
156
|
+
version.version_id,
|
|
157
|
+
new_version_num
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Apply retention policy
|
|
161
|
+
self._apply_retention_policy(tenant_id, file_id)
|
|
162
|
+
|
|
163
|
+
return ServiceResult.success_result(version)
|
|
164
|
+
|
|
165
|
+
except ValidationError as e:
|
|
166
|
+
return ServiceResult.error_result(
|
|
167
|
+
message=str(e),
|
|
168
|
+
error_code=ErrorCode.VALIDATION_ERROR
|
|
169
|
+
)
|
|
170
|
+
except Exception as e:
|
|
171
|
+
return ServiceResult.exception_result(
|
|
172
|
+
exception=e,
|
|
173
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
174
|
+
context="FileVersionService.create"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def get_by_id(
|
|
178
|
+
self,
|
|
179
|
+
resource_id: str,
|
|
180
|
+
tenant_id: str,
|
|
181
|
+
user_id: str,
|
|
182
|
+
file_id: Optional[str] = None
|
|
183
|
+
) -> ServiceResult[FileVersion]:
|
|
184
|
+
"""
|
|
185
|
+
Get version by ID with access control.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
resource_id: Version ID
|
|
189
|
+
tenant_id: Tenant ID
|
|
190
|
+
user_id: User ID (for access control)
|
|
191
|
+
file_id: File ID (required if version_id alone is ambiguous)
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
ServiceResult with FileVersion model
|
|
195
|
+
"""
|
|
196
|
+
try:
|
|
197
|
+
if not file_id:
|
|
198
|
+
raise ValidationError("file_id is required", "file_id")
|
|
199
|
+
|
|
200
|
+
pk = f"FILE#{tenant_id}#{file_id}"
|
|
201
|
+
sk = f"VERSION#{resource_id}"
|
|
202
|
+
|
|
203
|
+
result = self.dynamodb.get(
|
|
204
|
+
table_name=self.table_name,
|
|
205
|
+
key={"pk": pk, "sk": sk}
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Check if version exists
|
|
209
|
+
if not result or 'Item' not in result:
|
|
210
|
+
raise NotFoundError(f"Version not found: {resource_id}")
|
|
211
|
+
|
|
212
|
+
# Convert to FileVersion model
|
|
213
|
+
version = FileVersion()
|
|
214
|
+
version.map(result['Item'])
|
|
215
|
+
|
|
216
|
+
# Access control: Check file ownership
|
|
217
|
+
file_result = self._get_file(tenant_id, file_id, user_id)
|
|
218
|
+
if not file_result.success:
|
|
219
|
+
raise AccessDeniedError("You do not have access to this file version")
|
|
220
|
+
|
|
221
|
+
return ServiceResult.success_result(version)
|
|
222
|
+
|
|
223
|
+
except (NotFoundError, ValidationError) as e:
|
|
224
|
+
return ServiceResult.error_result(
|
|
225
|
+
message=str(e),
|
|
226
|
+
error_code=ErrorCode.NOT_FOUND if isinstance(e, NotFoundError) else ErrorCode.VALIDATION_ERROR
|
|
227
|
+
)
|
|
228
|
+
except AccessDeniedError as e:
|
|
229
|
+
return ServiceResult.error_result(
|
|
230
|
+
message=str(e),
|
|
231
|
+
error_code=ErrorCode.ACCESS_DENIED
|
|
232
|
+
)
|
|
233
|
+
except Exception as e:
|
|
234
|
+
return ServiceResult.exception_result(
|
|
235
|
+
exception=e,
|
|
236
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
237
|
+
context="FileVersionService.get_by_id"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def update(
|
|
241
|
+
self,
|
|
242
|
+
resource_id: str,
|
|
243
|
+
tenant_id: str,
|
|
244
|
+
user_id: str,
|
|
245
|
+
updates: Dict[str, Any]
|
|
246
|
+
) -> ServiceResult[FileVersion]:
|
|
247
|
+
"""
|
|
248
|
+
Update version metadata (limited fields).
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
resource_id: Version ID
|
|
252
|
+
tenant_id: Tenant ID
|
|
253
|
+
user_id: User ID (for access control)
|
|
254
|
+
updates: Dictionary of fields to update
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
ServiceResult with updated FileVersion model
|
|
258
|
+
"""
|
|
259
|
+
try:
|
|
260
|
+
# Get file_id from updates if provided
|
|
261
|
+
file_id = updates.get('file_id')
|
|
262
|
+
if not file_id:
|
|
263
|
+
raise ValidationError("file_id is required in updates", "file_id")
|
|
264
|
+
|
|
265
|
+
# Get existing version
|
|
266
|
+
get_result = self.get_by_id(resource_id, tenant_id, user_id, file_id=file_id)
|
|
267
|
+
if not get_result.success:
|
|
268
|
+
return get_result
|
|
269
|
+
|
|
270
|
+
version = get_result.data
|
|
271
|
+
|
|
272
|
+
# Only allow updating change_description and status
|
|
273
|
+
allowed_fields = ["change_description", "status"]
|
|
274
|
+
|
|
275
|
+
for field, value in updates.items():
|
|
276
|
+
if field in allowed_fields:
|
|
277
|
+
setattr(version, field, value)
|
|
278
|
+
|
|
279
|
+
# Update timestamp
|
|
280
|
+
version.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
281
|
+
|
|
282
|
+
# Save to DynamoDB
|
|
283
|
+
pk = f"FILE#{tenant_id}#{file_id}"
|
|
284
|
+
sk = f"VERSION#{resource_id}"
|
|
285
|
+
|
|
286
|
+
item = version.to_dictionary()
|
|
287
|
+
item["pk"] = pk
|
|
288
|
+
item["sk"] = sk
|
|
289
|
+
|
|
290
|
+
# Preserve GSI1 keys
|
|
291
|
+
item["gsi1_pk"] = f"FILE#{tenant_id}#{file_id}"
|
|
292
|
+
item["gsi1_sk"] = f"VERSION#{version.version_number:010d}"
|
|
293
|
+
|
|
294
|
+
self.dynamodb.save(
|
|
295
|
+
item=item,
|
|
296
|
+
table_name=self.table_name
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
return ServiceResult.success_result(version)
|
|
300
|
+
|
|
301
|
+
except (ValidationError, AccessDeniedError) as e:
|
|
302
|
+
return ServiceResult.error_result(
|
|
303
|
+
message=str(e),
|
|
304
|
+
error_code=ErrorCode.VALIDATION_ERROR if isinstance(e, ValidationError) else ErrorCode.ACCESS_DENIED
|
|
305
|
+
)
|
|
306
|
+
except Exception as e:
|
|
307
|
+
return ServiceResult.exception_result(
|
|
308
|
+
exception=e,
|
|
309
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
310
|
+
context="FileVersionService.update"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def delete(
|
|
314
|
+
self,
|
|
315
|
+
resource_id: str,
|
|
316
|
+
tenant_id: str,
|
|
317
|
+
user_id: str,
|
|
318
|
+
file_id: str
|
|
319
|
+
) -> ServiceResult[bool]:
|
|
320
|
+
"""
|
|
321
|
+
Delete a version (cannot delete current version).
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
resource_id: Version ID
|
|
325
|
+
tenant_id: Tenant ID
|
|
326
|
+
user_id: User ID (for access control)
|
|
327
|
+
file_id: File ID
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
ServiceResult with success boolean
|
|
331
|
+
"""
|
|
332
|
+
try:
|
|
333
|
+
# Get existing version
|
|
334
|
+
get_result = self.get_by_id(resource_id, tenant_id, user_id, file_id=file_id)
|
|
335
|
+
if not get_result.success:
|
|
336
|
+
return get_result
|
|
337
|
+
|
|
338
|
+
version = get_result.data
|
|
339
|
+
|
|
340
|
+
# Cannot delete current version
|
|
341
|
+
if version.is_current:
|
|
342
|
+
raise ValidationError(
|
|
343
|
+
"Cannot delete the current version. Restore a different version first.",
|
|
344
|
+
"is_current"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Soft delete - mark as archived
|
|
348
|
+
version.status = "archived"
|
|
349
|
+
version.deleted_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
350
|
+
|
|
351
|
+
pk = f"FILE#{tenant_id}#{file_id}"
|
|
352
|
+
sk = f"VERSION#{resource_id}"
|
|
353
|
+
|
|
354
|
+
item = version.to_dictionary()
|
|
355
|
+
item["pk"] = pk
|
|
356
|
+
item["sk"] = sk
|
|
357
|
+
|
|
358
|
+
# Preserve GSI1 keys
|
|
359
|
+
item["gsi1_pk"] = f"FILE#{tenant_id}#{file_id}"
|
|
360
|
+
item["gsi1_sk"] = f"VERSION#{version.version_number:010d}"
|
|
361
|
+
|
|
362
|
+
self.dynamodb.save(
|
|
363
|
+
item=item,
|
|
364
|
+
table_name=self.table_name
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
# Note: S3 file is kept for potential recovery
|
|
368
|
+
|
|
369
|
+
return ServiceResult.success_result(True)
|
|
370
|
+
|
|
371
|
+
except ValidationError as e:
|
|
372
|
+
return ServiceResult.error_result(
|
|
373
|
+
message=str(e),
|
|
374
|
+
error_code=ErrorCode.VALIDATION_ERROR
|
|
375
|
+
)
|
|
376
|
+
except Exception as e:
|
|
377
|
+
return ServiceResult.exception_result(
|
|
378
|
+
exception=e,
|
|
379
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
380
|
+
context="FileVersionService.delete"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
def list_versions(
|
|
384
|
+
self,
|
|
385
|
+
tenant_id: str,
|
|
386
|
+
file_id: str,
|
|
387
|
+
user_id: str,
|
|
388
|
+
limit: int = 50
|
|
389
|
+
) -> ServiceResult[List[FileVersion]]:
|
|
390
|
+
"""
|
|
391
|
+
List all versions of a file.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
tenant_id: Tenant ID
|
|
395
|
+
file_id: File ID
|
|
396
|
+
user_id: User ID (for access control)
|
|
397
|
+
limit: Maximum number of results
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
ServiceResult with list of FileVersion models
|
|
401
|
+
"""
|
|
402
|
+
try:
|
|
403
|
+
# Check file access
|
|
404
|
+
file_result = self._get_file(tenant_id, file_id, user_id)
|
|
405
|
+
if not file_result.success:
|
|
406
|
+
return ServiceResult.error_result(
|
|
407
|
+
message="File not found or access denied",
|
|
408
|
+
error_code=ErrorCode.ACCESS_DENIED
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# Query GSI1 for versions
|
|
412
|
+
gsi1_pk = f"FILE#{tenant_id}#{file_id}"
|
|
413
|
+
|
|
414
|
+
results = self.dynamodb.query(
|
|
415
|
+
key=Key('gsi1_pk').eq(gsi1_pk) & Key('gsi1_sk').begins_with("VERSION#"),
|
|
416
|
+
table_name=self.table_name,
|
|
417
|
+
index_name="gsi1",
|
|
418
|
+
limit=limit,
|
|
419
|
+
ascending=False # Newest first
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
versions = []
|
|
423
|
+
for item in results.get('Items', []):
|
|
424
|
+
version = FileVersion()
|
|
425
|
+
version.map(item)
|
|
426
|
+
|
|
427
|
+
# Filter out archived versions (which includes deleted ones)
|
|
428
|
+
if version.status == "active":
|
|
429
|
+
versions.append(version)
|
|
430
|
+
|
|
431
|
+
return ServiceResult.success_result(versions)
|
|
432
|
+
|
|
433
|
+
except Exception as e:
|
|
434
|
+
return ServiceResult.exception_result(
|
|
435
|
+
exception=e,
|
|
436
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
437
|
+
context="FileVersionService.list_versions"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
def restore_version(
|
|
441
|
+
self,
|
|
442
|
+
tenant_id: str,
|
|
443
|
+
file_id: str,
|
|
444
|
+
version_id: str,
|
|
445
|
+
user_id: str
|
|
446
|
+
) -> ServiceResult[FileVersion]:
|
|
447
|
+
"""
|
|
448
|
+
Restore a previous version as the current version.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
tenant_id: Tenant ID
|
|
452
|
+
file_id: File ID
|
|
453
|
+
version_id: Version ID to restore
|
|
454
|
+
user_id: User ID
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
ServiceResult with restored FileVersion (now current)
|
|
458
|
+
"""
|
|
459
|
+
try:
|
|
460
|
+
# Get the version to restore
|
|
461
|
+
get_result = self.get_by_id(version_id, tenant_id, user_id, file_id=file_id)
|
|
462
|
+
if not get_result.success:
|
|
463
|
+
return get_result
|
|
464
|
+
|
|
465
|
+
version_to_restore = get_result.data
|
|
466
|
+
|
|
467
|
+
# Get file
|
|
468
|
+
file_result = self._get_file(tenant_id, file_id, user_id)
|
|
469
|
+
if not file_result.success:
|
|
470
|
+
return file_result
|
|
471
|
+
|
|
472
|
+
file = file_result.data
|
|
473
|
+
|
|
474
|
+
# Mark current version as not current
|
|
475
|
+
if file.current_version_id:
|
|
476
|
+
self._mark_version_as_not_current(tenant_id, file_id, file.current_version_id)
|
|
477
|
+
|
|
478
|
+
# Mark restored version as current
|
|
479
|
+
version_to_restore.is_current = True
|
|
480
|
+
version_to_restore.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
481
|
+
|
|
482
|
+
pk = f"FILE#{tenant_id}#{file_id}"
|
|
483
|
+
sk = f"VERSION#{version_id}"
|
|
484
|
+
|
|
485
|
+
item = version_to_restore.to_dictionary()
|
|
486
|
+
item["pk"] = pk
|
|
487
|
+
item["sk"] = sk
|
|
488
|
+
|
|
489
|
+
# Preserve GSI1 keys
|
|
490
|
+
item["gsi1_pk"] = f"FILE#{tenant_id}#{file_id}"
|
|
491
|
+
item["gsi1_sk"] = f"VERSION#{version_to_restore.version_number:010d}"
|
|
492
|
+
|
|
493
|
+
self.dynamodb.save(
|
|
494
|
+
item=item,
|
|
495
|
+
table_name=self.table_name
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# Update file record
|
|
499
|
+
self._update_file_current_version(
|
|
500
|
+
tenant_id,
|
|
501
|
+
file_id,
|
|
502
|
+
version_id,
|
|
503
|
+
version_to_restore.version_number
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
return ServiceResult.success_result(version_to_restore)
|
|
507
|
+
|
|
508
|
+
except Exception as e:
|
|
509
|
+
return ServiceResult.exception_result(
|
|
510
|
+
exception=e,
|
|
511
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
512
|
+
context="FileVersionService.restore_version"
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
def download_version(
|
|
516
|
+
self,
|
|
517
|
+
tenant_id: str,
|
|
518
|
+
file_id: str,
|
|
519
|
+
version_id: str,
|
|
520
|
+
user_id: str
|
|
521
|
+
) -> ServiceResult[Dict[str, Any]]:
|
|
522
|
+
"""
|
|
523
|
+
Download a specific version of a file.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
tenant_id: Tenant ID
|
|
527
|
+
file_id: File ID
|
|
528
|
+
version_id: Version ID
|
|
529
|
+
user_id: User ID
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
ServiceResult with file data and metadata
|
|
533
|
+
"""
|
|
534
|
+
try:
|
|
535
|
+
# Get version
|
|
536
|
+
get_result = self.get_by_id(version_id, tenant_id, user_id, file_id=file_id)
|
|
537
|
+
if not get_result.success:
|
|
538
|
+
return get_result
|
|
539
|
+
|
|
540
|
+
version = get_result.data
|
|
541
|
+
|
|
542
|
+
# Download from S3
|
|
543
|
+
download_result = self.s3_service.download_file(
|
|
544
|
+
key=version.s3_key,
|
|
545
|
+
bucket=version.s3_bucket
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
if not download_result.success:
|
|
549
|
+
return ServiceResult.error_result(
|
|
550
|
+
message=f"Failed to download version from S3: {download_result.message}",
|
|
551
|
+
error_code=download_result.error_code
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
return ServiceResult.success_result({
|
|
555
|
+
"version": version,
|
|
556
|
+
"data": download_result.data["data"],
|
|
557
|
+
"content_type": download_result.data.get("content_type", version.mime_type),
|
|
558
|
+
"size": download_result.data.get("size", version.file_size)
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
except Exception as e:
|
|
562
|
+
return ServiceResult.exception_result(
|
|
563
|
+
exception=e,
|
|
564
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
565
|
+
context="FileVersionService.download_version"
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
# Helper methods
|
|
569
|
+
|
|
570
|
+
def _get_file(self, tenant_id: str, file_id: str, user_id: str) -> ServiceResult[File]:
|
|
571
|
+
"""Get file with access control."""
|
|
572
|
+
try:
|
|
573
|
+
pk = f"FILE#{tenant_id}#{file_id}"
|
|
574
|
+
sk = "METADATA"
|
|
575
|
+
|
|
576
|
+
result = self.dynamodb.get(
|
|
577
|
+
table_name=self.table_name,
|
|
578
|
+
key={"pk": pk, "sk": sk}
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
if not result or 'Item' not in result:
|
|
582
|
+
raise NotFoundError(f"File not found: {file_id}")
|
|
583
|
+
|
|
584
|
+
file = File()
|
|
585
|
+
file.map(result['Item'])
|
|
586
|
+
|
|
587
|
+
if file.owner_id != user_id:
|
|
588
|
+
raise AccessDeniedError("You do not have access to this file")
|
|
589
|
+
|
|
590
|
+
return ServiceResult.success_result(file)
|
|
591
|
+
|
|
592
|
+
except (NotFoundError, AccessDeniedError) as e:
|
|
593
|
+
return ServiceResult.error_result(
|
|
594
|
+
message=str(e),
|
|
595
|
+
error_code=ErrorCode.NOT_FOUND if isinstance(e, NotFoundError) else ErrorCode.ACCESS_DENIED
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
def _get_latest_version_number(self, tenant_id: str, file_id: str) -> int:
|
|
599
|
+
"""Get the latest version number for a file."""
|
|
600
|
+
try:
|
|
601
|
+
gsi1_pk = f"FILE#{tenant_id}#{file_id}"
|
|
602
|
+
|
|
603
|
+
results = self.dynamodb.query(
|
|
604
|
+
key=Key('gsi1_pk').eq(gsi1_pk) & Key('gsi1_sk').begins_with("VERSION#"),
|
|
605
|
+
table_name=self.table_name,
|
|
606
|
+
index_name="gsi1",
|
|
607
|
+
limit=1,
|
|
608
|
+
ascending=False # Get highest version number
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
items = results.get('Items', [])
|
|
612
|
+
if items:
|
|
613
|
+
version = FileVersion()
|
|
614
|
+
version.map(items[0])
|
|
615
|
+
return version.version_number
|
|
616
|
+
|
|
617
|
+
return 0 # No versions yet
|
|
618
|
+
|
|
619
|
+
except Exception:
|
|
620
|
+
return 0
|
|
621
|
+
|
|
622
|
+
def _mark_version_as_not_current(
|
|
623
|
+
self,
|
|
624
|
+
tenant_id: str,
|
|
625
|
+
file_id: str,
|
|
626
|
+
version_id: str
|
|
627
|
+
) -> None:
|
|
628
|
+
"""Mark a version as not current."""
|
|
629
|
+
try:
|
|
630
|
+
pk = f"FILE#{tenant_id}#{file_id}"
|
|
631
|
+
sk = f"VERSION#{version_id}"
|
|
632
|
+
|
|
633
|
+
result = self.dynamodb.get(
|
|
634
|
+
table_name=self.table_name,
|
|
635
|
+
key={"pk": pk, "sk": sk}
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
if result and 'Item' in result:
|
|
639
|
+
version = FileVersion()
|
|
640
|
+
version.map(result['Item'])
|
|
641
|
+
version.is_current = False
|
|
642
|
+
|
|
643
|
+
item = version.to_dictionary()
|
|
644
|
+
item["pk"] = pk
|
|
645
|
+
item["sk"] = sk
|
|
646
|
+
|
|
647
|
+
# Preserve GSI1 keys
|
|
648
|
+
item["gsi1_pk"] = result['Item'].get('gsi1_pk')
|
|
649
|
+
item["gsi1_sk"] = result['Item'].get('gsi1_sk')
|
|
650
|
+
|
|
651
|
+
self.dynamodb.save(
|
|
652
|
+
item=item,
|
|
653
|
+
table_name=self.table_name
|
|
654
|
+
)
|
|
655
|
+
except Exception:
|
|
656
|
+
pass # Best effort
|
|
657
|
+
|
|
658
|
+
def _update_file_current_version(
|
|
659
|
+
self,
|
|
660
|
+
tenant_id: str,
|
|
661
|
+
file_id: str,
|
|
662
|
+
version_id: str,
|
|
663
|
+
version_number: int
|
|
664
|
+
) -> None:
|
|
665
|
+
"""Update file record with current version info."""
|
|
666
|
+
try:
|
|
667
|
+
pk = f"FILE#{tenant_id}#{file_id}"
|
|
668
|
+
sk = "METADATA"
|
|
669
|
+
|
|
670
|
+
result = self.dynamodb.get(
|
|
671
|
+
table_name=self.table_name,
|
|
672
|
+
key={"pk": pk, "sk": sk}
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
if result and 'Item' in result:
|
|
676
|
+
file = File()
|
|
677
|
+
file.map(result['Item'])
|
|
678
|
+
|
|
679
|
+
file.current_version_id = version_id
|
|
680
|
+
file.version_count = version_number
|
|
681
|
+
file.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
682
|
+
|
|
683
|
+
item = file.to_dictionary()
|
|
684
|
+
item["pk"] = pk
|
|
685
|
+
item["sk"] = sk
|
|
686
|
+
|
|
687
|
+
self.dynamodb.save(
|
|
688
|
+
item=item,
|
|
689
|
+
table_name=self.table_name
|
|
690
|
+
)
|
|
691
|
+
except Exception:
|
|
692
|
+
pass # Best effort
|
|
693
|
+
|
|
694
|
+
def _apply_retention_policy(self, tenant_id: str, file_id: str) -> None:
|
|
695
|
+
"""Apply version retention policy (delete old versions beyond max_versions)."""
|
|
696
|
+
try:
|
|
697
|
+
# Get all versions
|
|
698
|
+
gsi1_pk = f"FILE#{tenant_id}#{file_id}"
|
|
699
|
+
|
|
700
|
+
results = self.dynamodb.query(
|
|
701
|
+
key=Key('gsi1_pk').eq(gsi1_pk) & Key('gsi1_sk').begins_with("VERSION#"),
|
|
702
|
+
table_name=self.table_name,
|
|
703
|
+
index_name="gsi1",
|
|
704
|
+
ascending=False # Newest first
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
items = results.get('Items', [])
|
|
708
|
+
active_versions = [item for item in items if item.get('status') == 'active']
|
|
709
|
+
|
|
710
|
+
# If we exceed max_versions, mark old ones as archived
|
|
711
|
+
if len(active_versions) > self.max_versions:
|
|
712
|
+
versions_to_archive = active_versions[self.max_versions:]
|
|
713
|
+
|
|
714
|
+
for item in versions_to_archive:
|
|
715
|
+
version = FileVersion()
|
|
716
|
+
version.map(item)
|
|
717
|
+
|
|
718
|
+
if not version.is_current: # Never archive current version
|
|
719
|
+
version.status = "archived"
|
|
720
|
+
version.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
721
|
+
|
|
722
|
+
pk = f"FILE#{tenant_id}#{file_id}"
|
|
723
|
+
sk = f"VERSION#{version.version_id}"
|
|
724
|
+
|
|
725
|
+
item_dict = version.to_dictionary()
|
|
726
|
+
item_dict["pk"] = pk
|
|
727
|
+
item_dict["sk"] = sk
|
|
728
|
+
|
|
729
|
+
self.dynamodb.save(
|
|
730
|
+
item=item_dict,
|
|
731
|
+
table_name=self.table_name
|
|
732
|
+
)
|
|
733
|
+
except Exception:
|
|
734
|
+
pass # Best effort
|
|
735
|
+
|
|
736
|
+
def _calculate_checksum(self, data: bytes) -> str:
|
|
737
|
+
"""Calculate checksum for file data."""
|
|
738
|
+
import hashlib
|
|
739
|
+
return hashlib.sha256(data).hexdigest()
|