statezero 0.1.0b4__py3-none-any.whl → 0.1.0b6__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.
- statezero/adaptors/django/actions.py +171 -0
- statezero/adaptors/django/apps.py +57 -17
- statezero/adaptors/django/orm.py +224 -174
- statezero/adaptors/django/urls.py +5 -3
- statezero/adaptors/django/views.py +133 -31
- statezero/core/actions.py +88 -0
- statezero/core/ast_parser.py +315 -175
- statezero/core/interfaces.py +216 -70
- statezero/core/process_request.py +1 -1
- {statezero-0.1.0b4.dist-info → statezero-0.1.0b6.dist-info}/METADATA +2 -2
- {statezero-0.1.0b4.dist-info → statezero-0.1.0b6.dist-info}/RECORD +14 -12
- {statezero-0.1.0b4.dist-info → statezero-0.1.0b6.dist-info}/WHEEL +0 -0
- {statezero-0.1.0b4.dist-info → statezero-0.1.0b6.dist-info}/licenses/license.md +0 -0
- {statezero-0.1.0b4.dist-info → statezero-0.1.0b6.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from django.urls import path
|
|
2
2
|
|
|
3
|
-
from .views import EventsAuthView, ModelListView, ModelView, SchemaView, FileUploadView, FastUploadView
|
|
3
|
+
from .views import EventsAuthView, ModelListView, ModelView, SchemaView, FileUploadView, FastUploadView, ActionSchemaView, ActionView
|
|
4
4
|
|
|
5
5
|
app_name = "statezero"
|
|
6
6
|
|
|
@@ -9,6 +9,8 @@ urlpatterns = [
|
|
|
9
9
|
path("models/", ModelListView.as_view(), name="model_list"),
|
|
10
10
|
path("files/upload/", FileUploadView.as_view(), name="file_upload"),
|
|
11
11
|
path("files/fast-upload/", FastUploadView.as_view(), name="fast_file_upload"),
|
|
12
|
+
path("actions/<str:action_name>/", ActionView.as_view(), name="action"),
|
|
13
|
+
path("actions-schema/", ActionSchemaView.as_view(), name="actions_schema"),
|
|
12
14
|
path("<str:model_name>/", ModelView.as_view(), name="model_view"),
|
|
13
|
-
path("<str:model_name>/get-schema/", SchemaView.as_view(), name="schema_view")
|
|
14
|
-
]
|
|
15
|
+
path("<str:model_name>/get-schema/", SchemaView.as_view(), name="schema_view"),
|
|
16
|
+
]
|
|
@@ -12,15 +12,20 @@ from django.utils.module_loading import import_string
|
|
|
12
12
|
from datetime import datetime
|
|
13
13
|
from django.conf import settings
|
|
14
14
|
from django.core.files.storage import default_storage
|
|
15
|
+
from statezero.core.exceptions import NotFound, PermissionDenied
|
|
15
16
|
import math
|
|
17
|
+
from typing import Type
|
|
16
18
|
import mimetypes
|
|
17
19
|
|
|
18
20
|
from statezero.adaptors.django.config import config, registry
|
|
19
21
|
from statezero.adaptors.django.exception_handler import \
|
|
20
22
|
explicit_exception_handler
|
|
21
23
|
from statezero.adaptors.django.permissions import ORMBridgeViewAccessGate
|
|
22
|
-
from statezero.
|
|
24
|
+
from statezero.adaptors.django.actions import DjangoActionSchemaGenerator
|
|
25
|
+
from statezero.core.interfaces import AbstractEventEmitter, AbstractActionPermission
|
|
23
26
|
from statezero.core.process_request import RequestProcessor
|
|
27
|
+
from statezero.core.actions import action_registry
|
|
28
|
+
from statezero.core.interfaces import AbstractActionPermission
|
|
24
29
|
|
|
25
30
|
logger = logging.getLogger(__name__)
|
|
26
31
|
logger.setLevel(logging.DEBUG)
|
|
@@ -114,7 +119,7 @@ class SchemaView(APIView):
|
|
|
114
119
|
except Exception as original_exception:
|
|
115
120
|
return explicit_exception_handler(original_exception)
|
|
116
121
|
return Response(result, status=status.HTTP_200_OK)
|
|
117
|
-
|
|
122
|
+
|
|
118
123
|
class FileUploadView(APIView):
|
|
119
124
|
"""Standard file upload - returns permanent URL"""
|
|
120
125
|
parser_classes = [MultiPartParser]
|
|
@@ -161,17 +166,17 @@ class FileUploadView(APIView):
|
|
|
161
166
|
class FastUploadView(APIView):
|
|
162
167
|
"""Fast upload with S3 presigned URLs - single or multipart based on chunks"""
|
|
163
168
|
permission_classes = [permission_class]
|
|
164
|
-
|
|
169
|
+
|
|
165
170
|
def post(self, request):
|
|
166
171
|
action = request.data.get('action', 'initiate')
|
|
167
|
-
|
|
172
|
+
|
|
168
173
|
if action == 'initiate':
|
|
169
174
|
return self._initiate_upload(request)
|
|
170
175
|
elif action == 'complete':
|
|
171
176
|
return self._complete_upload(request)
|
|
172
177
|
else:
|
|
173
178
|
return Response({'error': 'Invalid action'}, status=400)
|
|
174
|
-
|
|
179
|
+
|
|
175
180
|
def _initiate_upload(self, request):
|
|
176
181
|
"""Generate presigned URLs - single or multipart based on num_chunks"""
|
|
177
182
|
filename = request.data.get('filename')
|
|
@@ -179,24 +184,24 @@ class FastUploadView(APIView):
|
|
|
179
184
|
file_size = request.data.get('file_size', 0)
|
|
180
185
|
num_chunks_str = request.data.get('num_chunks', 1) # Client decides chunking
|
|
181
186
|
num_chunks = int(num_chunks_str)
|
|
182
|
-
|
|
187
|
+
|
|
183
188
|
if not filename:
|
|
184
189
|
return Response({'error': 'filename required'}, status=400)
|
|
185
|
-
|
|
190
|
+
|
|
186
191
|
# Generate file path
|
|
187
192
|
upload_dir = getattr(settings, 'STATEZERO_UPLOAD_DIR', 'statezero')
|
|
188
193
|
file_path = f"{upload_dir}/{filename}"
|
|
189
|
-
|
|
194
|
+
|
|
190
195
|
if not content_type:
|
|
191
196
|
content_type, _ = mimetypes.guess_type(filename)
|
|
192
197
|
content_type = content_type or 'application/octet-stream'
|
|
193
|
-
|
|
198
|
+
|
|
194
199
|
if not self._is_s3_storage():
|
|
195
200
|
return Response({'error': 'Fast upload requires S3 storage backend'}, status=400)
|
|
196
|
-
|
|
201
|
+
|
|
197
202
|
try:
|
|
198
203
|
s3_client = self._get_s3_client()
|
|
199
|
-
|
|
204
|
+
|
|
200
205
|
if num_chunks == 1:
|
|
201
206
|
# Single upload (existing logic)
|
|
202
207
|
presigned_url = s3_client.generate_presigned_url(
|
|
@@ -209,28 +214,28 @@ class FastUploadView(APIView):
|
|
|
209
214
|
ExpiresIn=3600,
|
|
210
215
|
HttpMethod='PUT',
|
|
211
216
|
)
|
|
212
|
-
|
|
217
|
+
|
|
213
218
|
return Response({
|
|
214
219
|
'upload_type': 'single',
|
|
215
220
|
'upload_url': presigned_url,
|
|
216
221
|
'file_path': file_path,
|
|
217
222
|
'content_type': content_type
|
|
218
223
|
})
|
|
219
|
-
|
|
224
|
+
|
|
220
225
|
else:
|
|
221
226
|
# Multipart upload
|
|
222
227
|
if num_chunks > 10000:
|
|
223
228
|
return Response({'error': 'Too many chunks (max 10,000)'}, status=400)
|
|
224
|
-
|
|
229
|
+
|
|
225
230
|
# Initiate multipart upload
|
|
226
231
|
response = s3_client.create_multipart_upload(
|
|
227
232
|
Bucket=settings.AWS_STORAGE_BUCKET_NAME,
|
|
228
233
|
Key=file_path,
|
|
229
234
|
ContentType=content_type
|
|
230
235
|
)
|
|
231
|
-
|
|
236
|
+
|
|
232
237
|
upload_id = response['UploadId']
|
|
233
|
-
|
|
238
|
+
|
|
234
239
|
# Generate presigned URLs for all parts
|
|
235
240
|
upload_urls = {}
|
|
236
241
|
for part_number in range(1, num_chunks + 1):
|
|
@@ -246,7 +251,7 @@ class FastUploadView(APIView):
|
|
|
246
251
|
HttpMethod='PUT'
|
|
247
252
|
)
|
|
248
253
|
upload_urls[part_number] = url
|
|
249
|
-
|
|
254
|
+
|
|
250
255
|
return Response({
|
|
251
256
|
'upload_type': 'multipart',
|
|
252
257
|
'upload_id': upload_id,
|
|
@@ -254,51 +259,51 @@ class FastUploadView(APIView):
|
|
|
254
259
|
'file_path': file_path,
|
|
255
260
|
'content_type': content_type
|
|
256
261
|
})
|
|
257
|
-
|
|
262
|
+
|
|
258
263
|
except Exception as e:
|
|
259
264
|
logger.error(f"Upload initiation failed: {e}")
|
|
260
265
|
return Response({'error': 'Upload unavailable'}, status=500)
|
|
261
|
-
|
|
266
|
+
|
|
262
267
|
def _complete_upload(self, request):
|
|
263
268
|
"""Complete upload - single or multipart"""
|
|
264
269
|
file_path = request.data.get('file_path')
|
|
265
270
|
original_name = request.data.get('original_name')
|
|
266
271
|
upload_id = request.data.get('upload_id') # Only present for multipart
|
|
267
272
|
parts = request.data.get('parts', []) # Only present for multipart
|
|
268
|
-
|
|
273
|
+
|
|
269
274
|
if not file_path:
|
|
270
275
|
return Response({'error': 'file_path required'}, status=400)
|
|
271
|
-
|
|
276
|
+
|
|
272
277
|
try:
|
|
273
278
|
if upload_id and parts:
|
|
274
279
|
# Complete multipart upload
|
|
275
280
|
s3_client = self._get_s3_client()
|
|
276
|
-
|
|
281
|
+
|
|
277
282
|
# Sort parts by PartNumber to ensure correct order
|
|
278
283
|
sorted_parts = sorted(parts, key=lambda x: x['PartNumber'])
|
|
279
|
-
|
|
284
|
+
|
|
280
285
|
response = s3_client.complete_multipart_upload(
|
|
281
286
|
Bucket=settings.AWS_STORAGE_BUCKET_NAME,
|
|
282
287
|
Key=file_path,
|
|
283
288
|
UploadId=upload_id,
|
|
284
289
|
MultipartUpload={'Parts': sorted_parts}
|
|
285
290
|
)
|
|
286
|
-
|
|
291
|
+
|
|
287
292
|
logger.info(f"Multipart upload completed for {file_path}")
|
|
288
|
-
|
|
293
|
+
|
|
289
294
|
# For single uploads, file is already there after PUT
|
|
290
295
|
# For multipart, it's now assembled
|
|
291
|
-
|
|
296
|
+
|
|
292
297
|
if not default_storage.exists(file_path):
|
|
293
298
|
return Response({'error': 'File not found'}, status=404)
|
|
294
|
-
|
|
299
|
+
|
|
295
300
|
return Response({
|
|
296
301
|
'file_path': file_path,
|
|
297
302
|
'file_url': default_storage.url(file_path),
|
|
298
303
|
'original_name': original_name,
|
|
299
304
|
'size': default_storage.size(file_path)
|
|
300
305
|
})
|
|
301
|
-
|
|
306
|
+
|
|
302
307
|
except Exception as e:
|
|
303
308
|
logger.error(f"Upload completion failed: {e}")
|
|
304
309
|
# Clean up failed multipart upload
|
|
@@ -314,7 +319,7 @@ class FastUploadView(APIView):
|
|
|
314
319
|
except Exception as cleanup_error:
|
|
315
320
|
logger.error(f"Failed to abort multipart upload: {cleanup_error}")
|
|
316
321
|
return Response({'error': 'Upload completion failed'}, status=500)
|
|
317
|
-
|
|
322
|
+
|
|
318
323
|
def _get_s3_client(self):
|
|
319
324
|
"""Get S3 client"""
|
|
320
325
|
import boto3
|
|
@@ -325,7 +330,7 @@ class FastUploadView(APIView):
|
|
|
325
330
|
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
|
326
331
|
endpoint_url=getattr(settings, 'AWS_S3_ENDPOINT_URL', None)
|
|
327
332
|
)
|
|
328
|
-
|
|
333
|
+
|
|
329
334
|
def _is_s3_storage(self) -> bool:
|
|
330
335
|
"""Check if using S3-compatible storage"""
|
|
331
336
|
try:
|
|
@@ -333,4 +338,101 @@ class FastUploadView(APIView):
|
|
|
333
338
|
from storages.backends.s3 import S3Storage
|
|
334
339
|
except ImportError:
|
|
335
340
|
return False
|
|
336
|
-
return isinstance(default_storage, (S3Boto3Storage, S3Storage))
|
|
341
|
+
return isinstance(default_storage, (S3Boto3Storage, S3Storage))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class ActionView(APIView):
|
|
345
|
+
"""
|
|
346
|
+
Django view to handle StateZero action execution.
|
|
347
|
+
It uses a single try/except block to catch all errors, including
|
|
348
|
+
not found actions and permission denials, and formats them with the
|
|
349
|
+
explicit_exception_handler.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
permission_classes = [ORMBridgeViewAccessGate]
|
|
353
|
+
|
|
354
|
+
def post(self, request, action_name):
|
|
355
|
+
"""Execute a registered action."""
|
|
356
|
+
try:
|
|
357
|
+
action_config = action_registry.get_action(action_name)
|
|
358
|
+
|
|
359
|
+
if not action_config:
|
|
360
|
+
# Raise an exception to be handled by the central handler
|
|
361
|
+
raise NotFound(detail=f"Action '{action_name}' not found")
|
|
362
|
+
|
|
363
|
+
# This will raise PermissionDenied on failure
|
|
364
|
+
self._check_permissions(request, action_config, action_name)
|
|
365
|
+
|
|
366
|
+
# Validate input data
|
|
367
|
+
validated_data = {}
|
|
368
|
+
if action_config["serializer"]:
|
|
369
|
+
serializer = action_config["serializer"](
|
|
370
|
+
data=request.data, context={"request": request}
|
|
371
|
+
)
|
|
372
|
+
# Using raise_exception=True automatically triggers the handler
|
|
373
|
+
# for validation errors.
|
|
374
|
+
serializer.is_valid(raise_exception=True)
|
|
375
|
+
validated_data = serializer.validated_data
|
|
376
|
+
else:
|
|
377
|
+
validated_data = request.data
|
|
378
|
+
|
|
379
|
+
# This will also raise PermissionDenied on failure
|
|
380
|
+
self._check_action_permissions(
|
|
381
|
+
request, action_config, action_name, validated_data
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
# Execute the core action function
|
|
385
|
+
action_func = action_config["function"]
|
|
386
|
+
result = action_func(**validated_data, request=request)
|
|
387
|
+
|
|
388
|
+
# Validate the response data
|
|
389
|
+
if action_config["response_serializer"]:
|
|
390
|
+
response_serializer = action_config["response_serializer"](data=result)
|
|
391
|
+
if not response_serializer.is_valid():
|
|
392
|
+
# This indicates an issue with the action's implementation
|
|
393
|
+
return Response(
|
|
394
|
+
{
|
|
395
|
+
"error": f"Action returned invalid response: {response_serializer.errors}"
|
|
396
|
+
},
|
|
397
|
+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
398
|
+
)
|
|
399
|
+
return Response(response_serializer.validated_data)
|
|
400
|
+
else:
|
|
401
|
+
return Response(result)
|
|
402
|
+
|
|
403
|
+
except Exception as original_exception:
|
|
404
|
+
# This single block now handles all runtime exceptions
|
|
405
|
+
return explicit_exception_handler(original_exception)
|
|
406
|
+
|
|
407
|
+
def _check_permissions(self, request, action_config, action_name) -> None:
|
|
408
|
+
"""Check view-level permissions, raising an exception on failure."""
|
|
409
|
+
permissions = action_config.get("permissions", [])
|
|
410
|
+
for permission_class in permissions:
|
|
411
|
+
permission_instance = permission_class()
|
|
412
|
+
if not permission_instance.has_permission(request, action_name):
|
|
413
|
+
raise PermissionDenied(
|
|
414
|
+
detail="You do not have permission to perform this action."
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
def _check_action_permissions(
|
|
418
|
+
self, request, action_config, action_name, validated_data
|
|
419
|
+
) -> None:
|
|
420
|
+
"""Check action-level permissions, raising an exception on failure."""
|
|
421
|
+
permissions = action_config.get("permissions", [])
|
|
422
|
+
for permission_class in permissions:
|
|
423
|
+
permission_instance: Type[AbstractActionPermission] = permission_class()
|
|
424
|
+
if not permission_instance.has_action_permission(
|
|
425
|
+
request, action_name, validated_data
|
|
426
|
+
):
|
|
427
|
+
raise PermissionDenied(
|
|
428
|
+
detail="You do not have permission for this specific request."
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class ActionSchemaView(APIView):
|
|
433
|
+
"""Django view to provide action schema information for frontend generation"""
|
|
434
|
+
permission_classes = [ORMBridgeViewAccessGate]
|
|
435
|
+
|
|
436
|
+
def get(self, request):
|
|
437
|
+
"""Return schema information for all registered actions"""
|
|
438
|
+
return DjangoActionSchemaGenerator.generate_actions_schema()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Dict, Any, Callable, List, Union, Optional
|
|
3
|
+
from .interfaces import AbstractActionPermission
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ActionRegistry:
|
|
7
|
+
"""Framework-agnostic action registry"""
|
|
8
|
+
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self._actions: Dict[str, Dict] = {}
|
|
11
|
+
|
|
12
|
+
def register(
|
|
13
|
+
self,
|
|
14
|
+
func: Callable = None,
|
|
15
|
+
*,
|
|
16
|
+
docstring: Optional[str] = None,
|
|
17
|
+
serializer=None,
|
|
18
|
+
response_serializer=None,
|
|
19
|
+
permissions: Union[
|
|
20
|
+
List[AbstractActionPermission], AbstractActionPermission, None
|
|
21
|
+
] = None,
|
|
22
|
+
name: Optional[str] = None,
|
|
23
|
+
):
|
|
24
|
+
"""Register an action function with an optional, explicit docstring."""
|
|
25
|
+
|
|
26
|
+
def decorator(func: Callable):
|
|
27
|
+
action_name = name or func.__name__
|
|
28
|
+
|
|
29
|
+
# Determine the docstring, prioritizing the explicit parameter over the function's own.
|
|
30
|
+
final_docstring = docstring or func.__doc__
|
|
31
|
+
if final_docstring:
|
|
32
|
+
# Clean up indentation and whitespace from the docstring.
|
|
33
|
+
final_docstring = inspect.cleandoc(final_docstring)
|
|
34
|
+
|
|
35
|
+
if permissions is None:
|
|
36
|
+
permission_list = []
|
|
37
|
+
elif isinstance(permissions, list):
|
|
38
|
+
permission_list = permissions
|
|
39
|
+
else:
|
|
40
|
+
permission_list = [permissions]
|
|
41
|
+
|
|
42
|
+
self._actions[action_name] = {
|
|
43
|
+
"function": func,
|
|
44
|
+
"serializer": serializer,
|
|
45
|
+
"response_serializer": response_serializer,
|
|
46
|
+
"permissions": permission_list,
|
|
47
|
+
"name": action_name,
|
|
48
|
+
"module": func.__module__,
|
|
49
|
+
"docstring": final_docstring, # Store the determined docstring
|
|
50
|
+
}
|
|
51
|
+
return func
|
|
52
|
+
|
|
53
|
+
if func is None:
|
|
54
|
+
return decorator
|
|
55
|
+
return decorator(func)
|
|
56
|
+
|
|
57
|
+
def get_actions(self) -> Dict[str, Dict]:
|
|
58
|
+
return self._actions
|
|
59
|
+
|
|
60
|
+
def get_action(self, name: str) -> Optional[Dict]:
|
|
61
|
+
return self._actions.get(name)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Global registry instance
|
|
65
|
+
action_registry = ActionRegistry()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Convenient decorator
|
|
69
|
+
def action(
|
|
70
|
+
func: Callable = None,
|
|
71
|
+
*,
|
|
72
|
+
docstring: Optional[str] = None,
|
|
73
|
+
serializer=None,
|
|
74
|
+
response_serializer=None,
|
|
75
|
+
permissions: Union[
|
|
76
|
+
List[AbstractActionPermission], AbstractActionPermission, None
|
|
77
|
+
] = None,
|
|
78
|
+
name: Optional[str] = None,
|
|
79
|
+
):
|
|
80
|
+
"""Framework-agnostic decorator to register an action."""
|
|
81
|
+
return action_registry.register(
|
|
82
|
+
func,
|
|
83
|
+
docstring=docstring,
|
|
84
|
+
serializer=serializer,
|
|
85
|
+
response_serializer=response_serializer,
|
|
86
|
+
permissions=permissions,
|
|
87
|
+
name=name,
|
|
88
|
+
)
|