geek-cafe-saas-sdk 0.6.0__py3-none-any.whl → 0.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of geek-cafe-saas-sdk might be problematic. Click here for more details.

Files changed (94) hide show
  1. geek_cafe_saas_sdk/__init__.py +2 -2
  2. geek_cafe_saas_sdk/domains/files/handlers/README.md +446 -0
  3. geek_cafe_saas_sdk/domains/files/handlers/__init__.py +6 -0
  4. geek_cafe_saas_sdk/domains/files/handlers/files/create/app.py +121 -0
  5. geek_cafe_saas_sdk/domains/files/handlers/files/download/app.py +80 -0
  6. geek_cafe_saas_sdk/domains/files/handlers/files/get/app.py +62 -0
  7. geek_cafe_saas_sdk/domains/files/handlers/files/list/app.py +72 -0
  8. geek_cafe_saas_sdk/domains/files/handlers/lineage/create_derived/app.py +99 -0
  9. geek_cafe_saas_sdk/domains/files/handlers/lineage/create_main/app.py +104 -0
  10. geek_cafe_saas_sdk/domains/files/handlers/lineage/download_bundle/app.py +99 -0
  11. geek_cafe_saas_sdk/domains/files/handlers/lineage/get_lineage/app.py +68 -0
  12. geek_cafe_saas_sdk/domains/files/handlers/lineage/prepare_bundle/app.py +76 -0
  13. geek_cafe_saas_sdk/domains/files/models/__init__.py +17 -0
  14. geek_cafe_saas_sdk/domains/files/models/directory.py +42 -6
  15. geek_cafe_saas_sdk/domains/files/models/file.py +158 -16
  16. geek_cafe_saas_sdk/domains/files/models/file_share.py +33 -0
  17. geek_cafe_saas_sdk/domains/files/models/file_version.py +24 -0
  18. geek_cafe_saas_sdk/domains/files/services/__init__.py +21 -0
  19. geek_cafe_saas_sdk/domains/files/services/directory_service.py +54 -135
  20. geek_cafe_saas_sdk/domains/files/services/file_lineage_service.py +487 -0
  21. geek_cafe_saas_sdk/domains/files/services/file_share_service.py +37 -120
  22. geek_cafe_saas_sdk/domains/files/services/file_system_service.py +67 -103
  23. geek_cafe_saas_sdk/domains/files/services/file_version_service.py +44 -124
  24. geek_cafe_saas_sdk/domains/messaging/services/contact_thread_service.py +55 -7
  25. geek_cafe_saas_sdk/domains/notifications/__init__.py +18 -0
  26. geek_cafe_saas_sdk/domains/notifications/handlers/__init__.py +1 -0
  27. geek_cafe_saas_sdk/domains/notifications/handlers/create_webhook/app.py +73 -0
  28. geek_cafe_saas_sdk/domains/notifications/handlers/get/app.py +40 -0
  29. geek_cafe_saas_sdk/domains/notifications/handlers/get_preferences/app.py +34 -0
  30. geek_cafe_saas_sdk/domains/notifications/handlers/list/app.py +43 -0
  31. geek_cafe_saas_sdk/domains/notifications/handlers/list_webhooks/app.py +40 -0
  32. geek_cafe_saas_sdk/domains/notifications/handlers/mark_read/app.py +40 -0
  33. geek_cafe_saas_sdk/domains/notifications/handlers/send/app.py +83 -0
  34. geek_cafe_saas_sdk/domains/notifications/handlers/update_preferences/app.py +45 -0
  35. geek_cafe_saas_sdk/domains/notifications/models/__init__.py +16 -0
  36. geek_cafe_saas_sdk/domains/notifications/models/notification.py +717 -0
  37. geek_cafe_saas_sdk/domains/notifications/models/notification_preference.py +365 -0
  38. geek_cafe_saas_sdk/domains/notifications/models/webhook_subscription.py +339 -0
  39. geek_cafe_saas_sdk/domains/notifications/services/__init__.py +10 -0
  40. geek_cafe_saas_sdk/domains/notifications/services/notification_service.py +576 -0
  41. geek_cafe_saas_sdk/domains/payments/__init__.py +16 -0
  42. geek_cafe_saas_sdk/domains/payments/handlers/README.md +334 -0
  43. geek_cafe_saas_sdk/domains/payments/handlers/__init__.py +6 -0
  44. geek_cafe_saas_sdk/domains/payments/handlers/billing_accounts/create/app.py +105 -0
  45. geek_cafe_saas_sdk/domains/payments/handlers/billing_accounts/get/app.py +60 -0
  46. geek_cafe_saas_sdk/domains/payments/handlers/billing_accounts/update/app.py +97 -0
  47. geek_cafe_saas_sdk/domains/payments/handlers/payment_intents/create/app.py +97 -0
  48. geek_cafe_saas_sdk/domains/payments/handlers/payment_intents/get/app.py +60 -0
  49. geek_cafe_saas_sdk/domains/payments/handlers/payments/get/app.py +60 -0
  50. geek_cafe_saas_sdk/domains/payments/handlers/payments/list/app.py +68 -0
  51. geek_cafe_saas_sdk/domains/payments/handlers/payments/record/app.py +118 -0
  52. geek_cafe_saas_sdk/domains/payments/handlers/refunds/create/app.py +89 -0
  53. geek_cafe_saas_sdk/domains/payments/handlers/refunds/get/app.py +60 -0
  54. geek_cafe_saas_sdk/domains/payments/models/__init__.py +17 -0
  55. geek_cafe_saas_sdk/domains/payments/models/billing_account.py +521 -0
  56. geek_cafe_saas_sdk/domains/payments/models/payment.py +639 -0
  57. geek_cafe_saas_sdk/domains/payments/models/payment_intent_ref.py +539 -0
  58. geek_cafe_saas_sdk/domains/payments/models/refund.py +404 -0
  59. geek_cafe_saas_sdk/domains/payments/services/__init__.py +11 -0
  60. geek_cafe_saas_sdk/domains/payments/services/payment_service.py +405 -0
  61. geek_cafe_saas_sdk/domains/subscriptions/__init__.py +19 -0
  62. geek_cafe_saas_sdk/domains/subscriptions/handlers/README.md +408 -0
  63. geek_cafe_saas_sdk/domains/subscriptions/handlers/__init__.py +1 -0
  64. geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/create/app.py +81 -0
  65. geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/get/app.py +48 -0
  66. geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/list/app.py +54 -0
  67. geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/update/app.py +54 -0
  68. geek_cafe_saas_sdk/domains/subscriptions/handlers/discounts/create/app.py +83 -0
  69. geek_cafe_saas_sdk/domains/subscriptions/handlers/discounts/get/app.py +47 -0
  70. geek_cafe_saas_sdk/domains/subscriptions/handlers/discounts/validate/app.py +62 -0
  71. geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/create/app.py +82 -0
  72. geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/get/app.py +48 -0
  73. geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/list/app.py +66 -0
  74. geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/update/app.py +54 -0
  75. geek_cafe_saas_sdk/domains/subscriptions/handlers/usage/aggregate/app.py +72 -0
  76. geek_cafe_saas_sdk/domains/subscriptions/handlers/usage/record/app.py +89 -0
  77. geek_cafe_saas_sdk/domains/subscriptions/models/__init__.py +13 -0
  78. geek_cafe_saas_sdk/domains/subscriptions/models/addon.py +604 -0
  79. geek_cafe_saas_sdk/domains/subscriptions/models/discount.py +492 -0
  80. geek_cafe_saas_sdk/domains/subscriptions/models/plan.py +569 -0
  81. geek_cafe_saas_sdk/domains/subscriptions/models/usage_record.py +300 -0
  82. geek_cafe_saas_sdk/domains/subscriptions/services/__init__.py +10 -0
  83. geek_cafe_saas_sdk/domains/subscriptions/services/subscription_manager_service.py +694 -0
  84. geek_cafe_saas_sdk/domains/tenancy/models/subscription.py +123 -1
  85. geek_cafe_saas_sdk/domains/tenancy/services/subscription_service.py +213 -0
  86. geek_cafe_saas_sdk/lambda_handlers/_base/base_handler.py +7 -0
  87. geek_cafe_saas_sdk/services/database_service.py +10 -6
  88. geek_cafe_saas_sdk/utilities/cognito_utility.py +16 -26
  89. geek_cafe_saas_sdk/utilities/environment_variables.py +16 -0
  90. geek_cafe_saas_sdk/utilities/logging_utility.py +77 -0
  91. {geek_cafe_saas_sdk-0.6.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/METADATA +11 -11
  92. {geek_cafe_saas_sdk-0.6.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/RECORD +94 -23
  93. {geek_cafe_saas_sdk-0.6.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/WHEEL +0 -0
  94. {geek_cafe_saas_sdk-0.6.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/licenses/LICENSE +0 -0
@@ -19,12 +19,16 @@ class File(BaseModel):
19
19
  Represents a file in the system with metadata, virtual path, and S3 location.
20
20
  Does not contain file data (stored in S3) - only metadata and references.
21
21
 
22
+ Multi-Tenancy:
23
+ - tenant_id: Organization/company (can have multiple users)
24
+ - owner_id: Specific user within the tenant who owns this file
25
+
22
26
  Access Patterns (DynamoDB Keys):
23
27
  - pk: FILE#{tenant_id}#{file_id}
24
- - sk: METADATA
25
- - gsi1_pk: TENANT#{tenant_id}
26
- - gsi1_sk: DIRECTORY#{directory_id}#{file_name}
27
- - gsi2_pk: TENANT#{tenant_id}#USER#{owner_id}
28
+ - sk: metadata
29
+ - gsi1_pk: tenant#{tenant_id}
30
+ - gsi1_sk: directory#{directory_id}#{file_name}
31
+ - gsi2_pk: tenant#{tenant_id}#USER#{owner_id}
28
32
  - gsi2_sk: FILE#{created_utc_ts}
29
33
 
30
34
  Versioning Strategies:
@@ -35,11 +39,9 @@ class File(BaseModel):
35
39
  def __init__(self):
36
40
  super().__init__()
37
41
 
38
- # File Identity
39
- self._file_id: str | None = None # Unique file ID (same as self.id)
40
-
41
- # Ownership (inherited tenant_id from BaseModel)
42
- self._owner_id: str | None = None # User who owns the file
42
+ # Identity (inherited from BaseModel: id, tenant_id)
43
+ # Note: tenant_id = organization/company, owner_id = specific user within tenant
44
+ self._owner_id: str | None = None # User ID who owns this file
43
45
 
44
46
  # File Information
45
47
  self._file_name: str | None = None # Display name (e.g., "report.pdf")
@@ -70,25 +72,72 @@ class File(BaseModel):
70
72
  self._status: str = "active" # "active", "archived", "deleted"
71
73
  self._is_shared: bool = False # Has active shares
72
74
 
75
+ # Lineage Tracking (for data processing pipelines)
76
+ self._file_role: str = "standalone" # "standalone", "original", "main", "derived"
77
+ self._original_file_id: str | None = None # Root file in lineage chain
78
+ self._parent_file_id: str | None = None # Immediate parent file
79
+
80
+ # Transformation Tracking
81
+ self._transformation_type: str | None = None # "convert", "clean", "process"
82
+ self._transformation_operation: str | None = None # "xls_to_csv", "data_cleaning_v2"
83
+ self._transformation_metadata: Dict[str, Any] | None = None # Additional operation details
84
+
85
+ # Relationship Counts
86
+ self._derived_file_count: int = 0 # Number of files derived from this one
87
+
73
88
  # Timestamps (inherited from BaseModel)
74
89
  # created_utc_ts, updated_utc_ts, deleted_utc_ts
90
+
91
+ # CRITICAL: Call _setup_indexes() as LAST line in __init__
92
+ self._setup_indexes()
75
93
 
76
- # Properties - File Identity
94
+ def _setup_indexes(self):
95
+ """Setup DynamoDB indexes for file queries."""
96
+
97
+ # Primary index: File by ID
98
+ primary = DynamoDBIndex()
99
+ primary.name = "primary"
100
+ primary.partition_key.attribute_name = "pk"
101
+ primary.partition_key.value = lambda: DynamoDBKey.build_key(("file", self.id))
102
+ primary.sort_key.attribute_name = "sk"
103
+ primary.sort_key.value = lambda: "metadata"
104
+ self.indexes.add_primary(primary)
105
+
106
+ # GSI1: Files by directory
107
+ gsi = DynamoDBIndex()
108
+ gsi.name = "gsi1"
109
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
110
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(("tenant", self.tenant_id))
111
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
112
+ if self.directory_id:
113
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(("directory", self.directory_id), ("file", self.file_name))
114
+ else:
115
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(("root", "file"), ("name", self.file_name))
116
+ self.indexes.add_secondary(gsi)
117
+
118
+ # GSI2: Files by owner
119
+ gsi = DynamoDBIndex()
120
+ gsi.name = "gsi2"
121
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
122
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(("tenant", self.tenant_id), ("user", self.owner_id))
123
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
124
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(("file", self.created_utc_ts))
125
+ self.indexes.add_secondary(gsi)
126
+
127
+ # Properties - File Identity (alias for BaseModel.id)
77
128
  @property
78
129
  def file_id(self) -> str | None:
79
- """Unique file ID."""
80
- return self._file_id or self.id
130
+ """Unique file ID (alias for id)."""
131
+ return self.id
81
132
 
82
133
  @file_id.setter
83
134
  def file_id(self, value: str | None):
84
- self._file_id = value
85
- if value:
86
- self.id = value
135
+ self.id = value
87
136
 
88
137
  # Properties - Ownership
89
138
  @property
90
139
  def owner_id(self) -> str | None:
91
- """User who owns the file."""
140
+ """User ID who owns the file (not tenant_id - that's the organization)."""
92
141
  return self._owner_id
93
142
 
94
143
  @owner_id.setter
@@ -258,6 +307,74 @@ class File(BaseModel):
258
307
  def is_shared(self, value: bool):
259
308
  self._is_shared = bool(value)
260
309
 
310
+ # Properties - Lineage Tracking
311
+ @property
312
+ def file_role(self) -> str:
313
+ """File role in lineage chain: 'standalone', 'original', 'main', 'derived'."""
314
+ return self._file_role
315
+
316
+ @file_role.setter
317
+ def file_role(self, value: str | None):
318
+ valid_roles = ["standalone", "original", "main", "derived"]
319
+ if value in valid_roles:
320
+ self._file_role = value
321
+ else:
322
+ self._file_role = "standalone"
323
+
324
+ @property
325
+ def original_file_id(self) -> str | None:
326
+ """Root file in lineage chain."""
327
+ return self._original_file_id
328
+
329
+ @original_file_id.setter
330
+ def original_file_id(self, value: str | None):
331
+ self._original_file_id = value
332
+
333
+ @property
334
+ def parent_file_id(self) -> str | None:
335
+ """Immediate parent file."""
336
+ return self._parent_file_id
337
+
338
+ @parent_file_id.setter
339
+ def parent_file_id(self, value: str | None):
340
+ self._parent_file_id = value
341
+
342
+ @property
343
+ def transformation_type(self) -> str | None:
344
+ """Type of transformation applied: 'convert', 'clean', 'process'."""
345
+ return self._transformation_type
346
+
347
+ @transformation_type.setter
348
+ def transformation_type(self, value: str | None):
349
+ self._transformation_type = value
350
+
351
+ @property
352
+ def transformation_operation(self) -> str | None:
353
+ """Specific operation performed (e.g., 'xls_to_csv', 'data_cleaning_v2')."""
354
+ return self._transformation_operation
355
+
356
+ @transformation_operation.setter
357
+ def transformation_operation(self, value: str | None):
358
+ self._transformation_operation = value
359
+
360
+ @property
361
+ def transformation_metadata(self) -> Dict[str, Any] | None:
362
+ """Additional transformation details."""
363
+ return self._transformation_metadata
364
+
365
+ @transformation_metadata.setter
366
+ def transformation_metadata(self, value: Dict[str, Any] | None):
367
+ self._transformation_metadata = value if isinstance(value, dict) else None
368
+
369
+ @property
370
+ def derived_file_count(self) -> int:
371
+ """Number of files derived from this one."""
372
+ return self._derived_file_count
373
+
374
+ @derived_file_count.setter
375
+ def derived_file_count(self, value: int | None):
376
+ self._derived_file_count = value if isinstance(value, int) else 0
377
+
261
378
  # Helper Methods
262
379
  def is_active(self) -> bool:
263
380
  """Check if file is active."""
@@ -310,3 +427,28 @@ class File(BaseModel):
310
427
  if self._s3_bucket and self._s3_key:
311
428
  return f"s3://{self._s3_bucket}/{self._s3_key}"
312
429
  return None
430
+
431
+ # Lineage Helper Methods
432
+ def has_lineage(self) -> bool:
433
+ """Check if file participates in lineage tracking."""
434
+ return self._file_role != "standalone"
435
+
436
+ def is_original(self) -> bool:
437
+ """Check if this is an original file."""
438
+ return self._file_role == "original"
439
+
440
+ def is_main(self) -> bool:
441
+ """Check if this is a main file."""
442
+ return self._file_role == "main"
443
+
444
+ def is_derived(self) -> bool:
445
+ """Check if this is a derived file."""
446
+ return self._file_role == "derived"
447
+
448
+ def is_standalone(self) -> bool:
449
+ """Check if this is a standalone file (no lineage)."""
450
+ return self._file_role == "standalone"
451
+
452
+ def increment_derived_count(self):
453
+ """Increment the derived file count."""
454
+ self._derived_file_count += 1
@@ -63,6 +63,39 @@ class FileShare(BaseModel):
63
63
 
64
64
  # Timestamps (inherited from BaseModel)
65
65
  # created_utc_ts
66
+
67
+ # CRITICAL: Call _setup_indexes() as LAST line in __init__
68
+ self._setup_indexes()
69
+
70
+ def _setup_indexes(self):
71
+ """Setup DynamoDB indexes for file share queries."""
72
+
73
+ # Primary index: Share by ID
74
+ primary = DynamoDBIndex()
75
+ primary.name = "primary"
76
+ primary.partition_key.attribute_name = "pk"
77
+ primary.partition_key.value = lambda: DynamoDBKey.build_key(("share", self.id))
78
+ primary.sort_key.attribute_name = "sk"
79
+ primary.sort_key.value = lambda: DynamoDBKey.build_key(("share", self.id))
80
+ self.indexes.add_primary(primary)
81
+
82
+ # GSI1: Shares by file
83
+ gsi = DynamoDBIndex()
84
+ gsi.name = "gsi1"
85
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
86
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(("file", self.file_id))
87
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
88
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(("created", self.created_utc_ts))
89
+ self.indexes.add_secondary(gsi)
90
+
91
+ # GSI2: Shares by shared_with_user
92
+ gsi = DynamoDBIndex()
93
+ gsi.name = "gsi2"
94
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
95
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(("shared_with", self.shared_with_user_id))
96
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
97
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(("created", self.created_utc_ts))
98
+ self.indexes.add_secondary(gsi)
66
99
 
67
100
  # Properties - Identity
68
101
  @property
@@ -54,6 +54,30 @@ class FileVersion(BaseModel):
54
54
 
55
55
  # Timestamps (inherited from BaseModel)
56
56
  # created_utc_ts - when this version was created
57
+
58
+ # CRITICAL: Call _setup_indexes() as LAST line in __init__
59
+ self._setup_indexes()
60
+
61
+ def _setup_indexes(self):
62
+ """Setup DynamoDB indexes for file version queries."""
63
+
64
+ # Primary index: Version by ID
65
+ primary = DynamoDBIndex()
66
+ primary.name = "primary"
67
+ primary.partition_key.attribute_name = "pk"
68
+ primary.partition_key.value = lambda: DynamoDBKey.build_key(("file_version", self.id))
69
+ primary.sort_key.attribute_name = "sk"
70
+ primary.sort_key.value = lambda: DynamoDBKey.build_key(("version", self.id))
71
+ self.indexes.add_primary(primary)
72
+
73
+ # GSI1: Versions by file (ordered by version number)
74
+ gsi = DynamoDBIndex()
75
+ gsi.name = "gsi1"
76
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
77
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(("file", self.file_id))
78
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
79
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(("version", self.version_number))
80
+ self.indexes.add_secondary(gsi)
57
81
 
58
82
  # Properties - Identity
59
83
  @property
@@ -0,0 +1,21 @@
1
+ """File services.
2
+
3
+ Geek Cafe, LLC
4
+ MIT License. See Project Root for the license information.
5
+ """
6
+
7
+ from .file_system_service import FileSystemService
8
+ from .directory_service import DirectoryService
9
+ from .file_version_service import FileVersionService
10
+ from .file_share_service import FileShareService
11
+ from .s3_file_service import S3FileService
12
+ from .file_lineage_service import FileLineageService
13
+
14
+ __all__ = [
15
+ "FileSystemService",
16
+ "DirectoryService",
17
+ "FileVersionService",
18
+ "FileShareService",
19
+ "S3FileService",
20
+ "FileLineageService",
21
+ ]
@@ -113,28 +113,11 @@ class DirectoryService(DatabaseService[Directory]):
113
113
  directory.total_size = 0
114
114
 
115
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}"
116
+ directory.prep_for_save()
117
+ save_result = self._save_model(directory)
129
118
 
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
- )
119
+ if not save_result.success:
120
+ return save_result
138
121
 
139
122
  # Update parent's subdirectory count
140
123
  if parent_id:
@@ -172,22 +155,13 @@ class DirectoryService(DatabaseService[Directory]):
172
155
  ServiceResult with Directory model
173
156
  """
174
157
  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
- )
158
+ # Use helper method with tenant check
159
+ directory = self._get_model_by_id_with_tenant_check(resource_id, Directory, tenant_id)
182
160
 
183
161
  # Check if directory exists
184
- if not result or 'Item' not in result:
162
+ if not directory:
185
163
  raise NotFoundError(f"Directory not found: {resource_id}")
186
164
 
187
- # Convert to Directory model
188
- directory = Directory()
189
- directory.map(result['Item'])
190
-
191
165
  # Access control: Check if user is owner
192
166
  if directory.owner_id != user_id:
193
167
  # TODO: Check shared access
@@ -292,27 +266,8 @@ class DirectoryService(DatabaseService[Directory]):
292
266
  directory.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
293
267
 
294
268
  # 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)
269
+ directory.prep_for_save()
270
+ return self._save_model(directory)
316
271
 
317
272
  except (ValidationError, AccessDeniedError) as e:
318
273
  return ServiceResult.error_result(
@@ -369,17 +324,11 @@ class DirectoryService(DatabaseService[Directory]):
369
324
  directory.status = "deleted"
370
325
  directory.deleted_utc_ts = dt.datetime.now(dt.UTC).timestamp()
371
326
 
372
- pk = f"DIR#{tenant_id}#{resource_id}"
373
- sk = "METADATA"
327
+ directory.prep_for_save()
328
+ save_result = self._save_model(directory)
374
329
 
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
- )
330
+ if not save_result.success:
331
+ return save_result
383
332
 
384
333
  # Update parent's subdirectory count
385
334
  if directory.parent_id:
@@ -419,30 +368,26 @@ class DirectoryService(DatabaseService[Directory]):
419
368
  ServiceResult with list of Directory models
420
369
  """
421
370
  try:
422
- gsi1_pk = f"TENANT#{tenant_id}"
371
+ # Use GSI2 to query subdirectories by parent
372
+ temp_directory = Directory()
373
+ temp_directory.tenant_id = tenant_id
374
+ temp_directory.parent_id = parent_id # None for root directories
423
375
 
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
- )
376
+ # Query using helper method
377
+ query_result = self._query_by_index(temp_directory, "gsi2", limit=limit, ascending=True)
378
+
379
+ if not query_result.success:
380
+ return query_result
436
381
 
382
+ # Filter results
437
383
  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:
384
+ for directory in query_result.data:
385
+ # Debug: print what we're seeing
386
+ # print(f"Found dir: {directory.directory_name}, parent_id={directory.parent_id}, expected={parent_id}")
387
+ # Filter out deleted directories and apply access control
388
+ if directory.status != "deleted" and directory.owner_id == user_id:
389
+ # Only include directories whose parent_id matches what we're looking for
390
+ if directory.parent_id == parent_id:
446
391
  directories.append(directory)
447
392
 
448
393
  return ServiceResult.success_result(directories)
@@ -570,24 +515,11 @@ class DirectoryService(DatabaseService[Directory]):
570
515
  directory.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
571
516
 
572
517
  # 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}"
518
+ directory.prep_for_save()
519
+ save_result = self._save_model(directory)
586
520
 
587
- self.dynamodb.save(
588
- item=item,
589
- table_name=self.table_name
590
- )
521
+ if not save_result.success:
522
+ return save_result
591
523
 
592
524
  # Update parent counts
593
525
  if old_parent_id:
@@ -619,21 +551,23 @@ class DirectoryService(DatabaseService[Directory]):
619
551
  ) -> bool:
