binalyze-air-sdk 1.0.1__py3-none-any.whl → 1.0.3__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.
Files changed (142) hide show
  1. binalyze_air/__init__.py +77 -77
  2. binalyze_air/apis/__init__.py +67 -27
  3. binalyze_air/apis/acquisitions.py +107 -0
  4. binalyze_air/apis/api_tokens.py +49 -0
  5. binalyze_air/apis/assets.py +161 -0
  6. binalyze_air/apis/audit_logs.py +26 -0
  7. binalyze_air/apis/{authentication.py → auth.py} +29 -27
  8. binalyze_air/apis/auto_asset_tags.py +79 -75
  9. binalyze_air/apis/backup.py +177 -0
  10. binalyze_air/apis/baseline.py +46 -0
  11. binalyze_air/apis/cases.py +225 -0
  12. binalyze_air/apis/cloud_forensics.py +116 -0
  13. binalyze_air/apis/event_subscription.py +96 -96
  14. binalyze_air/apis/evidence.py +249 -53
  15. binalyze_air/apis/interact.py +153 -36
  16. binalyze_air/apis/investigation_hub.py +234 -0
  17. binalyze_air/apis/license.py +104 -0
  18. binalyze_air/apis/logger.py +83 -0
  19. binalyze_air/apis/multipart_upload.py +201 -0
  20. binalyze_air/apis/notifications.py +115 -0
  21. binalyze_air/apis/organizations.py +267 -0
  22. binalyze_air/apis/params.py +44 -39
  23. binalyze_air/apis/policies.py +186 -0
  24. binalyze_air/apis/preset_filters.py +79 -0
  25. binalyze_air/apis/recent_activities.py +71 -0
  26. binalyze_air/apis/relay_server.py +104 -0
  27. binalyze_air/apis/settings.py +395 -27
  28. binalyze_air/apis/tasks.py +80 -0
  29. binalyze_air/apis/triage.py +197 -0
  30. binalyze_air/apis/user_management.py +183 -74
  31. binalyze_air/apis/webhook_executions.py +50 -0
  32. binalyze_air/apis/webhooks.py +322 -230
  33. binalyze_air/base.py +207 -133
  34. binalyze_air/client.py +217 -1337
  35. binalyze_air/commands/__init__.py +175 -145
  36. binalyze_air/commands/acquisitions.py +661 -387
  37. binalyze_air/commands/api_tokens.py +55 -0
  38. binalyze_air/commands/assets.py +324 -362
  39. binalyze_air/commands/{authentication.py → auth.py} +36 -36
  40. binalyze_air/commands/auto_asset_tags.py +230 -230
  41. binalyze_air/commands/backup.py +47 -0
  42. binalyze_air/commands/baseline.py +32 -396
  43. binalyze_air/commands/cases.py +609 -602
  44. binalyze_air/commands/cloud_forensics.py +88 -0
  45. binalyze_air/commands/event_subscription.py +101 -101
  46. binalyze_air/commands/evidences.py +918 -988
  47. binalyze_air/commands/interact.py +172 -58
  48. binalyze_air/commands/investigation_hub.py +315 -0
  49. binalyze_air/commands/license.py +183 -0
  50. binalyze_air/commands/logger.py +126 -0
  51. binalyze_air/commands/multipart_upload.py +363 -0
  52. binalyze_air/commands/notifications.py +45 -0
  53. binalyze_air/commands/organizations.py +200 -221
  54. binalyze_air/commands/policies.py +175 -203
  55. binalyze_air/commands/preset_filters.py +55 -0
  56. binalyze_air/commands/recent_activities.py +32 -0
  57. binalyze_air/commands/relay_server.py +144 -0
  58. binalyze_air/commands/settings.py +431 -29
  59. binalyze_air/commands/tasks.py +95 -56
  60. binalyze_air/commands/triage.py +224 -360
  61. binalyze_air/commands/user_management.py +351 -126
  62. binalyze_air/commands/webhook_executions.py +77 -0
  63. binalyze_air/config.py +244 -244
  64. binalyze_air/exceptions.py +49 -49
  65. binalyze_air/http_client.py +426 -305
  66. binalyze_air/models/__init__.py +287 -285
  67. binalyze_air/models/acquisitions.py +365 -250
  68. binalyze_air/models/api_tokens.py +73 -0
  69. binalyze_air/models/assets.py +438 -438
  70. binalyze_air/models/audit.py +247 -272
  71. binalyze_air/models/audit_logs.py +14 -0
  72. binalyze_air/models/{authentication.py → auth.py} +69 -69
  73. binalyze_air/models/auto_asset_tags.py +227 -116
  74. binalyze_air/models/backup.py +138 -0
  75. binalyze_air/models/baseline.py +231 -231
  76. binalyze_air/models/cases.py +275 -275
  77. binalyze_air/models/cloud_forensics.py +145 -0
  78. binalyze_air/models/event_subscription.py +170 -171
  79. binalyze_air/models/evidence.py +65 -65
  80. binalyze_air/models/evidences.py +367 -348
  81. binalyze_air/models/interact.py +266 -135
  82. binalyze_air/models/investigation_hub.py +265 -0
  83. binalyze_air/models/license.py +150 -0
  84. binalyze_air/models/logger.py +83 -0
  85. binalyze_air/models/multipart_upload.py +352 -0
  86. binalyze_air/models/notifications.py +138 -0
  87. binalyze_air/models/organizations.py +293 -293
  88. binalyze_air/models/params.py +153 -127
  89. binalyze_air/models/policies.py +260 -249
  90. binalyze_air/models/preset_filters.py +79 -0
  91. binalyze_air/models/recent_activities.py +70 -0
  92. binalyze_air/models/relay_server.py +121 -0
  93. binalyze_air/models/settings.py +538 -84
  94. binalyze_air/models/tasks.py +215 -149
  95. binalyze_air/models/triage.py +141 -142
  96. binalyze_air/models/user_management.py +200 -97
  97. binalyze_air/models/webhook_executions.py +33 -0
  98. binalyze_air/queries/__init__.py +121 -133
  99. binalyze_air/queries/acquisitions.py +155 -155
  100. binalyze_air/queries/api_tokens.py +46 -0
  101. binalyze_air/queries/assets.py +186 -105
  102. binalyze_air/queries/audit.py +400 -416
  103. binalyze_air/queries/{authentication.py → auth.py} +55 -55
  104. binalyze_air/queries/auto_asset_tags.py +59 -59
  105. binalyze_air/queries/backup.py +66 -0
  106. binalyze_air/queries/baseline.py +21 -185
  107. binalyze_air/queries/cases.py +292 -292
  108. binalyze_air/queries/cloud_forensics.py +137 -0
  109. binalyze_air/queries/event_subscription.py +54 -54
  110. binalyze_air/queries/evidence.py +139 -139
  111. binalyze_air/queries/evidences.py +279 -279
  112. binalyze_air/queries/interact.py +140 -28
  113. binalyze_air/queries/investigation_hub.py +329 -0
  114. binalyze_air/queries/license.py +85 -0
  115. binalyze_air/queries/logger.py +58 -0
  116. binalyze_air/queries/multipart_upload.py +180 -0
  117. binalyze_air/queries/notifications.py +71 -0
  118. binalyze_air/queries/organizations.py +222 -222
  119. binalyze_air/queries/params.py +154 -115
  120. binalyze_air/queries/policies.py +149 -149
  121. binalyze_air/queries/preset_filters.py +60 -0
  122. binalyze_air/queries/recent_activities.py +44 -0
  123. binalyze_air/queries/relay_server.py +42 -0
  124. binalyze_air/queries/settings.py +533 -20
  125. binalyze_air/queries/tasks.py +125 -81
  126. binalyze_air/queries/triage.py +230 -230
  127. binalyze_air/queries/user_management.py +193 -83
  128. binalyze_air/queries/webhook_executions.py +39 -0
  129. binalyze_air_sdk-1.0.3.dist-info/METADATA +752 -0
  130. binalyze_air_sdk-1.0.3.dist-info/RECORD +132 -0
  131. {binalyze_air_sdk-1.0.1.dist-info → binalyze_air_sdk-1.0.3.dist-info}/WHEEL +1 -1
  132. binalyze_air/apis/endpoints.py +0 -22
  133. binalyze_air/apis/evidences.py +0 -216
  134. binalyze_air/apis/users.py +0 -68
  135. binalyze_air/commands/users.py +0 -101
  136. binalyze_air/models/endpoints.py +0 -76
  137. binalyze_air/models/users.py +0 -82
  138. binalyze_air/queries/endpoints.py +0 -25
  139. binalyze_air/queries/users.py +0 -69
  140. binalyze_air_sdk-1.0.1.dist-info/METADATA +0 -635
  141. binalyze_air_sdk-1.0.1.dist-info/RECORD +0 -82
  142. {binalyze_air_sdk-1.0.1.dist-info → binalyze_air_sdk-1.0.3.dist-info}/top_level.txt +0 -0
