statezero 0.1.0b5__py3-none-any.whl → 0.1.0b7__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 statezero might be problematic. Click here for more details.

@@ -0,0 +1,171 @@
1
+ import os
2
+ from django.apps import apps
3
+ from rest_framework.response import Response
4
+ from rest_framework import fields
5
+ from statezero.core.actions import action_registry
6
+
7
+
8
+ class DjangoActionSchemaGenerator:
9
+ """Django-specific action schema generator that matches StateZero model schema format"""
10
+
11
+ @staticmethod
12
+ def generate_actions_schema():
13
+ """Generate schema for all registered actions matching StateZero model schema format"""
14
+ actions_schema = {}
15
+ all_app_configs = list(apps.get_app_configs())
16
+
17
+ for action_name, action_config in action_registry.get_actions().items():
18
+ func = action_config.get("function")
19
+ if not func:
20
+ raise ValueError(
21
+ f"Action '{action_name}' is missing a function and cannot be processed."
22
+ )
23
+
24
+ func_path = os.path.abspath(func.__code__.co_filename)
25
+ found_app = None
26
+
27
+ for app_config in all_app_configs:
28
+ app_path = os.path.abspath(app_config.path)
29
+ if func_path.startswith(app_path + os.sep):
30
+ if not found_app or len(app_path) > len(
31
+ os.path.abspath(found_app.path)
32
+ ):
33
+ found_app = app_config
34
+
35
+ if not found_app:
36
+ raise LookupError(
37
+ f"Action '{action_name}' from file '{func_path}' does not belong to any "
38
+ f"installed Django app. Please ensure the parent app is in INSTALLED_APPS."
39
+ )
40
+
41
+ app_name = found_app.label
42
+ docstring = action_config.get("docstring")
43
+
44
+ schema_info = {
45
+ "action_name": action_name,
46
+ "app": app_name,
47
+ "title": action_name.replace("_", " ").title(),
48
+ "docstring": docstring,
49
+ "class_name": "".join(
50
+ word.capitalize() for word in action_name.split("_")
51
+ ),
52
+ "input_properties": DjangoActionSchemaGenerator._get_serializer_schema(
53
+ action_config["serializer"]
54
+ ),
55
+ "response_properties": DjangoActionSchemaGenerator._get_serializer_schema(
56
+ action_config["response_serializer"]
57
+ ),
58
+ "permissions": [
59
+ perm.__name__ for perm in action_config.get("permissions", [])
60
+ ],
61
+ }
62
+ actions_schema[action_name] = schema_info
63
+
64
+ return Response({"actions": actions_schema, "count": len(actions_schema)})
65
+
66
+ @staticmethod
67
+ def _get_serializer_schema(serializer_class):
68
+ if not serializer_class:
69
+ return {}
70
+ try:
71
+ serializer_instance = serializer_class()
72
+ properties = {}
73
+ for field_name, field in serializer_instance.fields.items():
74
+ field_info = {
75
+ "type": DjangoActionSchemaGenerator._get_field_type(field),
76
+ "title": getattr(field, "label")
77
+ or field_name.replace("_", " ").title(),
78
+ "required": field.required,
79
+ "description": getattr(field, "help_text", None),
80
+ "nullable": getattr(field, "allow_null", False),
81
+ "format": DjangoActionSchemaGenerator._get_field_format(field),
82
+ "max_length": getattr(field, "max_length", None),
83
+ "choices": DjangoActionSchemaGenerator._get_field_choices(field),
84
+ "default": DjangoActionSchemaGenerator._get_field_default(field),
85
+ "validators": [],
86
+ "max_digits": getattr(field, "max_digits", None),
87
+ "decimal_places": getattr(field, "decimal_places", None),
88
+ "read_only": field.read_only,
89
+ "ref": None,
90
+ }
91
+ if hasattr(field, "max_value") and field.max_value is not None:
92
+ field_info["max_value"] = field.max_value
93
+ if hasattr(field, "min_value") and field.min_value is not None:
94
+ field_info["min_value"] = field.min_value
95
+ if hasattr(field, "min_length") and field.min_length is not None:
96
+ field_info["min_length"] = field.min_length
97
+ properties[field_name] = field_info
98
+ return properties
99
+ except Exception as e:
100
+ return {"error": f"Could not inspect serializer: {str(e)}"}
101
+
102
+ @staticmethod
103
+ def _get_field_type(field):
104
+ type_mapping = {
105
+ fields.BooleanField: "boolean",
106
+ fields.CharField: "string",
107
+ fields.EmailField: "string",
108
+ fields.URLField: "string",
109
+ fields.UUIDField: "string",
110
+ fields.IntegerField: "integer",
111
+ fields.FloatField: "number",
112
+ fields.DecimalField: "string",
113
+ fields.DateField: "string",
114
+ fields.DateTimeField: "string",
115
+ fields.TimeField: "string",
116
+ fields.JSONField: "object",
117
+ fields.DictField: "object",
118
+ fields.ListField: "array",
119
+ }
120
+ return type_mapping.get(type(field), "string")
121
+
122
+ @staticmethod
123
+ def _get_field_format(field):
124
+ format_mapping = {
125
+ fields.EmailField: "email",
126
+ fields.URLField: "uri",
127
+ fields.UUIDField: "uuid",
128
+ fields.DateField: "date",
129
+ fields.DateTimeField: "date-time",
130
+ fields.TimeField: "time",
131
+ }
132
+ return format_mapping.get(type(field))
133
+
134
+ @staticmethod
135
+ def _get_field_choices(field):
136
+ if hasattr(field, "choices") and field.choices:
137
+ choices = field.choices
138
+
139
+ # Handle dict format: {'low': 'Low', 'high': 'High'}
140
+ if isinstance(choices, dict):
141
+ return list(choices.keys())
142
+
143
+ # Handle list/tuple format: [('low', 'Low'), ('high', 'High')]
144
+ elif isinstance(choices, (list, tuple)):
145
+ try:
146
+ return [choice[0] for choice in choices]
147
+ except (IndexError, TypeError) as e:
148
+ raise ValueError(
149
+ f"Invalid choice format for field '{field}'. Expected list of tuples "
150
+ f"like [('value', 'display')], but got: {choices}. Error: {e}"
151
+ )
152
+
153
+ # Handle unexpected format
154
+ else:
155
+ raise ValueError(
156
+ f"Unsupported choice format for field '{field}'. Expected dict or list of tuples, "
157
+ f"but got {type(choices)}: {choices}"
158
+ )
159
+
160
+ return None
161
+
162
+ @staticmethod
163
+ def _get_field_default(field):
164
+ if hasattr(field, "default"):
165
+ default = field.default
166
+ if default is fields.empty:
167
+ return None
168
+ if callable(default):
169
+ return None
170
+ return default
171
+ return None
@@ -27,27 +27,46 @@ class StateZeroDjangoConfig(DjangoAppConfig):
27
27
 
