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.

Files changed (194) hide show
  1. geek_cafe_saas_sdk/__init__.py +9 -0
  2. geek_cafe_saas_sdk/core/__init__.py +11 -0
  3. geek_cafe_saas_sdk/core/audit_mixin.py +33 -0
  4. geek_cafe_saas_sdk/core/error_codes.py +132 -0
  5. geek_cafe_saas_sdk/core/service_errors.py +19 -0
  6. geek_cafe_saas_sdk/core/service_result.py +121 -0
  7. geek_cafe_saas_sdk/decorators/__init__.py +64 -0
  8. geek_cafe_saas_sdk/decorators/auth.py +373 -0
  9. geek_cafe_saas_sdk/decorators/core.py +358 -0
  10. geek_cafe_saas_sdk/domains/__init__.py +0 -0
  11. geek_cafe_saas_sdk/domains/analytics/__init__.py +0 -0
  12. geek_cafe_saas_sdk/domains/analytics/handlers/__init__.py +0 -0
  13. geek_cafe_saas_sdk/domains/analytics/models/__init__.py +9 -0
  14. geek_cafe_saas_sdk/domains/analytics/models/website_analytics.py +219 -0
  15. geek_cafe_saas_sdk/domains/analytics/models/website_analytics_summary.py +220 -0
  16. geek_cafe_saas_sdk/domains/analytics/services/__init__.py +11 -0
  17. geek_cafe_saas_sdk/domains/analytics/services/website_analytics_service.py +232 -0
  18. geek_cafe_saas_sdk/domains/analytics/services/website_analytics_summary_service.py +212 -0
  19. geek_cafe_saas_sdk/domains/analytics/services/website_analytics_tally_service.py +610 -0
  20. geek_cafe_saas_sdk/domains/auth/__init__.py +0 -0
  21. geek_cafe_saas_sdk/domains/auth/handlers/__init__.py +0 -0
  22. geek_cafe_saas_sdk/domains/auth/handlers/users/create/app.py +41 -0
  23. geek_cafe_saas_sdk/domains/auth/handlers/users/delete/app.py +41 -0
  24. geek_cafe_saas_sdk/domains/auth/handlers/users/get/app.py +39 -0
  25. geek_cafe_saas_sdk/domains/auth/handlers/users/list/app.py +36 -0
  26. geek_cafe_saas_sdk/domains/auth/handlers/users/update/app.py +44 -0
  27. geek_cafe_saas_sdk/domains/auth/models/__init__.py +13 -0
  28. geek_cafe_saas_sdk/domains/auth/models/permission.py +134 -0
  29. geek_cafe_saas_sdk/domains/auth/models/resource_permission.py +245 -0
  30. geek_cafe_saas_sdk/domains/auth/models/role.py +213 -0
  31. geek_cafe_saas_sdk/domains/auth/models/user.py +285 -0
  32. geek_cafe_saas_sdk/domains/auth/services/__init__.py +16 -0
  33. geek_cafe_saas_sdk/domains/auth/services/authorization_service.py +376 -0
  34. geek_cafe_saas_sdk/domains/auth/services/permission_registry.py +464 -0
  35. geek_cafe_saas_sdk/domains/auth/services/resource_permission_service.py +408 -0
  36. geek_cafe_saas_sdk/domains/auth/services/user_service.py +274 -0
  37. geek_cafe_saas_sdk/domains/communities/__init__.py +0 -0
  38. geek_cafe_saas_sdk/domains/communities/handlers/__init__.py +0 -0
  39. geek_cafe_saas_sdk/domains/communities/handlers/communities/create/app.py +41 -0
  40. geek_cafe_saas_sdk/domains/communities/handlers/communities/delete/app.py +41 -0
  41. geek_cafe_saas_sdk/domains/communities/handlers/communities/get/app.py +39 -0
  42. geek_cafe_saas_sdk/domains/communities/handlers/communities/list/app.py +36 -0
  43. geek_cafe_saas_sdk/domains/communities/handlers/communities/update/app.py +44 -0
  44. geek_cafe_saas_sdk/domains/communities/models/__init__.py +6 -0
  45. geek_cafe_saas_sdk/domains/communities/models/community.py +326 -0
  46. geek_cafe_saas_sdk/domains/communities/models/community_member.py +227 -0
  47. geek_cafe_saas_sdk/domains/communities/services/__init__.py +6 -0
  48. geek_cafe_saas_sdk/domains/communities/services/community_member_service.py +412 -0
  49. geek_cafe_saas_sdk/domains/communities/services/community_service.py +479 -0
  50. geek_cafe_saas_sdk/domains/events/__init__.py +0 -0
  51. geek_cafe_saas_sdk/domains/events/handlers/__init__.py +0 -0
  52. geek_cafe_saas_sdk/domains/events/handlers/attendees/app.py +67 -0
  53. geek_cafe_saas_sdk/domains/events/handlers/cancel/app.py +66 -0
  54. geek_cafe_saas_sdk/domains/events/handlers/check_in/app.py +60 -0
  55. geek_cafe_saas_sdk/domains/events/handlers/create/app.py +93 -0
  56. geek_cafe_saas_sdk/domains/events/handlers/delete/app.py +42 -0
  57. geek_cafe_saas_sdk/domains/events/handlers/get/app.py +39 -0
  58. geek_cafe_saas_sdk/domains/events/handlers/invite/app.py +98 -0
  59. geek_cafe_saas_sdk/domains/events/handlers/list/app.py +125 -0
  60. geek_cafe_saas_sdk/domains/events/handlers/publish/app.py +49 -0
  61. geek_cafe_saas_sdk/domains/events/handlers/rsvp/app.py +83 -0
  62. geek_cafe_saas_sdk/domains/events/handlers/update/app.py +44 -0
  63. geek_cafe_saas_sdk/domains/events/models/__init__.py +3 -0
  64. geek_cafe_saas_sdk/domains/events/models/event.py +681 -0
  65. geek_cafe_saas_sdk/domains/events/models/event_attendee.py +324 -0
  66. geek_cafe_saas_sdk/domains/events/services/__init__.py +9 -0
  67. geek_cafe_saas_sdk/domains/events/services/event_attendee_service.py +571 -0
  68. geek_cafe_saas_sdk/domains/events/services/event_service.py +684 -0
  69. geek_cafe_saas_sdk/domains/files/__init__.py +0 -0
  70. geek_cafe_saas_sdk/domains/files/models/__init__.py +0 -0
  71. geek_cafe_saas_sdk/domains/files/models/directory.py +258 -0
  72. geek_cafe_saas_sdk/domains/files/models/file.py +312 -0
  73. geek_cafe_saas_sdk/domains/files/models/file_share.py +268 -0
  74. geek_cafe_saas_sdk/domains/files/models/file_version.py +216 -0
  75. geek_cafe_saas_sdk/domains/files/services/__init__.py +0 -0
  76. geek_cafe_saas_sdk/domains/files/services/directory_service.py +701 -0
  77. geek_cafe_saas_sdk/domains/files/services/file_share_service.py +663 -0
  78. geek_cafe_saas_sdk/domains/files/services/file_system_service.py +575 -0
  79. geek_cafe_saas_sdk/domains/files/services/file_version_service.py +739 -0
  80. geek_cafe_saas_sdk/domains/files/services/s3_file_service.py +501 -0
  81. geek_cafe_saas_sdk/domains/messaging/__init__.py +0 -0
  82. geek_cafe_saas_sdk/domains/messaging/handlers/__init__.py +0 -0
  83. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/create/app.py +86 -0
  84. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/delete/app.py +65 -0
  85. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/get/app.py +64 -0
  86. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/list/app.py +97 -0
  87. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/update/app.py +149 -0
  88. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/create/app.py +67 -0
  89. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/delete/app.py +65 -0
  90. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/get/app.py +64 -0
  91. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/list/app.py +102 -0
  92. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/update/app.py +127 -0
  93. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/create/app.py +94 -0
  94. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/delete/app.py +66 -0
  95. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/get/app.py +67 -0
  96. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/list/app.py +95 -0
  97. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/update/app.py +156 -0
  98. geek_cafe_saas_sdk/domains/messaging/models/__init__.py +13 -0
  99. geek_cafe_saas_sdk/domains/messaging/models/chat_channel.py +337 -0
  100. geek_cafe_saas_sdk/domains/messaging/models/chat_channel_member.py +180 -0
  101. geek_cafe_saas_sdk/domains/messaging/models/chat_message.py +426 -0
  102. geek_cafe_saas_sdk/domains/messaging/models/contact_thread.py +392 -0
  103. geek_cafe_saas_sdk/domains/messaging/services/__init__.py +11 -0
  104. geek_cafe_saas_sdk/domains/messaging/services/chat_channel_service.py +700 -0
  105. geek_cafe_saas_sdk/domains/messaging/services/chat_message_service.py +491 -0
  106. geek_cafe_saas_sdk/domains/messaging/services/contact_thread_service.py +497 -0
  107. geek_cafe_saas_sdk/domains/tenancy/__init__.py +0 -0
  108. geek_cafe_saas_sdk/domains/tenancy/handlers/__init__.py +0 -0
  109. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/activate/app.py +52 -0
  110. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/active/app.py +37 -0
  111. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/cancel/app.py +55 -0
  112. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/get/app.py +39 -0
  113. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/list/app.py +44 -0
  114. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/record_payment/app.py +56 -0
  115. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/get/app.py +39 -0
  116. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/me/app.py +37 -0
  117. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/signup/app.py +61 -0
  118. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/update/app.py +44 -0
  119. geek_cafe_saas_sdk/domains/tenancy/models/__init__.py +6 -0
  120. geek_cafe_saas_sdk/domains/tenancy/models/subscription.py +440 -0
  121. geek_cafe_saas_sdk/domains/tenancy/models/tenant.py +258 -0
  122. geek_cafe_saas_sdk/domains/tenancy/services/__init__.py +6 -0
  123. geek_cafe_saas_sdk/domains/tenancy/services/subscription_service.py +557 -0
  124. geek_cafe_saas_sdk/domains/tenancy/services/tenant_service.py +575 -0
  125. geek_cafe_saas_sdk/domains/voting/__init__.py +0 -0
  126. geek_cafe_saas_sdk/domains/voting/handlers/__init__.py +0 -0
  127. geek_cafe_saas_sdk/domains/voting/handlers/votes/create/app.py +128 -0
  128. geek_cafe_saas_sdk/domains/voting/handlers/votes/delete/app.py +41 -0
  129. geek_cafe_saas_sdk/domains/voting/handlers/votes/get/app.py +39 -0
  130. geek_cafe_saas_sdk/domains/voting/handlers/votes/list/app.py +38 -0
  131. geek_cafe_saas_sdk/domains/voting/handlers/votes/summerize/README.md +3 -0
  132. geek_cafe_saas_sdk/domains/voting/handlers/votes/update/app.py +44 -0
  133. geek_cafe_saas_sdk/domains/voting/models/__init__.py +9 -0
  134. geek_cafe_saas_sdk/domains/voting/models/vote.py +231 -0
  135. geek_cafe_saas_sdk/domains/voting/models/vote_summary.py +193 -0
  136. geek_cafe_saas_sdk/domains/voting/services/__init__.py +11 -0
  137. geek_cafe_saas_sdk/domains/voting/services/vote_service.py +264 -0
  138. geek_cafe_saas_sdk/domains/voting/services/vote_summary_service.py +198 -0
  139. geek_cafe_saas_sdk/domains/voting/services/vote_tally_service.py +533 -0
  140. geek_cafe_saas_sdk/lambda_handlers/README.md +404 -0
  141. geek_cafe_saas_sdk/lambda_handlers/__init__.py +67 -0
  142. geek_cafe_saas_sdk/lambda_handlers/_base/__init__.py +25 -0
  143. geek_cafe_saas_sdk/lambda_handlers/_base/api_key_handler.py +129 -0
  144. geek_cafe_saas_sdk/lambda_handlers/_base/authorized_secure_handler.py +218 -0
  145. geek_cafe_saas_sdk/lambda_handlers/_base/base_handler.py +185 -0
  146. geek_cafe_saas_sdk/lambda_handlers/_base/handler_factory.py +256 -0
  147. geek_cafe_saas_sdk/lambda_handlers/_base/public_handler.py +53 -0
  148. geek_cafe_saas_sdk/lambda_handlers/_base/secure_handler.py +89 -0
  149. geek_cafe_saas_sdk/lambda_handlers/_base/service_pool.py +94 -0
  150. geek_cafe_saas_sdk/lambda_handlers/directories/create/app.py +79 -0
  151. geek_cafe_saas_sdk/lambda_handlers/directories/delete/app.py +76 -0
  152. geek_cafe_saas_sdk/lambda_handlers/directories/get/app.py +74 -0
  153. geek_cafe_saas_sdk/lambda_handlers/directories/list/app.py +75 -0
  154. geek_cafe_saas_sdk/lambda_handlers/directories/move/app.py +79 -0
  155. geek_cafe_saas_sdk/lambda_handlers/files/delete/app.py +121 -0
  156. geek_cafe_saas_sdk/lambda_handlers/files/download/app.py +187 -0
  157. geek_cafe_saas_sdk/lambda_handlers/files/get/app.py +127 -0
  158. geek_cafe_saas_sdk/lambda_handlers/files/list/app.py +108 -0
  159. geek_cafe_saas_sdk/lambda_handlers/files/share/app.py +83 -0
  160. geek_cafe_saas_sdk/lambda_handlers/files/shares/list/app.py +84 -0
  161. geek_cafe_saas_sdk/lambda_handlers/files/shares/revoke/app.py +76 -0
  162. geek_cafe_saas_sdk/lambda_handlers/files/update/app.py +143 -0
  163. geek_cafe_saas_sdk/lambda_handlers/files/upload/app.py +151 -0
  164. geek_cafe_saas_sdk/middleware/__init__.py +36 -0
  165. geek_cafe_saas_sdk/middleware/auth.py +85 -0
  166. geek_cafe_saas_sdk/middleware/authorization.py +523 -0
  167. geek_cafe_saas_sdk/middleware/cors.py +63 -0
  168. geek_cafe_saas_sdk/middleware/error_handling.py +114 -0
  169. geek_cafe_saas_sdk/middleware/validation.py +80 -0
  170. geek_cafe_saas_sdk/models/__init__.py +20 -0
  171. geek_cafe_saas_sdk/models/base_model.py +233 -0
  172. geek_cafe_saas_sdk/services/__init__.py +18 -0
  173. geek_cafe_saas_sdk/services/database_service.py +441 -0
  174. geek_cafe_saas_sdk/utilities/__init__.py +88 -0
  175. geek_cafe_saas_sdk/utilities/cognito_utility.py +568 -0
  176. geek_cafe_saas_sdk/utilities/custom_exceptions.py +183 -0
  177. geek_cafe_saas_sdk/utilities/datetime_utility.py +410 -0
  178. geek_cafe_saas_sdk/utilities/dictionary_utility.py +78 -0
  179. geek_cafe_saas_sdk/utilities/dynamodb_utils.py +151 -0
  180. geek_cafe_saas_sdk/utilities/environment_loader.py +149 -0
  181. geek_cafe_saas_sdk/utilities/environment_variables.py +228 -0
  182. geek_cafe_saas_sdk/utilities/http_body_parameters.py +44 -0
  183. geek_cafe_saas_sdk/utilities/http_path_parameters.py +60 -0
  184. geek_cafe_saas_sdk/utilities/http_status_code.py +63 -0
  185. geek_cafe_saas_sdk/utilities/jwt_utility.py +234 -0
  186. geek_cafe_saas_sdk/utilities/lambda_event_utility.py +776 -0
  187. geek_cafe_saas_sdk/utilities/logging_utility.py +64 -0
  188. geek_cafe_saas_sdk/utilities/message_query_helper.py +340 -0
  189. geek_cafe_saas_sdk/utilities/response.py +209 -0
  190. geek_cafe_saas_sdk/utilities/string_functions.py +180 -0
  191. geek_cafe_saas_sdk-0.6.0.dist-info/METADATA +397 -0
  192. geek_cafe_saas_sdk-0.6.0.dist-info/RECORD +194 -0
  193. geek_cafe_saas_sdk-0.6.0.dist-info/WHEEL +4 -0
  194. 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()