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,701 @@
1
+ """
2
+ DirectoryService for virtual directory hierarchy 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.directory import Directory
16
+ import datetime as dt
17
+
18
+
19
+ class DirectoryService(DatabaseService[Directory]):
20
+ """
21
+ Directory service for managing virtual directory hierarchy.
22
+
23
+ Handles:
24
+ - Directory creation and deletion
25
+ - Hierarchy traversal
26
+ - Path resolution
27
+ - Directory statistics (file count, size)
28
+ - Move/rename operations
29
+ """
30
+
31
+ def create(
32
+ self,
33
+ tenant_id: str,
34
+ user_id: str,
35
+ directory_name: str,
36
+ parent_id: Optional[str] = None,
37
+ description: Optional[str] = None,
38
+ color: Optional[str] = None,
39
+ icon: Optional[str] = None,
40
+ **kwargs
41
+ ) -> ServiceResult[Directory]:
42
+ """
43
+ Create a new directory.
44
+
45
+ Args:
46
+ tenant_id: Tenant ID
47
+ user_id: User ID (directory owner)
48
+ directory_name: Directory name
49
+ parent_id: Optional parent directory ID
50
+ description: Optional description
51
+ color: Optional color code
52
+ icon: Optional icon name
53
+
54
+ Returns:
55
+ ServiceResult with Directory model
56
+ """
57
+ try:
58
+ # Validate inputs
59
+ if not directory_name or not directory_name.strip():
60
+ raise ValidationError("Directory name is required", "directory_name")
61
+
62
+ # Validate directory name (no special chars)
63
+ if '/' in directory_name or '\\' in directory_name:
64
+ raise ValidationError(
65
+ "Directory name cannot contain slashes",
66
+ "directory_name"
67
+ )
68
+
69
+ # If parent specified, verify it exists
70
+ parent_dir = None
71
+ if parent_id:
72
+ parent_result = self.get_by_id(parent_id, tenant_id, user_id)
73
+ if not parent_result.success:
74
+ return ServiceResult.error_result(
75
+ message=f"Parent directory not found: {parent_id}",
76
+ error_code=ErrorCode.NOT_FOUND
77
+ )
78
+ parent_dir = parent_result.data
79
+
80
+ # Check for duplicate name in same parent
81
+ duplicate_check = self._check_duplicate_name(
82
+ tenant_id, directory_name, parent_id
83
+ )
84
+ if duplicate_check:
85
+ raise ValidationError(
86
+ f"Directory '{directory_name}' already exists in this location",
87
+ "directory_name"
88
+ )
89
+
90
+ # Create Directory model
91
+ directory = Directory()
92
+ directory.prep_for_save()
93
+ directory.tenant_id = tenant_id
94
+ directory.owner_id = user_id
95
+ directory.directory_name = directory_name
96
+ directory.parent_id = parent_id
97
+ directory.description = description
98
+ directory.color = color
99
+ directory.icon = icon
100
+ directory.status = "active"
101
+
102
+ # Calculate depth and full path
103
+ if parent_dir:
104
+ directory.depth = parent_dir.depth + 1
105
+ directory.full_path = f"{parent_dir.full_path}/{directory_name}"
106
+ else:
107
+ directory.depth = 0
108
+ directory.full_path = f"/{directory_name}"
109
+
110
+ # Initialize counters
111
+ directory.file_count = 0
112
+ directory.subdirectory_count = 0
113
+ directory.total_size = 0
114
+
115
+ # Save to DynamoDB
116
+ pk = f"DIR#{tenant_id}#{directory.directory_id}"
117
+ sk = "METADATA"
118
+
119
+ item = directory.to_dictionary()
120
+ item["pk"] = pk
121
+ item["sk"] = sk
122
+
123
+ # GSI1: Directories by parent
124
+ item["gsi1_pk"] = f"TENANT#{tenant_id}"
125
+ if parent_id:
126
+ item["gsi1_sk"] = f"PARENT#{parent_id}#{directory_name}"
127
+ else:
128
+ item["gsi1_sk"] = f"PARENT#ROOT#{directory_name}"
129
+
130
+ # GSI2: Directories by owner
131
+ item["gsi2_pk"] = f"TENANT#{tenant_id}#USER#{user_id}"
132
+ item["gsi2_sk"] = f"DIR#{directory.created_utc_ts}"
133
+
134
+ self.dynamodb.save(
135
+ item=item,
136
+ table_name=self.table_name
137
+ )
138
+
139
+ # Update parent's subdirectory count
140
+ if parent_id:
141
+ self._increment_subdirectory_count(tenant_id, parent_id, 1)
142
+
143
+ return ServiceResult.success_result(directory)
144
+
145
+ except ValidationError as e:
146
+ return ServiceResult.error_result(
147
+ message=str(e),
148
+ error_code=ErrorCode.VALIDATION_ERROR
149
+ )
150
+ except Exception as e:
151
+ return ServiceResult.exception_result(
152
+ exception=e,
153
+ error_code=ErrorCode.INTERNAL_ERROR,
154
+ context="DirectoryService.create"
155
+ )
156
+
157
+ def get_by_id(
158
+ self,
159
+ resource_id: str,
160
+ tenant_id: str,
161
+ user_id: str
162
+ ) -> ServiceResult[Directory]:
163
+ """
164
+ Get directory by ID with access control.
165
+
166
+ Args:
167
+ resource_id: Directory ID
168
+ tenant_id: Tenant ID
169
+ user_id: User ID (for access control)
170
+
171
+ Returns:
172
+ ServiceResult with Directory model
173
+ """
174
+ try:
175
+ pk = f"DIR#{tenant_id}#{resource_id}"
176
+ sk = "METADATA"
177
+
178
+ result = self.dynamodb.get(
179
+ table_name=self.table_name,
180
+ key={"pk": pk, "sk": sk}
181
+ )
182
+
183
+ # Check if directory exists
184
+ if not result or 'Item' not in result:
185
+ raise NotFoundError(f"Directory not found: {resource_id}")
186
+
187
+ # Convert to Directory model
188
+ directory = Directory()
189
+ directory.map(result['Item'])
190
+
191
+ # Access control: Check if user is owner
192
+ if directory.owner_id != user_id:
193
+ # TODO: Check shared access
194
+ raise AccessDeniedError("You do not have access to this directory")
195
+
196
+ return ServiceResult.success_result(directory)
197
+
198
+ except NotFoundError as e:
199
+ return ServiceResult.error_result(
200
+ message=str(e),
201
+ error_code=ErrorCode.NOT_FOUND
202
+ )
203
+ except AccessDeniedError as e:
204
+ return ServiceResult.error_result(
205
+ message=str(e),
206
+ error_code=ErrorCode.ACCESS_DENIED
207
+ )
208
+ except Exception as e:
209
+ return ServiceResult.exception_result(
210
+ exception=e,
211
+ error_code=ErrorCode.INTERNAL_ERROR,
212
+ context="DirectoryService.get_by_id"
213
+ )
214
+
215
+ def update(
216
+ self,
217
+ resource_id: str,
218
+ tenant_id: str,
219
+ user_id: str,
220
+ updates: Dict[str, Any]
221
+ ) -> ServiceResult[Directory]:
222
+ """
223
+ Update directory metadata.
224
+
225
+ Args:
226
+ resource_id: Directory ID
227
+ tenant_id: Tenant ID
228
+ user_id: User ID (for access control)
229
+ updates: Dictionary of fields to update
230
+
231
+ Returns:
232
+ ServiceResult with updated Directory model
233
+ """
234
+ try:
235
+ # Get existing directory
236
+ get_result = self.get_by_id(resource_id, tenant_id, user_id)
237
+ if not get_result.success:
238
+ return get_result
239
+
240
+ directory = get_result.data
241
+
242
+ # Only owner can update
243
+ if directory.owner_id != user_id:
244
+ raise AccessDeniedError("Only the owner can update this directory")
245
+
246
+ # Apply updates (only allowed fields)
247
+ allowed_fields = [
248
+ "directory_name", "description", "color", "icon", "status"
249
+ ]
250
+
251
+ # Handle directory rename
252
+ if "directory_name" in updates:
253
+ new_name = updates["directory_name"]
254
+ if not new_name or not new_name.strip():
255
+ raise ValidationError("Directory name cannot be empty", "directory_name")
256
+
257
+ if '/' in new_name or '\\' in new_name:
258
+ raise ValidationError(
259
+ "Directory name cannot contain slashes",
260
+ "directory_name"
261
+ )
262
+
263
+ # Check for duplicate
264
+ if new_name != directory.directory_name:
265
+ duplicate = self._check_duplicate_name(
266
+ tenant_id, new_name, directory.parent_id
267
+ )
268
+ if duplicate:
269
+ raise ValidationError(
270
+ f"Directory '{new_name}' already exists in this location",
271
+ "directory_name"
272
+ )
273
+
274
+ # Update full path
275
+ old_path = directory.full_path
276
+ if directory.parent_id:
277
+ # Get parent path
278
+ parent_result = self.get_by_id(directory.parent_id, tenant_id, user_id)
279
+ if parent_result.success:
280
+ directory.full_path = f"{parent_result.data.full_path}/{new_name}"
281
+ else:
282
+ directory.full_path = f"/{new_name}"
283
+
284
+ directory.directory_name = new_name
285
+
286
+ # Apply other updates
287
+ for field, value in updates.items():
288
+ if field in allowed_fields and field != "directory_name":
289
+ setattr(directory, field, value)
290
+
291
+ # Update timestamp
292
+ directory.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
293
+
294
+ # Save to DynamoDB
295
+ pk = f"DIR#{tenant_id}#{resource_id}"
296
+ sk = "METADATA"
297
+
298
+ item = directory.to_dictionary()
299
+ item["pk"] = pk
300
+ item["sk"] = sk
301
+
302
+ # Update GSI keys if name changed
303
+ if "directory_name" in updates:
304
+ item["gsi1_pk"] = f"TENANT#{tenant_id}"
305
+ if directory.parent_id:
306
+ item["gsi1_sk"] = f"PARENT#{directory.parent_id}#{directory.directory_name}"
307
+ else:
308
+ item["gsi1_sk"] = f"PARENT#ROOT#{directory.directory_name}"
309
+
310
+ self.dynamodb.save(
311
+ item=item,
312
+ table_name=self.table_name
313
+ )
314
+
315
+ return ServiceResult.success_result(directory)
316
+
317
+ except (ValidationError, AccessDeniedError) as e:
318
+ return ServiceResult.error_result(
319
+ message=str(e),
320
+ error_code=ErrorCode.VALIDATION_ERROR if isinstance(e, ValidationError) else ErrorCode.ACCESS_DENIED
321
+ )
322
+ except Exception as e:
323
+ return ServiceResult.exception_result(
324
+ exception=e,
325
+ error_code=ErrorCode.INTERNAL_ERROR,
326
+ context="DirectoryService.update"
327
+ )
328
+
329
+ def delete(
330
+ self,
331
+ resource_id: str,
332
+ tenant_id: str,
333
+ user_id: str,
334
+ recursive: bool = False
335
+ ) -> ServiceResult[bool]:
336
+ """
337
+ Delete directory.
338
+
339
+ Args:
340
+ resource_id: Directory ID
341
+ tenant_id: Tenant ID
342
+ user_id: User ID (for access control)
343
+ recursive: If True, delete subdirectories and files. If False, fail if not empty.
344
+
345
+ Returns:
346
+ ServiceResult with success boolean
347
+ """
348
+ try:
349
+ # Get existing directory
350
+ get_result = self.get_by_id(resource_id, tenant_id, user_id)
351
+ if not get_result.success:
352
+ return get_result
353
+
354
+ directory = get_result.data
355
+
356
+ # Only owner can delete
357
+ if directory.owner_id != user_id:
358
+ raise AccessDeniedError("Only the owner can delete this directory")
359
+
360
+ # Check if directory is empty
361
+ if not recursive and (directory.file_count > 0 or directory.subdirectory_count > 0):
362
+ raise ValidationError(
363
+ "Directory is not empty. Use recursive=True to delete contents.",
364
+ "recursive"
365
+ )
366
+
367
+ # TODO: If recursive, delete all subdirectories and files
368
+ # For now, just soft delete the directory
369
+ directory.status = "deleted"
370
+ directory.deleted_utc_ts = dt.datetime.now(dt.UTC).timestamp()
371
+
372
+ pk = f"DIR#{tenant_id}#{resource_id}"
373
+ sk = "METADATA"
374
+
375
+ item = directory.to_dictionary()
376
+ item["pk"] = pk
377
+ item["sk"] = sk
378
+
379
+ self.dynamodb.save(
380
+ item=item,
381
+ table_name=self.table_name
382
+ )
383
+
384
+ # Update parent's subdirectory count
385
+ if directory.parent_id:
386
+ self._increment_subdirectory_count(tenant_id, directory.parent_id, -1)
387
+
388
+ return ServiceResult.success_result(True)
389
+
390
+ except (ValidationError, AccessDeniedError) as e:
391
+ return ServiceResult.error_result(
392
+ message=str(e),
393
+ error_code=ErrorCode.VALIDATION_ERROR if isinstance(e, ValidationError) else ErrorCode.ACCESS_DENIED
394
+ )
395
+ except Exception as e:
396
+ return ServiceResult.exception_result(
397
+ exception=e,
398
+ error_code=ErrorCode.INTERNAL_ERROR,
399
+ context="DirectoryService.delete"
400
+ )
401
+
402
+ def list_subdirectories(
403
+ self,
404
+ tenant_id: str,
405
+ parent_id: Optional[str],
406
+ user_id: str,
407
+ limit: int = 50
408
+ ) -> ServiceResult[List[Directory]]:
409
+ """
410
+ List subdirectories in a directory.
411
+
412
+ Args:
413
+ tenant_id: Tenant ID
414
+ parent_id: Parent directory ID (None for root)
415
+ user_id: User ID (for access control)
416
+ limit: Maximum number of results
417
+
418
+ Returns:
419
+ ServiceResult with list of Directory models
420
+ """
421
+ try:
422
+ gsi1_pk = f"TENANT#{tenant_id}"
423
+
424
+ if parent_id:
425
+ gsi1_sk_prefix = f"PARENT#{parent_id}#"
426
+ else:
427
+ gsi1_sk_prefix = "PARENT#ROOT#"
428
+
429
+ # Query GSI1
430
+ results = self.dynamodb.query(
431
+ key=Key('gsi1_pk').eq(gsi1_pk) & Key('gsi1_sk').begins_with(gsi1_sk_prefix),
432
+ table_name=self.table_name,
433
+ index_name="gsi1",
434
+ limit=limit
435
+ )
436
+
437
+ directories = []
438
+ for item in results.get('Items', []):
439
+ directory = Directory()
440
+ directory.map(item)
441
+
442
+ # Filter out deleted directories
443
+ if directory.status != "deleted":
444
+ # Basic access control: show only owned directories
445
+ if directory.owner_id == user_id:
446
+ directories.append(directory)
447
+
448
+ return ServiceResult.success_result(directories)
449
+
450
+ except Exception as e:
451
+ return ServiceResult.exception_result(
452
+ exception=e,
453
+ error_code=ErrorCode.INTERNAL_ERROR,
454
+ context="DirectoryService.list_subdirectories"
455
+ )
456
+
457
+ def get_path_components(
458
+ self,
459
+ directory_id: str,
460
+ tenant_id: str,
461
+ user_id: str
462
+ ) -> ServiceResult[List[Directory]]:
463
+ """
464
+ Get all directories in the path from root to target directory.
465
+
466
+ Args:
467
+ directory_id: Target directory ID
468
+ tenant_id: Tenant ID
469
+ user_id: User ID
470
+
471
+ Returns:
472
+ ServiceResult with list of Directory models (root to target)
473
+ """
474
+ try:
475
+ path = []
476
+ current_id = directory_id
477
+
478
+ # Traverse up to root
479
+ while current_id:
480
+ result = self.get_by_id(current_id, tenant_id, user_id)
481
+ if not result.success:
482
+ return result
483
+
484
+ directory = result.data
485
+ path.insert(0, directory) # Prepend to build root-to-target order
486
+ current_id = directory.parent_id
487
+
488
+ return ServiceResult.success_result(path)
489
+
490
+ except Exception as e:
491
+ return ServiceResult.exception_result(
492
+ exception=e,
493
+ error_code=ErrorCode.INTERNAL_ERROR,
494
+ context="DirectoryService.get_path_components"
495
+ )
496
+
497
+ def move_directory(
498
+ self,
499
+ directory_id: str,
500
+ new_parent_id: Optional[str],
501
+ tenant_id: str,
502
+ user_id: str
503
+ ) -> ServiceResult[Directory]:
504
+ """
505
+ Move directory to a new parent.
506
+
507
+ Args:
508
+ directory_id: Directory to move
509
+ new_parent_id: New parent directory ID (None for root)
510
+ tenant_id: Tenant ID
511
+ user_id: User ID
512
+
513
+ Returns:
514
+ ServiceResult with updated Directory model
515
+ """
516
+ try:
517
+ # Get directory to move
518
+ get_result = self.get_by_id(directory_id, tenant_id, user_id)
519
+ if not get_result.success:
520
+ return get_result
521
+
522
+ directory = get_result.data
523
+
524
+ # Only owner can move
525
+ if directory.owner_id != user_id:
526
+ raise AccessDeniedError("Only the owner can move this directory")
527
+
528
+ # Can't move to itself
529
+ if directory_id == new_parent_id:
530
+ raise ValidationError("Cannot move directory to itself", "new_parent_id")
531
+
532
+ # Verify new parent exists and is not a descendant
533
+ if new_parent_id:
534
+ parent_result = self.get_by_id(new_parent_id, tenant_id, user_id)
535
+ if not parent_result.success:
536
+ return ServiceResult.error_result(
537
+ message=f"Target parent directory not found: {new_parent_id}",
538
+ error_code=ErrorCode.NOT_FOUND
539
+ )
540
+
541
+ parent = parent_result.data
542
+
543
+ # Check if new parent is a descendant (would create cycle)
544
+ if self._is_descendant(tenant_id, user_id, new_parent_id, directory_id):
545
+ raise ValidationError(
546
+ "Cannot move directory into its own subdirectory",
547
+ "new_parent_id"
548
+ )
549
+
550
+ # Check for duplicate name
551
+ duplicate = self._check_duplicate_name(
552
+ tenant_id, directory.directory_name, new_parent_id
553
+ )
554
+ if duplicate:
555
+ raise ValidationError(
556
+ f"Directory '{directory.directory_name}' already exists in target location",
557
+ "directory_name"
558
+ )
559
+
560
+ # Update depth and path
561
+ directory.depth = parent.depth + 1
562
+ directory.full_path = f"{parent.full_path}/{directory.directory_name}"
563
+ else:
564
+ # Moving to root
565
+ directory.depth = 0
566
+ directory.full_path = f"/{directory.directory_name}"
567
+
568
+ old_parent_id = directory.parent_id
569
+ directory.parent_id = new_parent_id
570
+ directory.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
571
+
572
+ # Save to DynamoDB
573
+ pk = f"DIR#{tenant_id}#{directory_id}"
574
+ sk = "METADATA"
575
+
576
+ item = directory.to_dictionary()
577
+ item["pk"] = pk
578
+ item["sk"] = sk
579
+
580
+ # Update GSI1 for new parent
581
+ item["gsi1_pk"] = f"TENANT#{tenant_id}"
582
+ if new_parent_id:
583
+ item["gsi1_sk"] = f"PARENT#{new_parent_id}#{directory.directory_name}"
584
+ else:
585
+ item["gsi1_sk"] = f"PARENT#ROOT#{directory.directory_name}"
586
+
587
+ self.dynamodb.save(
588
+ item=item,
589
+ table_name=self.table_name
590
+ )
591
+
592
+ # Update parent counts
593
+ if old_parent_id:
594
+ self._increment_subdirectory_count(tenant_id, old_parent_id, -1)
595
+ if new_parent_id:
596
+ self._increment_subdirectory_count(tenant_id, new_parent_id, 1)
597
+
598
+ return ServiceResult.success_result(directory)
599
+
600
+ except (ValidationError, AccessDeniedError) as e:
601
+ return ServiceResult.error_result(
602
+ message=str(e),
603
+ error_code=ErrorCode.VALIDATION_ERROR if isinstance(e, ValidationError) else ErrorCode.ACCESS_DENIED
604
+ )
605
+ except Exception as e:
606
+ return ServiceResult.exception_result(
607
+ exception=e,
608
+ error_code=ErrorCode.INTERNAL_ERROR,
609
+ context="DirectoryService.move_directory"
610
+ )
611
+
612
+ # Helper methods
613
+
614
+ def _check_duplicate_name(
615
+ self,
616
+ tenant_id: str,
617
+ directory_name: str,
618
+ parent_id: Optional[str]
619
+ ) -> bool:
620
+ """Check if directory name already exists in parent."""
621
+ try:
622
+ gsi1_pk = f"TENANT#{tenant_id}"
623
+ if parent_id:
624
+ gsi1_sk = f"PARENT#{parent_id}#{directory_name}"
625
+ else:
626
+ gsi1_sk = f"PARENT#ROOT#{directory_name}"
627
+
628
+ results = self.dynamodb.query(
629
+ key=Key('gsi1_pk').eq(gsi1_pk) & Key('gsi1_sk').eq(gsi1_sk),
630
+ table_name=self.table_name,
631
+ index_name="gsi1",
632
+ limit=1
633
+ )
634
+
635
+ items = results.get('Items', [])
636
+ return len(items) > 0
637
+
638
+ except Exception:
639
+ return False
640
+
641
+ def _increment_subdirectory_count(
642
+ self,
643
+ tenant_id: str,
644
+ directory_id: str,
645
+ delta: int
646
+ ) -> None:
647
+ """Increment or decrement subdirectory count."""
648
+ try:
649
+ pk = f"DIR#{tenant_id}#{directory_id}"
650
+ sk = "METADATA"
651
+
652
+ # Get current directory
653
+ result = self.dynamodb.get(
654
+ table_name=self.table_name,
655
+ key={"pk": pk, "sk": sk}
656
+ )
657
+
658
+ if result and 'Item' in result:
659
+ directory = Directory()
660
+ directory.map(result['Item'])
661
+
662
+ directory.subdirectory_count = max(0, directory.subdirectory_count + delta)
663
+
664
+ item = directory.to_dictionary()
665
+ item["pk"] = pk
666
+ item["sk"] = sk
667
+
668
+ self.dynamodb.save(
669
+ item=item,
670
+ table_name=self.table_name
671
+ )
672
+ except Exception:
673
+ # Silent fail - this is a best-effort update
674
+ pass
675
+
676
+ def _is_descendant(
677
+ self,
678
+ tenant_id: str,
679
+ user_id: str,
680
+ potential_descendant_id: str,
681
+ ancestor_id: str
682
+ ) -> bool:
683
+ """Check if potential_descendant is a descendant of ancestor."""
684
+ try:
685
+ current_id = potential_descendant_id
686
+
687
+ # Traverse up the tree
688
+ while current_id:
689
+ if current_id == ancestor_id:
690
+ return True
691
+
692
+ result = self.get_by_id(current_id, tenant_id, user_id)
693
+ if not result.success:
694
+ return False
695
+
696
+ current_id = result.data.parent_id
697
+
698
+ return False
699
+
700
+ except Exception:
701
+ return False