620
552
  """Check if directory name already exists in parent."""
621
553
  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}"
554
+ # Use GSI2 to query subdirectories by parent
555
+ temp_directory = Directory()
556
+ temp_directory.tenant_id = tenant_id
557
+ temp_directory.parent_id = parent_id
558
+ temp_directory.directory_name = directory_name
627
559
 
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
- )
560
+ query_result = self._query_by_index(temp_directory, "gsi2", limit=1)
561
+
562
+ if not query_result.success:
563
+ return False
634
564
 
635
- items = results.get('Items', [])
636
- return len(items) > 0
565
+ # Check if any non-deleted directories with this name exist
566
+ for directory in query_result.data:
567
+ if directory.directory_name == directory_name and directory.status != "deleted":
568
+ return True
569
+
570
+ return False
637
571
 
638
572
  except Exception:
639
573
  return False
@@ -646,29 +580,14 @@ class DirectoryService(DatabaseService[Directory]):
646
580
  ) -> None:
647
581
  """Increment or decrement subdirectory count."""
648
582
  try:
649
- pk = f"DIR#{tenant_id}#{directory_id}"
650
- sk = "METADATA"
583
+ # Get current directory using helper
584
+ directory = self._get_model_by_id_with_tenant_check(directory_id, Directory, tenant_id)
651
585
 
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
-
586
+ if directory:
662
587
  directory.subdirectory_count = max(0, directory.subdirectory_count + delta)
663
588
 
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
- )
589
+ directory.prep_for_save()
590
+ self._save_model(directory)
672
591
  except Exception:
673
592
  # Silent fail - this is a best-effort update
674
593
  pass