28
28
  def ready(self):
29
29
  # Import crud modules which register models in the registry.
30
- if hasattr(settings, 'CONFIG_FILE_PREFIX'):
30
+ if hasattr(settings, "CONFIG_FILE_PREFIX"):
31
31
  config_file_prefix: str = settings.CONFIG_FILE_PREFIX
32
- config_file_prefix = config_file_prefix.replace('.py', '')
33
- if (not isinstance(config_file_prefix, str)) or (len(config_file_prefix) < 1):
34
- raise ValueError(f"If provided, CONFIG_FILE_PREFIX must be a string with at least one character. In your settings.py it is set to {settings.CONFIG_FILE_PREFIX}. Either delete the setting completely or use a valid file name like 'crud'")
32
+ config_file_prefix = config_file_prefix.replace(".py", "")
33
+ if (not isinstance(config_file_prefix, str)) or (
34
+ len(config_file_prefix) < 1
35
+ ):
36
+ raise ValueError(
37
+ f"If provided, CONFIG_FILE_PREFIX must be a string with at least one character. In your settings.py it is set to {settings.CONFIG_FILE_PREFIX}. Either delete the setting completely or use a valid file name like 'crud'"
38
+ )
35
39
  else:
36
40
  config_file_prefix = "crud"
37
41
  for app_config_instance in apps.get_app_configs():
38
42
  module_name = f"{app_config_instance.name}.{config_file_prefix}"
39
43
  try:
40
44
  importlib.import_module(module_name)
41
- logger.debug(f"Imported {config_file_prefix} module from {app_config_instance.name}")
45
+ logger.debug(
46
+ f"Imported {config_file_prefix} module from {app_config_instance.name}"
47
+ )
48
+ except ModuleNotFoundError:
49
+ pass
50
+
51
+ # Import actions modules which register actions in the action registry.
52
+ from statezero.core.actions import action_registry
53
+
54
+ for app_config_instance in apps.get_app_configs():
55
+ actions_module_name = f"{app_config_instance.name}.actions"
56
+ try:
57
+ importlib.import_module(actions_module_name)
58
+ logger.debug(f"Imported actions module from {app_config_instance.name}")
42
59
  except ModuleNotFoundError:
43
60
  pass
44
61
 
45
62
  # Once all the apps are imported, initialize StateZero and provide the registry to the event bus.
46
63
  config.initialize()
47
- config.validate_exposed_models(registry) # Raises an exception if a non StateZero model is implicitly exposed
64
+ config.validate_exposed_models(
65
+ registry
66
+ ) # Raises an exception if a non StateZero model is implicitly exposed
48
67
  config.event_bus.set_registry(registry)
49
68
 
50
- # Print the list of published models (from registry) to confirm StateZero is running.
69
+ # Print the list of published models and actions to confirm StateZero is running.
51
70
  try:
52
71
  published_models = []
53
72
  for model in registry._models_config.keys():
@@ -55,20 +74,43 @@ class StateZeroDjangoConfig(DjangoAppConfig):
55
74
  model_name = model.__name__
56
75
  published_models.append(model_name)
57
76
 
77
+ # Get registered actions
78
+ registered_actions = list(action_registry.get_actions().keys())
79
+
80
+ # Build base message for models
58
81
  if published_models:
59
- base_message = (
82
+ models_message = (
60
83
  "[bold green]StateZero is exposing models:[/bold green] [bold yellow]"
61
84
  + ", ".join(published_models)
62
- + "[/bold yellow]"
85
+ + "[/bold yellow]"
63
86
  )
64
87
  else:
65
- base_message = "[bold yellow]StateZero is running but no models are registered.[/bold yellow]"
88
+ models_message = "[bold yellow]StateZero is running but no models are registered.[/bold yellow]"
89
+
90
+ # Build message for actions (limit to first 10 to avoid cluttering console)
91
+ if registered_actions:
92
+ displayed_actions = registered_actions[:10]
93
+ actions_message = (
94
+ "\n[bold green]StateZero is exposing actions:[/bold green] [bold cyan]"
95
+ + ", ".join(displayed_actions)
96
+ )
97
+ if len(registered_actions) > 10:
98
+ actions_message += (
99
+ f" [dim](and {len(registered_actions) - 10} more)[/dim]"
100
+ )
101
+ actions_message += "[/bold cyan]"
102
+ else:
103
+ actions_message = (
104
+ "\n[bold yellow]No actions are registered.[/bold yellow]"
105
+ )
106
+
107
+ base_message = models_message + actions_message
66
108
 
67
109
  # Append the npm command instruction only in debug mode.
68
- if published_models and settings.DEBUG:
110
+ if (published_models or registered_actions) and settings.DEBUG:
69
111
  npm_message = (
70
- "\n[bold blue]Next step:[/bold blue] Run [italic]npm run sync-models[/italic] in your frontend project directory "
71
- "to generate or update the client-side code corresponding to these models. "
112
+ "\n[bold blue]Next step:[/bold blue] Run [italic]npm run sync[/italic] in your frontend project directory "
113
+ "to generate or update the client-side code corresponding to these models and actions. "
72
114
  "Note: This command should only be executed in a development environment."
73
115
  )
74
116
  message = base_message + npm_message
@@ -85,13 +127,11 @@ class StateZeroDjangoConfig(DjangoAppConfig):
85
127
  final_message = demarcation + message + demarcation
86
128
  logger.info(final_message)
87
129
  except Exception as e:
88
- error_message = (
89
- f"[bold red]Error retrieving published models: {e}[/bold red]"
90
- )
130
+ error_message = f"[bold red]Error retrieving published models and actions: {e}[/bold red]"
91
131
  if console:
92
132
  final_message = Panel(error_message, expand=False)
93
133
  console.print(final_message)
94
134
  else:
95
135
  demarcation = "\n" + "-" * 50 + "\n"
96
136
  final_message = demarcation + error_message + demarcation
97
- logger.info(final_message)
137
+ logger.info(final_message)
@@ -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 = [permission_class]
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
+ )
@@ -452,6 +452,29 @@ class AbstractEventEmitter(ABC):
452
452
  # --- Permissions ---
453
453
 
454
454
 
