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.
@@ -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.core.interfaces import AbstractEventEmitter
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
+ )