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.
- statezero/adaptors/django/actions.py +171 -0
- statezero/adaptors/django/apps.py +57 -17
- statezero/adaptors/django/urls.py +5 -3
- statezero/adaptors/django/views.py +133 -31
- statezero/core/actions.py +88 -0
- statezero/core/interfaces.py +24 -2
- {statezero-0.1.0b5.dist-info → statezero-0.1.0b7.dist-info}/METADATA +2 -2
- {statezero-0.1.0b5.dist-info → statezero-0.1.0b7.dist-info}/RECORD +11 -9
- {statezero-0.1.0b5.dist-info → statezero-0.1.0b7.dist-info}/WHEEL +0 -0
- {statezero-0.1.0b5.dist-info → statezero-0.1.0b7.dist-info}/licenses/license.md +0 -0
- {statezero-0.1.0b5.dist-info → statezero-0.1.0b7.dist-info}/top_level.txt +0 -0
|
@@ -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,
|
|
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(
|
|
33
|
-
if (not isinstance(config_file_prefix, str)) or (
|
|
34
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
+
)
|
statezero/core/interfaces.py
CHANGED
|
@@ -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.
|
|
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
|
|
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/
|
|
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=
|
|
18
|
-
statezero/adaptors/django/views.py,sha256=
|
|
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=
|
|
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.
|
|
43
|
-
statezero-0.1.
|
|
44
|
-
statezero-0.1.
|
|
45
|
-
statezero-0.1.
|
|
46
|
-
statezero-0.1.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|