455
+ class AbstractActionPermission(ABC):
456
+ """
457
+ Permission class for StateZero actions.
458
+ Similar to DRF BasePermission but with access to validated data and
459
+ gives the action instead of the view.
460
+ """
461
+
462
+ @abstractmethod
463
+ def has_permission(self, request, action_name: str) -> bool:
464
+ """
465
+ View-level permission check (before validation).
466
+ Similar to DRF BasePermission.has_permission
467
+ """
468
+ pass
469
+
470
+ @abstractmethod
471
+ def has_action_permission(self, request, action_name: str, validated_data: dict) -> bool:
472
+ """
473
+ Action-level permission check (after validation).
474
+ This is where you check permissions that depend on the actual data.
475
+ """
476
+ pass
477
+
455
478
  class AbstractPermission(ABC):
456
479
  @abstractmethod
457
480
  def filter_queryset(
@@ -526,7 +549,6 @@ class AbstractPermission(ABC):
526
549
  """
527
550
  pass
528
551
 
529
-
530
552
  class AbstractSearchProvider(ABC):
531
553
  """Base class for search providers in StateZero."""
532
554
 
@@ -613,4 +635,4 @@ class AbstractQueryOptimizer(ABC):
613
635
  ValueError: If required parameters (like 'fields' or init config
614
636
  for generation) are missing.
615
637
  """
616
- raise NotImplementedError
638
+ raise NotImplementedError
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statezero
3
- Version: 0.1.0b5
3
+ Version: 0.1.0b7
4
4
  Summary: Connect your Python backend to a modern JavaScript SPA frontend with 90% less complexity.
5
5
  Author-email: Robert <robert.herring@statezero.dev>
6
6
  Project-URL: homepage, https://www.statezero.dev
@@ -222,7 +222,7 @@ npm i @statezero/core
222
222
  ### Generate TypeScript Models
223
223
 
224
224
  ```bash
225
- npx statezero sync-models
225
+ npx statezero sync
226
226
  ```
227
227
 
228
228
  ## Why Choose StateZero Over...
@@ -1,7 +1,8 @@
1
1
  statezero/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  statezero/adaptors/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  statezero/adaptors/django/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- statezero/adaptors/django/apps.py,sha256=UtL1XTZ70_9-q0ZEct0getDnaxJxnEjo_P9iEIqiYYE,4388
4
+ statezero/adaptors/django/actions.py,sha256=Rm6riu-0zcaJKy65Eni2_GQ_NBg6-62sHit1WW4qFdo,7242
5
+ statezero/adaptors/django/apps.py,sha256=51ZvUIDmHeAIazoG2qraUZuBGbONJJzLQ28ob_-smU8,6055
5
6
  statezero/adaptors/django/config.py,sha256=FVoKf-bYv0GAeAFjpQhfDnvn-L1c2noKXAg0B79egVo,3878
6
7
  statezero/adaptors/django/context_manager.py,sha256=Vrscb63wGJ2frXnOPPcJGULiyDkPnRO2SUhN-K-pJeI,379
7
8
  statezero/adaptors/django/event_emitters.py,sha256=Lb7NW7yFf_KxczVbj8g0vRcKZEny15t25m7PyIi2d3s,3057
@@ -14,8 +15,8 @@ statezero/adaptors/django/permissions.py,sha256=fU2c4bKK0zX2uuVB0UazZHTI-5OkiI5-
14
15
  statezero/adaptors/django/query_optimizer.py,sha256=-GNqL7Xn8WP8OsLEAAxXpIszSyEwm-l6WjgdkEFzxUM,38541
15
16
  statezero/adaptors/django/schemas.py,sha256=shq8ed9qHCnbCfYVsRxVE7V3R3GhGIKeRRj7dI3r1IU,12728
16
17
  statezero/adaptors/django/serializers.py,sha256=YFFDu6bzoWkSEOVH5Wmc4yJ8SaOkUA6HbXXYt6djlfc,23296
17
- statezero/adaptors/django/urls.py,sha256=G4LAUVG1WtPCyB_POQ3wa_VKqKCSI6p3fHZGy6GV99g,636
18
- statezero/adaptors/django/views.py,sha256=ZoTocBkqv8sdxaq5bMOZpsj1zko5FDcSYP1C9m8s9Hw,13723
18
+ statezero/adaptors/django/urls.py,sha256=_Ylta5Bba0eI6pDvO7XddMt9ffEutx3JmZS2mSSi5DQ,828
19
+ statezero/adaptors/django/views.py,sha256=RTBuGc5iFnt6fjdatA-mNttzAZ3-aNA3Brf5f0ODyFI,17974
19
20
  statezero/adaptors/django/extensions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
21
  statezero/adaptors/django/extensions/custom_field_serializers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
22
  statezero/adaptors/django/extensions/custom_field_serializers/file_fields.py,sha256=BaOaJPmyzCp-YFwpsTOvGHjHpk6s8UJuZ5JsF-PEGV4,4518
@@ -27,6 +28,7 @@ statezero/adaptors/django/search_providers/__init__.py,sha256=47DEQpj8HBSa-_TImW
27
28
  statezero/adaptors/django/search_providers/basic_search.py,sha256=5_GJ1r_B6JdIYXL6yYEYn5mik88EolSH5aZvygc_UF0,977
28
29
  statezero/adaptors/django/search_providers/postgres_search.py,sha256=IMoHxzfi-Y3hAxPND4Xc6GatrPs1eXAqpmcfwt5Zr14,2459
29
30
  statezero/core/__init__.py,sha256=Z6RTutAAElLMEjBFphVVmpySPdJBA55j-Uo0BtR7c5E,1040
31
+ statezero/core/actions.py,sha256=uwN9O0NOcDhZtW3X3cq066_H9CyO-8WtjO38gyxxudo,2752
30
32
  statezero/core/ast_parser.py,sha256=ezEHqVB1Afqw_GSyUs4Dh0W94xFM4aaNEaxyYMk-rr4,38756
31
33
  statezero/core/ast_validator.py,sha256=YZAflPyba0kXWBNhd1Z_XeEk-_zUzM6MkY9zSlX1PMs,11582
32
34
  statezero/core/classes.py,sha256=-rJ8szqGGzsFxE3TvbtYHFHFP9Kg2WP24aYi74Io338,4923
@@ -36,11 +38,11 @@ statezero/core/event_bus.py,sha256=2IFLBHSkLzpm1AX0MfSXSmF2X-lXK-gOoODZCtB2Jdw,6
36
38
  statezero/core/event_emitters.py,sha256=qjMbeUmdn4bG7WiVfqTmNdaflEea5amnTEpOn5X0J44,2046
37
39
  statezero/core/exceptions.py,sha256=_krMHWW9qBbMXvvqFdWf85a3Kayn7XbJczfC3x3gmBI,3330
38
40
  statezero/core/hook_checks.py,sha256=uqtvwRx1qGsF7Vc49elAWdOjMzhuv3RADKY1wiLvhK4,3425
39
- statezero/core/interfaces.py,sha256=VCekst5esL9Yh739tW8PSedRS_s50qhEzlnJ8pwaxtU,19064
41
+ statezero/core/interfaces.py,sha256=uUWSq6k_hXqryhUILvBQP3L2bGMEpFKXglxIT-Tom7U,19818
40
42
  statezero/core/process_request.py,sha256=dwIeBEVOE8zA-oE1h65XNOGiVqFbbXA7SzTAguLNgZk,8060
41
43
  statezero/core/types.py,sha256=K9x9AU5J6yd2AWvqRz27CeAY6UYfuQoQ7xTEwTijrmM,1982
42
- statezero-0.1.0b5.dist-info/licenses/license.md,sha256=0uKjybTt9K_YbEmYgf25JN292qjjJ-BPofvIZ3wdtX4,7411
43
- statezero-0.1.0b5.dist-info/METADATA,sha256=cHmsrjlDYnl5F-H5HSHcgqpP13UMLfZr9M8gqoC7UZc,7152
44
- statezero-0.1.0b5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
45
- statezero-0.1.0b5.dist-info/top_level.txt,sha256=UAuZYPKczradU1kcMQxsGjUzEW0qdgsqzhXyscrcLpw,10
46
- statezero-0.1.0b5.dist-info/RECORD,,
44
+ statezero-0.1.0b7.dist-info/licenses/license.md,sha256=0uKjybTt9K_YbEmYgf25JN292qjjJ-BPofvIZ3wdtX4,7411
45
+ statezero-0.1.0b7.dist-info/METADATA,sha256=GzBRo7i-WnqLOPVX_HYJ7CfzbeEpkrlDD3jOZ4Iz57M,7145
46
+ statezero-0.1.0b7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
47
+ statezero-0.1.0b7.dist-info/top_level.txt,sha256=UAuZYPKczradU1kcMQxsGjUzEW0qdgsqzhXyscrcLpw,10
48
+ statezero-0.1.0b7.dist-info/RECORD,,