@@ -1,603 +1,610 @@
1
- """
2
- Case-related commands for the Binalyze AIR SDK.
3
- Fixed to match API documentation exactly.
4
- """
5
-
6
- from typing import List, Dict, Any, Optional
7
-
8
- from ..base import Command
9
- from ..models.cases import CreateCaseRequest, UpdateCaseRequest, Case, CaseNote
10
- from ..models.assets import AssetFilter
11
- from ..http_client import HTTPClient
12
-
13
-
14
- class CreateCaseCommand(Command[Case]):
15
- """Command to create a new case - FIXED endpoint URL."""
16
-
17
- def __init__(self, http_client: HTTPClient, case_data: CreateCaseRequest):
18
- self.http_client = http_client
19
- self.case_data = case_data
20
-
21
- def execute(self) -> Case:
22
- """Execute the create case command."""
23
- payload = {
24
- "organizationId": self.case_data.organization_id,
25
- "name": self.case_data.name,
26
- "ownerUserId": self.case_data.owner_user_id,
27
- "visibility": self.case_data.visibility,
28
- "assignedUserIds": self.case_data.assigned_user_ids,
29
- }
30
-
31
- # FIXED: Remove api/public/ prefix
32
- response = self.http_client.post("cases", json_data=payload)
33
-
34
- # Use Pydantic parsing with proper field aliasing
35
- entity_data = response.get("result", {})
36
- return Case.model_validate(entity_data)
37
-
38
-
39
- class UpdateCaseCommand(Command[Case]):
40
- """Command to update an existing case - FIXED endpoint URL."""
41
-
42
- def __init__(self, http_client: HTTPClient, case_id: str, update_data: UpdateCaseRequest):
43
- self.http_client = http_client
44
- self.case_id = case_id
45
- self.update_data = update_data
46
-
47
- def execute(self) -> Case:
48
- """Execute the update case command."""
49
- payload = {}
50
-
51
- # Only include fields that are set
52
- if self.update_data.name is not None:
53
- payload["name"] = self.update_data.name
54
- if self.update_data.owner_user_id is not None:
55
- payload["ownerUserId"] = self.update_data.owner_user_id
56
- if self.update_data.visibility is not None:
57
- payload["visibility"] = self.update_data.visibility
58
- if self.update_data.assigned_user_ids is not None:
59
- payload["assignedUserIds"] = self.update_data.assigned_user_ids
60
- if self.update_data.status is not None:
61
- payload["status"] = self.update_data.status
62
- if self.update_data.notes is not None:
63
- payload["notes"] = self.update_data.notes
64
-
65
- # FIXED: Use PATCH method to match API specification (was PUT)
66
- response = self.http_client.patch(f"cases/{self.case_id}", json_data=payload)
67
-
68
- # Use Pydantic parsing with proper field aliasing
69
- entity_data = response.get("result", {})
70
- return Case.model_validate(entity_data)
71
-
72
-
73
- class CloseCaseCommand(Command[Case]):
74
- """Command to close a case - FIXED endpoint URL."""
75
-
76
- def __init__(self, http_client: HTTPClient, case_id: str):
77
- self.http_client = http_client
78
- self.case_id = case_id
79
-
80
- def execute(self) -> Case:
81
- """Execute the close case command."""
82
- # FIXED: Remove api/public/ prefix
83
- response = self.http_client.post(f"cases/{self.case_id}/close", json_data={})
84
-
85
- # Use Pydantic parsing with proper field aliasing
86
- entity_data = response.get("result", {})
87
- return Case.model_validate(entity_data)
88
-
89
-
90
- class OpenCaseCommand(Command[Case]):
91
- """Command to open a case - FIXED endpoint URL."""
92
-
93
- def __init__(self, http_client: HTTPClient, case_id: str):
94
- self.http_client = http_client
95
- self.case_id = case_id
96
-
97
- def execute(self) -> Case:
98
- """Execute the open case command."""
99
- # FIXED: Remove api/public/ prefix
100
- response = self.http_client.post(f"cases/{self.case_id}/open", json_data={})
101
-
102
- # Use Pydantic parsing with proper field aliasing
103
- entity_data = response.get("result", {})
104
- return Case.model_validate(entity_data)
105
-
106
-
107
- class ArchiveCaseCommand(Command[Case]):
108
- """Command to archive a case - FIXED endpoint URL."""
109
-
110
- def __init__(self, http_client: HTTPClient, case_id: str):
111
- self.http_client = http_client
112
- self.case_id = case_id
113
-
114
- def execute(self) -> Case:
115
- """Execute the archive case command."""
116
- # FIXED: Remove api/public/ prefix
117
- response = self.http_client.post(f"cases/{self.case_id}/archive", json_data={})
118
-
119
- # Use Pydantic parsing with proper field aliasing
120
- entity_data = response.get("result", {})
121
- return Case.model_validate(entity_data)
122
-
123
-
124
- class ChangeCaseOwnerCommand(Command[Case]):
125
- """Command to change case owner - FIXED to match API specification exactly."""
126
-
127
- def __init__(self, http_client: HTTPClient, case_id: str, new_owner_id: str):
128
- self.http_client = http_client
129
- self.case_id = case_id
130
- self.new_owner_id = new_owner_id
131
-
132
- def execute(self) -> Case:
133
- """Execute the change case owner command."""
134
- # FIXED: Use correct payload field name as per API specification
135
- payload = {"newOwnerId": self.new_owner_id}
136
-
137
- # FIXED: Use correct endpoint URL and HTTP method as per API specification
138
- # POST /api/public/cases/{id}/change-owner
139
- response = self.http_client.post(f"cases/{self.case_id}/change-owner", json_data=payload)
140
-
141
- # Use Pydantic parsing with proper field aliasing
142
- entity_data = response.get("result", {})
143
- return Case.model_validate(entity_data)
144
-
145
-
146
- class RemoveEndpointsFromCaseCommand(Command[Dict[str, Any]]):
147
- """Command to remove endpoints from a case - FIXED to match API documentation exactly."""
148
-
149
- def __init__(self, http_client: HTTPClient, case_id: str, asset_filter: AssetFilter):
150
- self.http_client = http_client
151
- self.case_id = case_id
152
- self.asset_filter = asset_filter
153
-
154
- def execute(self) -> Dict[str, Any]:
155
- """Execute the remove endpoints from case command with correct structure."""
156
- # FIXED: Use proper filter structure as per API documentation
157
- payload = {
158
- "filter": self.asset_filter.to_filter_dict()
159
- }
160
-
161
- # FIXED: Correct endpoint URL and HTTP method (DELETE)
162
- return self.http_client.delete(f"cases/{self.case_id}/endpoints", json_data=payload)
163
-
164
-
165
- class RemoveTaskAssignmentFromCaseCommand(Command[Dict[str, Any]]):
166
- """Command to remove task assignment from a case - FIXED endpoint URL."""
167
-
168
- def __init__(self, http_client: HTTPClient, case_id: str, task_assignment_id: str):
169
- self.http_client = http_client
170
- self.case_id = case_id
171
- self.task_assignment_id = task_assignment_id
172
-
173
- def execute(self) -> Dict[str, Any]:
174
- """Execute the remove task assignment from case command."""
175
- # FIXED: Remove api/public/ prefix
176
- return self.http_client.post(
177
- f"cases/{self.case_id}/tasks/{self.task_assignment_id}/remove",
178
- json_data={}
179
- )
180
-
181
-
182
- class ImportTaskAssignmentsToCaseCommand(Command[Dict[str, Any]]):
183
- """Command to import task assignments to a case - FIXED endpoint URL."""
184
-
185
- def __init__(self, http_client: HTTPClient, case_id: str, task_assignment_ids: List[str]):
186
- self.http_client = http_client
187
- self.case_id = case_id
188
- self.task_assignment_ids = task_assignment_ids
189
-
190
- def execute(self) -> Dict[str, Any]:
191
- """Execute the import task assignments to case command."""
192
- payload = {"taskAssignmentIds": self.task_assignment_ids}
193
-
194
- # FIXED: Remove api/public/ prefix
195
- return self.http_client.post(f"cases/{self.case_id}/tasks/import", json_data=payload)
196
-
197
-
198
- class AddNoteToCaseCommand(Command[CaseNote]):
199
- """Command to add a note to a case - Based on API documentation."""
200
-
201
- def __init__(self, http_client: HTTPClient, case_id: str, note_value: str):
202
- self.http_client = http_client
203
- self.case_id = case_id
204
- self.note_value = note_value
205
-
206
- def execute(self) -> CaseNote:
207
- """Execute the add note to case command."""
208
- payload = {"value": self.note_value}
209
-
210
- # POST /api/public/cases/{id}/notes
211
- response = self.http_client.post(f"cases/{self.case_id}/notes", json_data=payload)
212
-
213
- # Use Pydantic parsing with proper field aliasing
214
- entity_data = response.get("result", {})
215
- return CaseNote.model_validate(entity_data)
216
-
217
-
218
- class UpdateNoteToCaseCommand(Command[CaseNote]):
219
- """Command to update a note in a case - Based on API documentation."""
220
-
221
- def __init__(self, http_client: HTTPClient, case_id: str, note_id: str, note_value: str):
222
- self.http_client = http_client
223
- self.case_id = case_id
224
- self.note_id = note_id
225
- self.note_value = note_value
226
-
227
- def execute(self) -> CaseNote:
228
- """Execute the update note in case command."""
229
- payload = {"value": self.note_value}
230
-
231
- # PATCH /api/public/cases/{caseId}/notes/{noteId}
232
- response = self.http_client.patch(f"cases/{self.case_id}/notes/{self.note_id}", json_data=payload)
233
-
234
- # Use Pydantic parsing with proper field aliasing
235
- entity_data = response.get("result", {})
236
- return CaseNote.model_validate(entity_data)
237
-
238
-
239
- class DeleteNoteToCaseCommand(Command[Dict[str, Any]]):
240
- """Command to delete a note from a case - Based on API documentation."""
241
-
242
- def __init__(self, http_client: HTTPClient, case_id: str, note_id: str):
243
- self.http_client = http_client
244
- self.case_id = case_id
245
- self.note_id = note_id
246
-
247
- def execute(self) -> Dict[str, Any]:
248
- """Execute the delete note from case command."""
249
- # DELETE /api/public/cases/{caseId}/notes/{noteId}
250
- response = self.http_client.delete(f"cases/{self.case_id}/notes/{self.note_id}")
251
- return response
252
-
253
-
254
- class ExportCaseNotesCommand(Command[Dict[str, Any]]):
255
- """Command to export case notes as a file download - Based on API documentation."""
256
-
257
- def __init__(self, http_client: HTTPClient, case_id: str):
258
- self.http_client = http_client
259
- self.case_id = case_id
260
-
261
- def execute(self) -> Dict[str, Any]:
262
- """Execute the export case notes command."""
263
- import requests
264
-
265
- # GET /api/public/cases/{id}/notes/export
266
- # This endpoint returns a file download (ZIP/CSV), so we need raw HTTP handling
267
- headers = {
268
- 'Authorization': f'Bearer {self.http_client.config.api_token}',
269
- 'Content-Type': 'application/json'
270
- }
271
-
272
- url = f"{self.http_client.config.host}/api/public/cases/{self.case_id}/notes/export"
273
-
274
- response = requests.get(
275
- url,
276
- headers=headers,
277
- verify=self.http_client.config.verify_ssl,
278
- timeout=self.http_client.config.timeout
279
- )
280
-
281
- if response.status_code == 200:
282
- # Handle file downloads (ZIP/CSV export)
283
- content_type = response.headers.get('content-type', '')
284
- if 'application/zip' in content_type or 'text/csv' in content_type:
285
- # Extract filename from content disposition if available
286
- content_disposition = response.headers.get('content-disposition', '')
287
- filename = "case_notes_export"
288
- if 'filename=' in content_disposition:
289
- filename = content_disposition.split('filename=')[1].strip('"')
290
-
291
- return {
292
- "success": True,
293
- "result": {
294
- "export_type": "file_download",
295
- "content_type": content_type,
296
- "file_size": len(response.content),
297
- "filename": filename,
298
- "file_content": response.content # Binary content for file download
299
- },
300
- "statusCode": response.status_code,
301
- "errors": []
302
- }
303
- else:
304
- # Fallback to JSON parsing
305
- return response.json()
306
- else:
307
- raise Exception(f"HTTP {response.status_code}: {response.text}")
308
-
309
-
310
- class ExportCasesCommand(Command[Dict[str, Any]]):
311
- """Command to export cases as a file download - Based on API documentation."""
312
-
313
- def __init__(self, http_client: HTTPClient, filter_params: Optional[Dict[str, Any]] = None):
314
- self.http_client = http_client
315
- self.filter_params = filter_params or {}
316
-
317
- def execute(self) -> Dict[str, Any]:
318
- """Execute the export cases command."""
319
- import requests
320
-
321
- # GET /api/public/cases/export
322
- # This endpoint returns a file download (CSV), so we need raw HTTP handling
323
- headers = {
324
- 'Authorization': f'Bearer {self.http_client.config.api_token}',
325
- 'Content-Type': 'application/json'
326
- }
327
-
328
- url = f"{self.http_client.config.host}/api/public/cases/export"
329
-
330
- # Prepare query parameters
331
- params = {}
332
- if self.filter_params:
333
- params.update(self.filter_params)
334
-
335
- response = requests.get(
336
- url,
337
- headers=headers,
338
- params=params,
339
- verify=self.http_client.config.verify_ssl,
340
- timeout=self.http_client.config.timeout
341
- )
342
-
343
- if response.status_code == 200:
344
- # Handle file downloads (CSV export)
345
- content_type = response.headers.get('content-type', '')
346
- if 'text/csv' in content_type or 'application/octet-stream' in content_type:
347
- # Extract filename from content disposition if available
348
- content_disposition = response.headers.get('content-disposition', '')
349
- filename = "cases_export.csv"
350
- if 'filename=' in content_disposition:
351
- filename = content_disposition.split('filename=')[1].strip('"')
352
-
353
- # Count lines in CSV to get case count
354
- csv_content = response.text
355
- case_count = 0
356
- if csv_content:
357
- lines = csv_content.strip().split('\n')
358
- case_count = len(lines) - 1 if len(lines) > 0 else 0 # Subtract header
359
-
360
- return {
361
- "success": True,
362
- "result": {
363
- "export_type": "csv_download",
364
- "content_type": content_type,
365
- "file_size": len(response.content),
366
- "filename": filename,
367
- "csv_content": csv_content,
368
- "case_count": case_count,
369
- "file_content": response.content # Binary content for file download
370
- },
371
- "statusCode": response.status_code,
372
- "errors": []
373
- }
374
- else:
375
- # Fallback to JSON parsing
376
- return response.json()
377
- else:
378
- raise Exception(f"HTTP {response.status_code}: {response.text}")
379
-
380
-
381
- class ExportCaseEndpointsCommand(Command[Dict[str, Any]]):
382
- """Command to export case endpoints as a file download - Based on API documentation."""
383
-
384
- def __init__(self, http_client: HTTPClient, case_id: str, filter_params: Optional[Dict[str, Any]] = None):
385
- self.http_client = http_client
386
- self.case_id = case_id
387
- self.filter_params = filter_params or {}
388
-
389
- def execute(self) -> Dict[str, Any]:
390
- """Execute the export case endpoints command."""
391
- import requests
392
-
393
- # GET /api/public/cases/{id}/endpoints/export
394
- # This endpoint returns a file download (CSV), so we need raw HTTP handling
395
- headers = {
396
- 'Authorization': f'Bearer {self.http_client.config.api_token}',
397
- 'Content-Type': 'application/json'
398
- }
399
-
400
- url = f"{self.http_client.config.host}/api/public/cases/{self.case_id}/endpoints/export"
401
-
402
- # Prepare query parameters - ensure organizationIds is included
403
- params = {}
404
- if self.filter_params:
405
- params.update(self.filter_params)
406
-
407
- # Ensure organizationIds is set if not provided
408
- if 'filter[organizationIds]' not in params:
409
- params['filter[organizationIds]'] = '0' # Default organization
410
-
411
- response = requests.get(
412
- url,
413
- headers=headers,
414
- params=params,
415
- verify=self.http_client.config.verify_ssl,
416
- timeout=self.http_client.config.timeout
417
- )
418
-
419
- if response.status_code == 200:
420
- # Handle file downloads (CSV export)
421
- content_type = response.headers.get('content-type', '')
422
- if 'text/csv' in content_type or 'application/octet-stream' in content_type:
423
- # Extract filename from content disposition if available
424
- content_disposition = response.headers.get('content-disposition', '')
425
- filename = "case_endpoints_export.csv"
426
- if 'filename=' in content_disposition:
427
- filename = content_disposition.split('filename=')[1].strip('"')
428
-
429
- # Count lines in CSV to get endpoint count
430
- csv_content = response.text
431
- endpoint_count = 0
432
- if csv_content:
433
- lines = csv_content.strip().split('\n')
434
- endpoint_count = len(lines) - 1 if len(lines) > 0 else 0 # Subtract header
435
-
436
- return {
437
- "success": True,
438
- "result": {
439
- "export_type": "csv_download",
440
- "content_type": content_type,
441
- "file_size": len(response.content),
442
- "filename": filename,
443
- "csv_content": csv_content,
444
- "endpoint_count": endpoint_count,
445
- "file_content": response.content # Binary content for file download
446
- },
447
- "statusCode": response.status_code,
448
- "errors": []
449
- }
450
- else:
451
- # Fallback to JSON parsing
452
- return response.json()
453
- else:
454
- raise Exception(f"HTTP {response.status_code}: {response.text}")
455
-
456
-
457
- class ExportCaseActivitiesCommand(Command[Dict[str, Any]]):
458
- """Command to export case activities as a file download - Based on API documentation."""
459
-
460
- def __init__(self, http_client: HTTPClient, case_id: str, filter_params: Optional[Dict[str, Any]] = None):
461
- self.http_client = http_client
462
- self.case_id = case_id
463
- self.filter_params = filter_params or {}
464
-
465
- def execute(self) -> Dict[str, Any]:
466
- """Execute the export case activities command."""
467
- import requests
468
-
469
- # GET /api/public/cases/{id}/activities/export
470
- # This endpoint returns a file download (CSV), so we need raw HTTP handling
471
- headers = {
472
- 'Authorization': f'Bearer {self.http_client.config.api_token}',
473
- 'Content-Type': 'application/json'
474
- }
475
-
476
- url = f"{self.http_client.config.host}/api/public/cases/{self.case_id}/activities/export"
477
-
478
- # Prepare query parameters - support pagination and filtering
479
- params = {}
480
- if self.filter_params:
481
- params.update(self.filter_params)
482
-
483
- # Set default pagination if not provided
484
- if 'pageNumber' not in params:
485
- params['pageNumber'] = '1'
486
- if 'pageSize' not in params:
487
- params['pageSize'] = '50' # Reasonable default for export
488
- if 'sortBy' not in params:
489
- params['sortBy'] = 'createdAt'
490
- if 'sortType' not in params:
491
- params['sortType'] = 'ASC'
492
-
493
- response = requests.get(
494
- url,
495
- headers=headers,
496
- params=params,
497
- verify=self.http_client.config.verify_ssl,
498
- timeout=self.http_client.config.timeout
499
- )
500
-
501
- if response.status_code == 200:
502
- # Handle file downloads (ZIP/CSV export)
503
- content_type = response.headers.get('content-type', '')
504
-
505
- if ('text/csv' in content_type or 'application/octet-stream' in content_type or
506
- 'application/vnd.ms-excel' in content_type or 'application/zip' in content_type):
507
- # Extract filename from content disposition if available
508
- content_disposition = response.headers.get('content-disposition', '')
509
- filename = "case_activities_export"
510
- if 'filename=' in content_disposition:
511
- filename_part = content_disposition.split('filename=')[1].strip('"')
512
- if 'filename*=' in content_disposition:
513
- # Handle UTF-8 encoded filenames
514
- filename_part = content_disposition.split("filename*=UTF-8''")[1] if "filename*=UTF-8''" in content_disposition else filename_part
515
- filename = filename_part
516
- elif 'application/zip' in content_type:
517
- filename = "case_activities_export.zip"
518
- elif 'text/csv' in content_type:
519
- filename = "case_activities_export.csv"
520
-
521
- # Determine export type and count activities
522
- if 'application/zip' in content_type:
523
- export_type = "zip_download"
524
- # For ZIP files, we can't easily count activities without extracting
525
- activity_count = "unknown_zip_content"
526
- else:
527
- export_type = "csv_download"
528
- # Count lines in CSV to get activity count
529
- csv_content = response.text
530
- activity_count = 0
531
- if csv_content:
532
- lines = csv_content.strip().split('\n')
533
- activity_count = len(lines) - 1 if len(lines) > 0 else 0 # Subtract header
534
-
535
- result_data = {
536
- "export_type": export_type,
537
- "content_type": content_type,
538
- "file_size": len(response.content),
539
- "filename": filename,
540
- "activity_count": activity_count,
541
- "file_content": response.content # Binary content for file download
542
- }
543
-
544
- # Add content-specific fields
545
- if export_type == "csv_download":
546
- result_data["csv_content"] = response.text
547
- elif export_type == "zip_download":
548
- result_data["zip_content"] = response.content
549
-
550
- return {
551
- "success": True,
552
- "result": result_data,
553
- "statusCode": response.status_code,
554
- "errors": []
555
- }
556
- else:
557
- # Try to handle as text response first
558
- try:
559
- if response.text.strip():
560
- # If it looks like JSON, try to parse it
561
- if response.text.strip().startswith('{'):
562
- return response.json()
563
- else:
564
- # Treat as plain text/CSV
565
- return {
566
- "success": True,
567
- "result": {
568
- "export_type": "text_download",
569
- "content_type": content_type,
570
- "file_size": len(response.content),
571
- "filename": "case_activities_export.txt",
572
- "text_content": response.text,
573
- "activity_count": len(response.text.strip().split('\n')) - 1 if response.text.strip() else 0,
574
- "file_content": response.content
575
- },
576
- "statusCode": response.status_code,
577
- "errors": []
578
- }
579
- else:
580
- # Empty response
581
- return {
582
- "success": True,
583
- "result": {
584
- "export_type": "empty_response",
585
- "content_type": content_type,
586
- "file_size": 0,
587
- "filename": "empty_export",
588
- "activity_count": 0,
589
- "file_content": b""
590
- },
591
- "statusCode": response.status_code,
592
- "errors": []
593
- }
594
- except Exception as e:
595
- print(f"[DEBUG] Failed to parse response: {e}")
596
- return {
597
- "success": False,
598
- "result": None,
599
- "statusCode": response.status_code,
600
- "errors": [f"Failed to parse response: {e}"]
601
- }
602
- else:
1
+ """
2
+ Case-related commands for the Binalyze AIR SDK.
3
+ Fixed to match API documentation exactly.
4
+ """
5
+
6
+ from typing import List, Dict, Any, Optional, Union
7
+
8
+ from ..base import Command, ensure_organization_ids, format_organization_ids_param
9
+ from ..models.cases import CreateCaseRequest, UpdateCaseRequest, Case, CaseNote
10
+ from ..models.assets import AssetFilter
11
+ from ..http_client import HTTPClient
12
+
13
+
14
+ class CreateCaseCommand(Command[Case]):
15
+ """Command to create a new case - FIXED endpoint URL."""
16
+
17
+ def __init__(self, http_client: HTTPClient, case_data: CreateCaseRequest):
18
+ self.http_client = http_client
19
+ self.case_data = case_data
20
+
21
+ def execute(self) -> Case:
22
+ """Execute the create case command."""
23
+ payload = {
24
+ "organizationId": self.case_data.organization_id,
25
+ "name": self.case_data.name,
26
+ "ownerUserId": self.case_data.owner_user_id,
27
+ "visibility": self.case_data.visibility,
28
+ "assignedUserIds": self.case_data.assigned_user_ids,
29
+ }
30
+
31
+ # FIXED: Remove api/public/ prefix
32
+ response = self.http_client.post("cases", json_data=payload)
33
+
34
+ # Use Pydantic parsing with proper field aliasing
35
+ entity_data = response.get("result", {})
36
+ return Case.model_validate(entity_data)
37
+
38
+
39
+ class UpdateCaseCommand(Command[Case]):
40
+ """Command to update an existing case - FIXED endpoint URL."""
41
+
42
+ def __init__(self, http_client: HTTPClient, case_id: str, update_data: UpdateCaseRequest):
43
+ self.http_client = http_client
44
+ self.case_id = case_id
45
+ self.update_data = update_data
46
+
47
+ def execute(self) -> Case:
48
+ """Execute the update case command."""
49
+ payload = {}
50
+
51
+ # Only include fields that are set
52
+ if self.update_data.name is not None:
53
+ payload["name"] = self.update_data.name
54
+ if self.update_data.owner_user_id is not None:
55
+ payload["ownerUserId"] = self.update_data.owner_user_id
56
+ if self.update_data.visibility is not None:
57
+ payload["visibility"] = self.update_data.visibility
58
+ if self.update_data.assigned_user_ids is not None:
59
+ payload["assignedUserIds"] = self.update_data.assigned_user_ids
60
+ if self.update_data.status is not None:
61
+ payload["status"] = self.update_data.status
62
+ if self.update_data.notes is not None:
63
+ payload["notes"] = self.update_data.notes
64
+
65
+ # FIXED: Use PATCH method to match API specification (was PUT)
66
+ response = self.http_client.patch(f"cases/{self.case_id}", json_data=payload)
67
+
68
+ # Use Pydantic parsing with proper field aliasing
69
+ entity_data = response.get("result", {})
70
+ return Case.model_validate(entity_data)
71
+
72
+
73
+ class CloseCaseCommand(Command[Case]):
74
+ """Command to close a case - FIXED endpoint URL."""
75
+
76
+ def __init__(self, http_client: HTTPClient, case_id: str):
77
+ self.http_client = http_client
78
+ self.case_id = case_id
79
+
80
+ def execute(self) -> Case:
81
+ """Execute the close case command."""
82
+ # FIXED: Remove api/public/ prefix
83
+ response = self.http_client.post(f"cases/{self.case_id}/close", json_data={})
84
+
85
+ # Use Pydantic parsing with proper field aliasing
86
+ entity_data = response.get("result", {})
87
+ return Case.model_validate(entity_data)
88
+
89
+
90
+ class OpenCaseCommand(Command[Case]):
91
+ """Command to open a case - FIXED endpoint URL."""
92
+
93
+ def __init__(self, http_client: HTTPClient, case_id: str):
94
+ self.http_client = http_client
95
+ self.case_id = case_id
96
+
97
+ def execute(self) -> Case:
98
+ """Execute the open case command."""
99
+ # FIXED: Remove api/public/ prefix
100
+ response = self.http_client.post(f"cases/{self.case_id}/open", json_data={})
101
+
102
+ # Use Pydantic parsing with proper field aliasing
103
+ entity_data = response.get("result", {})
104
+ return Case.model_validate(entity_data)
105
+
106
+
107
+ class ArchiveCaseCommand(Command[Case]):
108
+ """Command to archive a case - FIXED endpoint URL."""
109
+
110
+ def __init__(self, http_client: HTTPClient, case_id: str):
111
+ self.http_client = http_client
112
+ self.case_id = case_id
113
+
114
+ def execute(self) -> Case:
115
+ """Execute the archive case command."""
116
+ # FIXED: Remove api/public/ prefix
117
+ response = self.http_client.post(f"cases/{self.case_id}/archive", json_data={})
118
+
119
+ # Use Pydantic parsing with proper field aliasing
120
+ entity_data = response.get("result", {})
121
+ return Case.model_validate(entity_data)
122
+
123
+
124
+ class ChangeCaseOwnerCommand(Command[Case]):
125
+ """Command to change case owner - FIXED to match API specification exactly."""
126
+
127
+ def __init__(self, http_client: HTTPClient, case_id: str, new_owner_id: str):
128
+ self.http_client = http_client
129
+ self.case_id = case_id
130
+ self.new_owner_id = new_owner_id
131
+
132
+ def execute(self) -> Case:
133
+ """Execute the change case owner command."""
134
+ # FIXED: Use correct payload field name as per API specification
135
+ payload = {"newOwnerId": self.new_owner_id}
136
+
137
+ # FIXED: Use correct endpoint URL and HTTP method as per API specification
138
+ # POST /api/public/cases/{id}/change-owner
139
+ response = self.http_client.post(f"cases/{self.case_id}/change-owner", json_data=payload)
140
+
141
+ # Use Pydantic parsing with proper field aliasing
142
+ entity_data = response.get("result", {})
143
+ return Case.model_validate(entity_data)
144
+
145
+
146
+ class RemoveEndpointsFromCaseCommand(Command[Dict[str, Any]]):
147
+ """Command to remove endpoints from a case - FIXED to match API documentation exactly."""
148
+
149
+ def __init__(self, http_client: HTTPClient, case_id: str, asset_filter: AssetFilter):
150
+ self.http_client = http_client
151
+ self.case_id = case_id
152
+ self.asset_filter = asset_filter
153
+
154
+ def execute(self) -> Dict[str, Any]:
155
+ """Execute the remove endpoints from case command with correct structure."""
156
+ # FIXED: Use proper filter structure as per API documentation
157
+ payload = {
158
+ "filter": self.asset_filter.to_filter_dict()
159
+ }
160
+
161
+ # FIXED: Correct endpoint URL and HTTP method (DELETE)
162
+ return self.http_client.delete(f"cases/{self.case_id}/endpoints", json_data=payload)
163
+
164
+
165
+ class RemoveTaskAssignmentFromCaseCommand(Command[Dict[str, Any]]):
166
+ """Command to remove task assignment from a case - FIXED endpoint URL."""
167
+
168
+ def __init__(self, http_client: HTTPClient, case_id: str, task_assignment_id: str):
169
+ self.http_client = http_client
170
+ self.case_id = case_id
171
+ self.task_assignment_id = task_assignment_id
172
+
173
+ def execute(self) -> Dict[str, Any]:
174
+ """Execute the remove task assignment from case command."""
175
+ # FIXED: Remove api/public/ prefix
176
+ return self.http_client.post(
177
+ f"cases/{self.case_id}/tasks/{self.task_assignment_id}/remove",
178
+ json_data={}
179
+ )
180
+
181
+
182
+ class ImportTaskAssignmentsToCaseCommand(Command[Dict[str, Any]]):
183
+ """Command to import task assignments to a case - FIXED endpoint URL."""
184
+
185
+ def __init__(self, http_client: HTTPClient, case_id: str, task_assignment_ids: List[str]):
186
+ self.http_client = http_client
187
+ self.case_id = case_id
188
+ self.task_assignment_ids = task_assignment_ids
189
+
190
+ def execute(self) -> Dict[str, Any]:
191
+ """Execute the import task assignments to case command."""
192
+ payload = {"taskAssignmentIds": self.task_assignment_ids}
193
+
194
+ # FIXED: Remove api/public/ prefix
195
+ return self.http_client.post(f"cases/{self.case_id}/tasks/import", json_data=payload)
196
+
197
+
198
+ class AddNoteToCaseCommand(Command[CaseNote]):
199
+ """Command to add a note to a case - Based on API documentation."""
200
+
201
+ def __init__(self, http_client: HTTPClient, case_id: str, note_value: str):
202
+ self.http_client = http_client
203
+ self.case_id = case_id
204
+ self.note_value = note_value
205
+
206
+ def execute(self) -> CaseNote:
207
+ """Execute the add note to case command."""
208
+ payload = {"value": self.note_value}
209
+
210
+ # POST /api/public/cases/{id}/notes
211
+ response = self.http_client.post(f"cases/{self.case_id}/notes", json_data=payload)
212
+
213
+ # Use Pydantic parsing with proper field aliasing
214
+ entity_data = response.get("result", {})
215
+ return CaseNote.model_validate(entity_data)
216
+
217
+
218
+ class UpdateNoteToCaseCommand(Command[CaseNote]):
219
+ """Command to update a note in a case - Based on API documentation."""
220
+
221
+ def __init__(self, http_client: HTTPClient, case_id: str, note_id: str, note_value: str):
222
+ self.http_client = http_client
223
+ self.case_id = case_id
224
+ self.note_id = note_id
225
+ self.note_value = note_value
226
+
227
+ def execute(self) -> CaseNote:
228
+ """Execute the update note in case command."""
229
+ payload = {"value": self.note_value}
230
+
231
+ # PATCH /api/public/cases/{caseId}/notes/{noteId}
232
+ response = self.http_client.patch(f"cases/{self.case_id}/notes/{self.note_id}", json_data=payload)
233
+
234
+ # Use Pydantic parsing with proper field aliasing
235
+ entity_data = response.get("result", {})
236
+ return CaseNote.model_validate(entity_data)
237
+
238
+
239
+ class DeleteNoteToCaseCommand(Command[Dict[str, Any]]):
240
+ """Command to delete a note from a case - Based on API documentation."""
241
+
242
+ def __init__(self, http_client: HTTPClient, case_id: str, note_id: str):
243
+ self.http_client = http_client
244
+ self.case_id = case_id
245
+ self.note_id = note_id
246
+
247
+ def execute(self) -> Dict[str, Any]:
248
+ """Execute the delete note from case command."""
249
+ # DELETE /api/public/cases/{caseId}/notes/{noteId}
250
+ response = self.http_client.delete(f"cases/{self.case_id}/notes/{self.note_id}")
251
+ return response
252
+
253
+
254
+ class ExportCaseNotesCommand(Command[Dict[str, Any]]):
255
+ """Command to export case notes as a file download - Based on API documentation."""
256
+
257
+ def __init__(self, http_client: HTTPClient, case_id: str):
258
+ self.http_client = http_client
259
+ self.case_id = case_id
260
+
261
+ def execute(self) -> Dict[str, Any]:
262
+ """Execute the export case notes command."""
263
+ import requests
264
+
265
+ # GET /api/public/cases/{id}/notes/export
266
+ # This endpoint returns a file download (ZIP/CSV), so we need raw HTTP handling
267
+ headers = {
268
+ 'Authorization': f'Bearer {self.http_client.config.api_token}',
269
+ 'Content-Type': 'application/json'
270
+ }
271
+
272
+ url = f"{self.http_client.config.host}/api/public/cases/{self.case_id}/notes/export"
273
+
274
+ response = requests.get(
275
+ url,
276
+ headers=headers,
277
+ verify=self.http_client.config.verify_ssl,
278
+ timeout=self.http_client.config.timeout
279
+ )
280
+
281
+ if response.status_code == 200:
282
+ # Handle file downloads (ZIP/CSV export)
283
+ content_type = response.headers.get('content-type', '')
284
+ if 'application/zip' in content_type or 'text/csv' in content_type:
285
+ # Extract filename from content disposition if available
286
+ content_disposition = response.headers.get('content-disposition', '')
287
+ filename = "case_notes_export"
288
+ if 'filename=' in content_disposition:
289
+ filename = content_disposition.split('filename=')[1].strip('"')
290
+
291
+ return {
292
+ "success": True,
293
+ "result": {
294
+ "export_type": "file_download",
295
+ "content_type": content_type,
296
+ "file_size": len(response.content),
297
+ "filename": filename,
298
+ "file_content": response.content # Binary content for file download
299
+ },
300
+ "statusCode": response.status_code,
301
+ "errors": []
302
+ }
303
+ else:
304
+ # Fallback to JSON parsing
305
+ return response.json()
306
+ else:
307
+ raise Exception(f"HTTP {response.status_code}: {response.text}")
308
+
309
+
310
+ class ExportCasesCommand(Command[Dict[str, Any]]):
311
+ """Command to export cases as a file download - Based on API documentation."""
312
+
313
+ def __init__(self, http_client: HTTPClient, filter_params: Optional[Dict[str, Any]] = None):
314
+ self.http_client = http_client
315
+ self.filter_params = filter_params or {}
316
+
317
+ def execute(self) -> Dict[str, Any]:
318
+ """Execute the export cases command."""
319
+ import requests
320
+
321
+ # GET /api/public/cases/export
322
+ # This endpoint returns a file download (CSV), so we need raw HTTP handling
323
+ headers = {
324
+ 'Authorization': f'Bearer {self.http_client.config.api_token}',
325
+ 'Content-Type': 'application/json'
326
+ }
327
+
328
+ url = f"{self.http_client.config.host}/api/public/cases/export"
329
+
330
+ # Prepare query parameters with organization ID protection
331
+ params = {}
332
+ if self.filter_params:
333
+ params.update(self.filter_params)
334
+
335
+ # Ensure organizationIds is set using our utility functions
336
+ if 'filter[organizationIds]' not in params:
337
+ # Use our organization ID utilities to ensure proper formatting
338
+ org_ids = ensure_organization_ids(None) # Returns [0] if None
339
+ org_params = format_organization_ids_param(org_ids)
340
+ params.update(org_params)
341
+
342
+ response = requests.get(
343
+ url,
344
+ headers=headers,
345
+ params=params,
346
+ verify=self.http_client.config.verify_ssl,
347
+ timeout=self.http_client.config.timeout
348
+ )
349
+
350
+ if response.status_code == 200:
351
+ # Handle file downloads (CSV export)
352
+ content_type = response.headers.get('content-type', '')
353
+ if 'text/csv' in content_type or 'application/octet-stream' in content_type:
354
+ # Extract filename from content disposition if available
355
+ content_disposition = response.headers.get('content-disposition', '')
356
+ filename = "cases_export.csv"
357
+ if 'filename=' in content_disposition:
358
+ filename = content_disposition.split('filename=')[1].strip('"')
359
+
360
+ # Count lines in CSV to get case count
361
+ csv_content = response.text
362
+ case_count = 0
363
+ if csv_content:
364
+ lines = csv_content.strip().split('\n')
365
+ case_count = len(lines) - 1 if len(lines) > 0 else 0 # Subtract header
366
+
367
+ return {
368
+ "success": True,
369
+ "result": {
370
+ "export_type": "csv_download",
371
+ "content_type": content_type,
372
+ "file_size": len(response.content),
373
+ "filename": filename,
374
+ "csv_content": csv_content,
375
+ "case_count": case_count,
376
+ "file_content": response.content # Binary content for file download
377
+ },
378
+ "statusCode": response.status_code,
379
+ "errors": []
380
+ }
381
+ else:
382
+ # Fallback to JSON parsing
383
+ return response.json()
384
+ else:
385
+ raise Exception(f"HTTP {response.status_code}: {response.text}")
386
+
387
+
388
+ class ExportCaseEndpointsCommand(Command[Dict[str, Any]]):
389
+ """Command to export case endpoints as a file download - Based on API documentation."""
390
+
391
+ def __init__(self, http_client: HTTPClient, case_id: str, filter_params: Optional[Dict[str, Any]] = None):
392
+ self.http_client = http_client
393
+ self.case_id = case_id
394
+ self.filter_params = filter_params or {}
395
+
396
+ def execute(self) -> Dict[str, Any]:
397
+ """Execute the export case endpoints command."""
398
+ import requests
399
+
400
+ # GET /api/public/cases/{id}/endpoints/export
401
+ # This endpoint returns a file download (CSV), so we need raw HTTP handling
402
+ headers = {
403
+ 'Authorization': f'Bearer {self.http_client.config.api_token}',
404
+ 'Content-Type': 'application/json'
405
+ }
406
+
407
+ url = f"{self.http_client.config.host}/api/public/cases/{self.case_id}/endpoints/export"
408
+
409
+ # Prepare query parameters - ensure organizationIds is included
410
+ params = {}
411
+ if self.filter_params:
412
+ params.update(self.filter_params)
413
+
414
+ # Ensure organizationIds is set if not provided
415
+ if 'filter[organizationIds]' not in params:
416
+ params['filter[organizationIds]'] = '0' # Default organization
417
+
418
+ response = requests.get(
419
+ url,
420
+ headers=headers,
421
+ params=params,
422
+ verify=self.http_client.config.verify_ssl,
423
+ timeout=self.http_client.config.timeout
424
+ )
425
+
426
+ if response.status_code == 200:
427
+ # Handle file downloads (CSV export)
428
+ content_type = response.headers.get('content-type', '')
429
+ if 'text/csv' in content_type or 'application/octet-stream' in content_type:
430
+ # Extract filename from content disposition if available
431
+ content_disposition = response.headers.get('content-disposition', '')
432
+ filename = "case_endpoints_export.csv"
433
+ if 'filename=' in content_disposition:
434
+ filename = content_disposition.split('filename=')[1].strip('"')
435
+
436
+ # Count lines in CSV to get endpoint count
437
+ csv_content = response.text
438
+ endpoint_count = 0
439
+ if csv_content:
440
+ lines = csv_content.strip().split('\n')
441
+ endpoint_count = len(lines) - 1 if len(lines) > 0 else 0 # Subtract header
442
+
443
+ return {
444
+ "success": True,
445
+ "result": {
446
+ "export_type": "csv_download",
447
+ "content_type": content_type,
448
+ "file_size": len(response.content),
449
+ "filename": filename,
450
+ "csv_content": csv_content,
451
+ "endpoint_count": endpoint_count,
452
+ "file_content": response.content # Binary content for file download
453
+ },
454
+ "statusCode": response.status_code,
455
+ "errors": []
456
+ }
457
+ else:
458
+ # Fallback to JSON parsing
459
+ return response.json()
460
+ else:
461
+ raise Exception(f"HTTP {response.status_code}: {response.text}")
462
+
463
+
464
+ class ExportCaseActivitiesCommand(Command[Dict[str, Any]]):
465
+ """Command to export case activities as a file download - Based on API documentation."""
466
+
467
+ def __init__(self, http_client: HTTPClient, case_id: str, filter_params: Optional[Dict[str, Any]] = None):
468
+ self.http_client = http_client
469
+ self.case_id = case_id
470
+ self.filter_params = filter_params or {}
471
+
472
+ def execute(self) -> Dict[str, Any]:
473
+ """Execute the export case activities command."""
474
+ import requests
475
+
476
+ # GET /api/public/cases/{id}/activities/export
477
+ # This endpoint returns a file download (CSV), so we need raw HTTP handling
478
+ headers = {
479
+ 'Authorization': f'Bearer {self.http_client.config.api_token}',
480
+ 'Content-Type': 'application/json'
481
+ }
482
+
483
+ url = f"{self.http_client.config.host}/api/public/cases/{self.case_id}/activities/export"
484
+
485
+ # Prepare query parameters - support pagination and filtering
486
+ params = {}
487
+ if self.filter_params:
488
+ params.update(self.filter_params)
489
+
490
+ # Set default pagination if not provided
491
+ if 'pageNumber' not in params:
492
+ params['pageNumber'] = '1'
493
+ if 'pageSize' not in params:
494
+ params['pageSize'] = '50' # Reasonable default for export
495
+ if 'sortBy' not in params:
496
+ params['sortBy'] = 'createdAt'
497
+ if 'sortType' not in params:
498
+ params['sortType'] = 'ASC'
499
+
500
+ response = requests.get(
501
+ url,
502
+ headers=headers,
503
+ params=params,
504
+ verify=self.http_client.config.verify_ssl,
505
+ timeout=self.http_client.config.timeout
506
+ )
507
+
508
+ if response.status_code == 200:
509
+ # Handle file downloads (ZIP/CSV export)
510
+ content_type = response.headers.get('content-type', '')
511
+
512
+ if ('text/csv' in content_type or 'application/octet-stream' in content_type or
513
+ 'application/vnd.ms-excel' in content_type or 'application/zip' in content_type):
514
+ # Extract filename from content disposition if available
515
+ content_disposition = response.headers.get('content-disposition', '')
516
+ filename = "case_activities_export"
517
+ if 'filename=' in content_disposition:
518
+ filename_part = content_disposition.split('filename=')[1].strip('"')
519
+ if 'filename*=' in content_disposition:
520
+ # Handle UTF-8 encoded filenames
521
+ filename_part = content_disposition.split("filename*=UTF-8''")[1] if "filename*=UTF-8''" in content_disposition else filename_part
522
+ filename = filename_part
523
+ elif 'application/zip' in content_type:
524
+ filename = "case_activities_export.zip"
525
+ elif 'text/csv' in content_type:
526
+ filename = "case_activities_export.csv"
527
+
528
+ # Determine export type and count activities
529
+ if 'application/zip' in content_type:
530
+ export_type = "zip_download"
531
+ # For ZIP files, we can't easily count activities without extracting
532
+ activity_count = "unknown_zip_content"
533
+ else:
534
+ export_type = "csv_download"
535
+ # Count lines in CSV to get activity count
536
+ csv_content = response.text
537
+ activity_count = 0
538
+ if csv_content:
539
+ lines = csv_content.strip().split('\n')
540
+ activity_count = len(lines) - 1 if len(lines) > 0 else 0 # Subtract header
541
+
542
+ result_data = {
543
+ "export_type": export_type,
544
+ "content_type": content_type,
545
+ "file_size": len(response.content),
546
+ "filename": filename,
547
+ "activity_count": activity_count,
548
+ "file_content": response.content # Binary content for file download
549
+ }
550
+
551
+ # Add content-specific fields
552
+ if export_type == "csv_download":
553
+ result_data["csv_content"] = response.text
554
+ elif export_type == "zip_download":
555
+ result_data["zip_content"] = response.content
556
+
557
+ return {
558
+ "success": True,
559
+ "result": result_data,
560
+ "statusCode": response.status_code,
561
+ "errors": []
562
+ }
563
+ else:
564
+ # Try to handle as text response first
565
+ try:
566
+ if response.text.strip():
567
+ # If it looks like JSON, try to parse it
568
+ if response.text.strip().startswith('{'):
569
+ return response.json()
570
+ else:
571
+ # Treat as plain text/CSV
572
+ return {
573
+ "success": True,
574
+ "result": {
575
+ "export_type": "text_download",
576
+ "content_type": content_type,
577
+ "file_size": len(response.content),
578
+ "filename": "case_activities_export.txt",
579
+ "text_content": response.text,
580
+ "activity_count": len(response.text.strip().split('\n')) - 1 if response.text.strip() else 0,
581
+ "file_content": response.content
582
+ },
583
+ "statusCode": response.status_code,
584
+ "errors": []
585
+ }
586
+ else:
587
+ # Empty response
588
+ return {
589
+ "success": True,
590
+ "result": {
591
+ "export_type": "empty_response",
592
+ "content_type": content_type,
593
+ "file_size": 0,
594
+ "filename": "empty_export",
595
+ "activity_count": 0,
596
+ "file_content": b""
597
+ },
598
+ "statusCode": response.status_code,
599
+ "errors": []
600
+ }
601
+ except Exception as e:
602
+ print(f"[DEBUG] Failed to parse response: {e}")
603
+ return {
604
+ "success": False,
605
+ "result": None,
606
+ "statusCode": response.status_code,
607
+ "errors": [f"Failed to parse response: {e}"]
608
+ }
609
+ else:
603
610
  raise Exception(f"HTTP {response.status_code}: {response.text}")