statezero 0.1.0b20__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/__init__.py +0 -0
- statezero/adaptors/__init__.py +0 -0
- statezero/adaptors/django/__init__.py +0 -0
- statezero/adaptors/django/actions.py +248 -0
- statezero/adaptors/django/apps.py +137 -0
- statezero/adaptors/django/config.py +99 -0
- statezero/adaptors/django/context_manager.py +12 -0
- statezero/adaptors/django/event_emitters.py +78 -0
- statezero/adaptors/django/exception_handler.py +98 -0
- statezero/adaptors/django/extensions/__init__.py +0 -0
- statezero/adaptors/django/extensions/custom_field_serializers/__init__.py +0 -0
- statezero/adaptors/django/extensions/custom_field_serializers/file_fields.py +141 -0
- statezero/adaptors/django/extensions/custom_field_serializers/money_field.py +83 -0
- statezero/adaptors/django/f_handler.py +312 -0
- statezero/adaptors/django/helpers.py +153 -0
- statezero/adaptors/django/middleware.py +10 -0
- statezero/adaptors/django/migrations/0001_initial.py +33 -0
- statezero/adaptors/django/migrations/0002_delete_modelviewsubscription.py +16 -0
- statezero/adaptors/django/migrations/__init__.py +0 -0
- statezero/adaptors/django/orm.py +1073 -0
- statezero/adaptors/django/permissions.py +252 -0
- statezero/adaptors/django/query_optimizer.py +810 -0
- statezero/adaptors/django/schemas.py +360 -0
- statezero/adaptors/django/search_providers/__init__.py +0 -0
- statezero/adaptors/django/search_providers/basic_search.py +24 -0
- statezero/adaptors/django/search_providers/postgres_search.py +51 -0
- statezero/adaptors/django/serializers.py +588 -0
- statezero/adaptors/django/urls.py +18 -0
- statezero/adaptors/django/views.py +588 -0
- statezero/core/__init__.py +34 -0
- statezero/core/actions.py +92 -0
- statezero/core/ast_parser.py +961 -0
- statezero/core/ast_validator.py +266 -0
- statezero/core/classes.py +224 -0
- statezero/core/config.py +267 -0
- statezero/core/context_storage.py +4 -0
- statezero/core/event_bus.py +175 -0
- statezero/core/event_emitters.py +60 -0
- statezero/core/exceptions.py +106 -0
- statezero/core/hook_checks.py +86 -0
- statezero/core/interfaces.py +677 -0
- statezero/core/process_request.py +184 -0
- statezero/core/types.py +29 -0
- statezero-0.1.0b20.dist-info/METADATA +242 -0
- statezero-0.1.0b20.dist-info/RECORD +48 -0
- statezero-0.1.0b20.dist-info/WHEEL +5 -0
- statezero-0.1.0b20.dist-info/licenses/license.md +117 -0
- statezero-0.1.0b20.dist-info/top_level.txt +1 -0
statezero/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from django.apps import apps
|
|
3
|
+
from rest_framework.response import Response
|
|
4
|
+
from rest_framework import fields, serializers
|
|
5
|
+
from django.db import models
|
|
6
|
+
from statezero.core.actions import action_registry
|
|
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
|
+
input_properties, input_relationships = DjangoActionSchemaGenerator._get_serializer_schema(
|
|
45
|
+
action_config["serializer"]
|
|
46
|
+
)
|
|
47
|
+
response_properties, response_relationships = DjangoActionSchemaGenerator._get_serializer_schema(
|
|
48
|
+
action_config["response_serializer"]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Serialize display metadata if present
|
|
52
|
+
display_data = None
|
|
53
|
+
if action_config.get("display"):
|
|
54
|
+
display_data = DjangoActionSchemaGenerator._serialize_display_metadata(
|
|
55
|
+
action_config["display"]
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
schema_info = {
|
|
59
|
+
"action_name": action_name,
|
|
60
|
+
"app": app_name,
|
|
61
|
+
"title": action_name.replace("_", " ").title(),
|
|
62
|
+
"docstring": docstring,
|
|
63
|
+
"class_name": "".join(
|
|
64
|
+
word.capitalize() for word in action_name.split("_")
|
|
65
|
+
),
|
|
66
|
+
"input_properties": input_properties,
|
|
67
|
+
"response_properties": response_properties,
|
|
68
|
+
"relationships": {**input_relationships, **response_relationships},
|
|
69
|
+
"permissions": [
|
|
70
|
+
perm.__name__ for perm in action_config.get("permissions", [])
|
|
71
|
+
],
|
|
72
|
+
"display": display_data,
|
|
73
|
+
}
|
|
74
|
+
actions_schema[action_name] = schema_info
|
|
75
|
+
|
|
76
|
+
return Response({"actions": actions_schema, "count": len(actions_schema)})
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _get_serializer_schema(serializer_class):
|
|
80
|
+
if not serializer_class:
|
|
81
|
+
return {}, {}
|
|
82
|
+
try:
|
|
83
|
+
serializer_instance = serializer_class()
|
|
84
|
+
properties = {}
|
|
85
|
+
relationships = {}
|
|
86
|
+
for field_name, field in serializer_instance.fields.items():
|
|
87
|
+
relation_info = DjangoActionSchemaGenerator._get_relation_info(field)
|
|
88
|
+
if relation_info:
|
|
89
|
+
relationships[field_name] = relation_info
|
|
90
|
+
|
|
91
|
+
field_info = {
|
|
92
|
+
"type": DjangoActionSchemaGenerator._get_field_type(field),
|
|
93
|
+
"title": getattr(field, "label")
|
|
94
|
+
or field_name.replace("_", " ").title(),
|
|
95
|
+
"required": field.required,
|
|
96
|
+
"description": getattr(field, "help_text", None),
|
|
97
|
+
"nullable": getattr(field, "allow_null", False),
|
|
98
|
+
"format": DjangoActionSchemaGenerator._get_field_format(field),
|
|
99
|
+
"max_length": getattr(field, "max_length", None),
|
|
100
|
+
"choices": DjangoActionSchemaGenerator._get_field_choices(field),
|
|
101
|
+
"default": DjangoActionSchemaGenerator._get_field_default(field),
|
|
102
|
+
"validators": [],
|
|
103
|
+
"max_digits": getattr(field, "max_digits", None),
|
|
104
|
+
"decimal_places": getattr(field, "decimal_places", None),
|
|
105
|
+
"read_only": field.read_only,
|
|
106
|
+
"ref": None,
|
|
107
|
+
}
|
|
108
|
+
if hasattr(field, "max_value") and field.max_value is not None:
|
|
109
|
+
field_info["max_value"] = field.max_value
|
|
110
|
+
if hasattr(field, "min_value") and field.min_value is not None:
|
|
111
|
+
field_info["min_value"] = field.min_value
|
|
112
|
+
if hasattr(field, "min_length") and field.min_length is not None:
|
|
113
|
+
field_info["min_length"] = field.min_length
|
|
114
|
+
properties[field_name] = field_info
|
|
115
|
+
return properties, relationships
|
|
116
|
+
except Exception as e:
|
|
117
|
+
print(f"Could not inspect serializer: {str(e)}")
|
|
118
|
+
raise e
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _get_field_type(field):
|
|
122
|
+
if isinstance(field, serializers.PrimaryKeyRelatedField):
|
|
123
|
+
pk_field = field.queryset.model._meta.pk
|
|
124
|
+
if isinstance(pk_field, (models.UUIDField, models.CharField)):
|
|
125
|
+
return "string"
|
|
126
|
+
return "integer"
|
|
127
|
+
|
|
128
|
+
# Handle nested serializers (many=True creates a ListSerializer)
|
|
129
|
+
if isinstance(field, serializers.ListSerializer):
|
|
130
|
+
return "array"
|
|
131
|
+
|
|
132
|
+
# Handle nested serializers (single nested serializer)
|
|
133
|
+
if isinstance(field, serializers.Serializer):
|
|
134
|
+
return "object"
|
|
135
|
+
|
|
136
|
+
type_mapping = {
|
|
137
|
+
fields.BooleanField: "boolean",
|
|
138
|
+
fields.CharField: "string",
|
|
139
|
+
fields.EmailField: "string",
|
|
140
|
+
fields.URLField: "string",
|
|
141
|
+
fields.UUIDField: "string",
|
|
142
|
+
fields.IntegerField: "integer",
|
|
143
|
+
fields.FloatField: "number",
|
|
144
|
+
fields.DecimalField: "string",
|
|
145
|
+
fields.DateField: "string",
|
|
146
|
+
fields.DateTimeField: "string",
|
|
147
|
+
fields.TimeField: "string",
|
|
148
|
+
fields.JSONField: "object",
|
|
149
|
+
fields.DictField: "object",
|
|
150
|
+
fields.ListField: "array",
|
|
151
|
+
serializers.ManyRelatedField: "array",
|
|
152
|
+
}
|
|
153
|
+
return type_mapping.get(type(field), "string")
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _get_field_format(field):
|
|
157
|
+
format_mapping = {
|
|
158
|
+
fields.EmailField: "email",
|
|
159
|
+
fields.URLField: "uri",
|
|
160
|
+
fields.UUIDField: "uuid",
|
|
161
|
+
fields.DateField: "date",
|
|
162
|
+
fields.DateTimeField: "date-time",
|
|
163
|
+
fields.TimeField: "time",
|
|
164
|
+
serializers.ManyRelatedField: "many-to-many",
|
|
165
|
+
serializers.PrimaryKeyRelatedField: "foreign-key",
|
|
166
|
+
}
|
|
167
|
+
return format_mapping.get(type(field))
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _get_field_choices(field):
|
|
171
|
+
if hasattr(field, "choices") and field.choices:
|
|
172
|
+
choices = field.choices
|
|
173
|
+
|
|
174
|
+
# Handle dict format: {'low': 'Low', 'high': 'High'}
|
|
175
|
+
if isinstance(choices, dict):
|
|
176
|
+
return choices
|
|
177
|
+
|
|
178
|
+
# Handle list/tuple format: [('low', 'Low'), ('high', 'High')]
|
|
179
|
+
elif isinstance(choices, (list, tuple)):
|
|
180
|
+
try:
|
|
181
|
+
# Return dict with value->label mapping (same as model)
|
|
182
|
+
return {str(choice[0]): choice[1] for choice in choices}
|
|
183
|
+
except (IndexError, TypeError) as e:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
f"Invalid choice format for field '{field}'. Expected list of tuples "
|
|
186
|
+
f"like [('value', 'display')], but got: {choices}. Error: {e}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Handle unexpected format
|
|
190
|
+
else:
|
|
191
|
+
raise ValueError(
|
|
192
|
+
f"Unsupported choice format for field '{field}'. Expected dict or list of tuples, "
|
|
193
|
+
f"but got {type(choices)}: {choices}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def _get_field_default(field):
|
|
200
|
+
if hasattr(field, "default"):
|
|
201
|
+
default = field.default
|
|
202
|
+
if default is fields.empty:
|
|
203
|
+
return None
|
|
204
|
+
if callable(default):
|
|
205
|
+
return None
|
|
206
|
+
return default
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
def _get_relation_info(field):
|
|
211
|
+
relation_type = DjangoActionSchemaGenerator._get_field_format(field)
|
|
212
|
+
if not relation_type in ["foreign-key", "one-to-one", "many-to-many"]:
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
if isinstance(field, serializers.PrimaryKeyRelatedField):
|
|
216
|
+
model = field.queryset.model
|
|
217
|
+
return {
|
|
218
|
+
"type": relation_type,
|
|
219
|
+
"model": f"{model._meta.app_label}.{model._meta.model_name}",
|
|
220
|
+
"class_name": model.__name__,
|
|
221
|
+
"primary_key_field": model._meta.pk.name,
|
|
222
|
+
}
|
|
223
|
+
if isinstance(field, serializers.ManyRelatedField):
|
|
224
|
+
model = field.child_relation.queryset.model
|
|
225
|
+
return {
|
|
226
|
+
"type": relation_type,
|
|
227
|
+
"model": f"{model._meta.app_label}.{model._meta.model_name}",
|
|
228
|
+
"class_name": model.__name__,
|
|
229
|
+
"primary_key_field": model._meta.pk.name,
|
|
230
|
+
}
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def _serialize_display_metadata(display):
|
|
235
|
+
"""Convert DisplayMetadata dataclass to dict for JSON serialization"""
|
|
236
|
+
from dataclasses import asdict, is_dataclass
|
|
237
|
+
|
|
238
|
+
if display is None:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
if is_dataclass(display):
|
|
242
|
+
return asdict(display)
|
|
243
|
+
|
|
244
|
+
# If it's already a dict, return as-is
|
|
245
|
+
if isinstance(display, dict):
|
|
246
|
+
return display
|
|
247
|
+
|
|
248
|
+
return None
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from django.apps import AppConfig as DjangoAppConfig
|
|
6
|
+
from django.apps import apps
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
|
|
9
|
+
from statezero.adaptors.django.config import config, registry
|
|
10
|
+
|
|
11
|
+
# Attempt to import Rich for nicer console output.
|
|
12
|
+
try:
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
except ImportError:
|
|
18
|
+
console = None
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StateZeroDjangoConfig(DjangoAppConfig):
|
|
24
|
+
name = "statezero.adaptors.django"
|
|
25
|
+
verbose_name = "StateZero Django Integration"
|
|
26
|
+
label = "statezero"
|
|
27
|
+
|
|
28
|
+
def ready(self):
|
|
29
|
+
# Import crud modules which register models in the registry.
|
|
30
|
+
if hasattr(settings, "CONFIG_FILE_PREFIX"):
|
|
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 (
|
|
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
|
+
)
|
|
39
|
+
else:
|
|
40
|
+
config_file_prefix = "crud"
|
|
41
|
+
for app_config_instance in apps.get_app_configs():
|
|
42
|
+
module_name = f"{app_config_instance.name}.{config_file_prefix}"
|
|
43
|
+
try:
|
|
44
|
+
importlib.import_module(module_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}")
|
|
59
|
+
except ModuleNotFoundError:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
# Once all the apps are imported, initialize StateZero and provide the registry to the event bus.
|
|
63
|
+
config.initialize()
|
|
64
|
+
config.validate_exposed_models(
|
|
65
|
+
registry
|
|
66
|
+
) # Raises an exception if a non StateZero model is implicitly exposed
|
|
67
|
+
config.event_bus.set_registry(registry)
|
|
68
|
+
|
|
69
|
+
# Print the list of published models and actions to confirm StateZero is running.
|
|
70
|
+
try:
|
|
71
|
+
published_models = []
|
|
72
|
+
for model in registry._models_config.keys():
|
|
73
|
+
# Use the ORM provider's get_model_name to get the namespaced model name.
|
|
74
|
+
model_name = model.__name__
|
|
75
|
+
published_models.append(model_name)
|
|
76
|
+
|
|
77
|
+
# Get registered actions
|
|
78
|
+
registered_actions = list(action_registry.get_actions().keys())
|
|
79
|
+
|
|
80
|
+
# Build base message for models
|
|
81
|
+
if published_models:
|
|
82
|
+
models_message = (
|
|
83
|
+
"[bold green]StateZero is exposing models:[/bold green] [bold yellow]"
|
|
84
|
+
+ ", ".join(published_models)
|
|
85
|
+
+ "[/bold yellow]"
|
|
86
|
+
)
|
|
87
|
+
else:
|
|
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
|
|
108
|
+
|
|
109
|
+
# Append the npm command instruction only in debug mode.
|
|
110
|
+
if (published_models or registered_actions) and settings.DEBUG:
|
|
111
|
+
npm_message = (
|
|
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. "
|
|
114
|
+
"Note: This command should only be executed in a development environment."
|
|
115
|
+
)
|
|
116
|
+
message = base_message + npm_message
|
|
117
|
+
else:
|
|
118
|
+
message = base_message
|
|
119
|
+
|
|
120
|
+
# Use Rich Panel for a boxed display if Rich is available.
|
|
121
|
+
if console:
|
|
122
|
+
final_message = Panel(message, expand=False)
|
|
123
|
+
console.print(final_message)
|
|
124
|
+
else:
|
|
125
|
+
# Fallback to simple demarcation lines if Rich isn't available.
|
|
126
|
+
demarcation = "\n" + "-" * 50 + "\n"
|
|
127
|
+
final_message = demarcation + message + demarcation
|
|
128
|
+
logger.info(final_message)
|
|
129
|
+
except Exception as e:
|
|
130
|
+
error_message = f"[bold red]Error retrieving published models and actions: {e}[/bold red]"
|
|
131
|
+
if console:
|
|
132
|
+
final_message = Panel(error_message, expand=False)
|
|
133
|
+
console.print(final_message)
|
|
134
|
+
else:
|
|
135
|
+
demarcation = "\n" + "-" * 50 + "\n"
|
|
136
|
+
final_message = demarcation + error_message + demarcation
|
|
137
|
+
logger.info(final_message)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
5
|
+
from django.utils.module_loading import import_string
|
|
6
|
+
import warnings
|
|
7
|
+
|
|
8
|
+
from statezero.adaptors.django.query_optimizer import DjangoQueryOptimizer
|
|
9
|
+
from statezero.adaptors.django.context_manager import query_timeout
|
|
10
|
+
from statezero.core.config import AppConfig, Registry
|
|
11
|
+
from django.db.models import FileField
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from django.db.models import ImageField
|
|
15
|
+
image_field_available = True
|
|
16
|
+
except ImportError:
|
|
17
|
+
ImageField = None
|
|
18
|
+
image_field_available = False
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
class DjangoLocalConfig(AppConfig):
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.DEBUG = settings.DEBUG
|
|
25
|
+
|
|
26
|
+
def initialize(self):
|
|
27
|
+
from statezero.adaptors.django.event_emitters import \
|
|
28
|
+
DjangoPusherEventEmitter, DjangoConsoleEventEmitter
|
|
29
|
+
from statezero.adaptors.django.orm import DjangoORMAdapter
|
|
30
|
+
from statezero.adaptors.django.schemas import DjangoSchemaGenerator
|
|
31
|
+
from statezero.adaptors.django.serializers import DRFDynamicSerializer
|
|
32
|
+
from statezero.adaptors.django.search_providers.basic_search import BasicSearchProvider
|
|
33
|
+
from statezero.core.event_bus import EventBus
|
|
34
|
+
|
|
35
|
+
# Initialize serializer, schema generator, and ORM adapter.
|
|
36
|
+
self.serializer = DRFDynamicSerializer()
|
|
37
|
+
self.schema_generator = DjangoSchemaGenerator()
|
|
38
|
+
self.orm_provider = DjangoORMAdapter()
|
|
39
|
+
self.context_manager = query_timeout
|
|
40
|
+
self.query_optimizer = DjangoQueryOptimizer
|
|
41
|
+
|
|
42
|
+
# Instantiate emitters by injecting only the necessary functions.
|
|
43
|
+
if hasattr(settings, 'STATEZERO_PUSHER'):
|
|
44
|
+
event_emitter = DjangoPusherEventEmitter()
|
|
45
|
+
else:
|
|
46
|
+
warnings.warn("You have not added STATEZERO_PUSHER to your settings.py. Live model changes will not be broadcast")
|
|
47
|
+
event_emitter = DjangoConsoleEventEmitter()
|
|
48
|
+
|
|
49
|
+
# Create the EventBus with two explicit emitters.
|
|
50
|
+
self.event_bus = EventBus(
|
|
51
|
+
broadcast_emitter=event_emitter,
|
|
52
|
+
orm_provider=self.orm_provider,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Setup the search provider
|
|
56
|
+
self.search_provider = BasicSearchProvider()
|
|
57
|
+
|
|
58
|
+
self.file_upload_callbacks = None
|
|
59
|
+
|
|
60
|
+
# Explicitly register event signals after both components are configured.
|
|
61
|
+
self.orm_provider.register_event_signals(self.event_bus)
|
|
62
|
+
|
|
63
|
+
from statezero.adaptors.django.extensions.custom_field_serializers.file_fields import (
|
|
64
|
+
FileFieldSerializer, ImageFieldSerializer)
|
|
65
|
+
|
|
66
|
+
# Initialize custom serializers
|
|
67
|
+
self.custom_serializers = {
|
|
68
|
+
FileField: FileFieldSerializer
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if image_field_available:
|
|
72
|
+
self.custom_serializers[ImageField] = ImageFieldSerializer
|
|
73
|
+
|
|
74
|
+
# Try to register djmoney support
|
|
75
|
+
try:
|
|
76
|
+
from statezero.adaptors.django.extensions.custom_field_serializers.money_field import (
|
|
77
|
+
MoneyFieldSchema, MoneyFieldSerializer)
|
|
78
|
+
from djmoney.models.fields import MoneyField
|
|
79
|
+
self.custom_serializers[MoneyField] = MoneyFieldSerializer
|
|
80
|
+
self.schema_overrides = {
|
|
81
|
+
MoneyField: MoneyFieldSchema,
|
|
82
|
+
}
|
|
83
|
+
except Exception:
|
|
84
|
+
self.schema_overrides = {}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Create the singleton instances.
|
|
88
|
+
custom_config_path = getattr(settings, "STATEZERO_CUSTOM_CONFIG", None)
|
|
89
|
+
if custom_config_path:
|
|
90
|
+
custom_config_class = import_string(custom_config_path)
|
|
91
|
+
if not issubclass(custom_config_class, AppConfig):
|
|
92
|
+
raise ImproperlyConfigured(
|
|
93
|
+
"STATEZERO_CUSTOM_CONFIG must be a subclass of AppConfig"
|
|
94
|
+
)
|
|
95
|
+
config = custom_config_class()
|
|
96
|
+
else:
|
|
97
|
+
config = DjangoLocalConfig()
|
|
98
|
+
|
|
99
|
+
registry = Registry()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
from django.db import connection
|
|
3
|
+
|
|
4
|
+
@contextmanager
|
|
5
|
+
def query_timeout(timeout_ms):
|
|
6
|
+
if connection.vendor == 'postgresql':
|
|
7
|
+
with connection.cursor() as cursor:
|
|
8
|
+
cursor.execute(f'SET LOCAL statement_timeout = {timeout_ms};')
|
|
9
|
+
yield
|
|
10
|
+
else:
|
|
11
|
+
# For SQLite or others, no operation
|
|
12
|
+
yield
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
import logging
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from rest_framework.request import Request
|
|
5
|
+
from django.utils.module_loading import import_string
|
|
6
|
+
|
|
7
|
+
from statezero.core.event_emitters import ConsoleEventEmitter, PusherEventEmitter
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DjangoConsoleEventEmitter(ConsoleEventEmitter):
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
super().__init__()
|
|
15
|
+
|
|
16
|
+
permission_class_path = getattr(
|
|
17
|
+
settings,
|
|
18
|
+
"STATEZERO_VIEW_ACCESS_CLASS",
|
|
19
|
+
"rest_framework.permissions.IsAuthenticated",
|
|
20
|
+
)
|
|
21
|
+
try:
|
|
22
|
+
self.permission_class = import_string(permission_class_path)()
|
|
23
|
+
logger.debug("Using emitter permission class: %s", permission_class_path)
|
|
24
|
+
except Exception as e:
|
|
25
|
+
logger.error("Error importing emitter permission class '%s': %s", permission_class_path, str(e))
|
|
26
|
+
from rest_framework.permissions import IsAuthenticated
|
|
27
|
+
self.permission_class = IsAuthenticated()
|
|
28
|
+
|
|
29
|
+
def has_permission(self, request: Request, namespace: str) -> bool:
|
|
30
|
+
return self.permission_class.has_permission(request, None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DjangoPusherEventEmitter(PusherEventEmitter):
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
app_id: Optional[str] = None,
|
|
37
|
+
key: Optional[str] = None,
|
|
38
|
+
secret: Optional[str] = None,
|
|
39
|
+
cluster: Optional[str] = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
import pusher
|
|
42
|
+
|
|
43
|
+
app_id = app_id or settings.STATEZERO_PUSHER.get("APP_ID")
|
|
44
|
+
key = key or settings.STATEZERO_PUSHER.get("KEY")
|
|
45
|
+
secret = secret or settings.STATEZERO_PUSHER.get("SECRET")
|
|
46
|
+
cluster = cluster or settings.STATEZERO_PUSHER.get("CLUSTER")
|
|
47
|
+
|
|
48
|
+
if not all([app_id, key, secret, cluster]):
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"Pusher credentials must be provided via parameters or defined in settings as "
|
|
51
|
+
"'APP_ID', 'KEY', 'SECRET', and 'CLUSTER'."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
pusher_client = pusher.Pusher(
|
|
55
|
+
app_id=app_id,
|
|
56
|
+
key=key,
|
|
57
|
+
secret=secret,
|
|
58
|
+
cluster=cluster,
|
|
59
|
+
ssl=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
super().__init__(pusher_client=pusher_client)
|
|
63
|
+
|
|
64
|
+
permission_class_path = getattr(
|
|
65
|
+
settings,
|
|
66
|
+
"STATEZERO_VIEW_ACCESS_CLASS",
|
|
67
|
+
"rest_framework.permissions.IsAuthenticated",
|
|
68
|
+
)
|
|
69
|
+
try:
|
|
70
|
+
self.permission_class = import_string(permission_class_path)()
|
|
71
|
+
logger.debug("Using emitter permission class: %s", permission_class_path)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.error("Error importing emitter permission class '%s': %s", permission_class_path, str(e))
|
|
74
|
+
from rest_framework.permissions import IsAuthenticated
|
|
75
|
+
self.permission_class = IsAuthenticated()
|
|
76
|
+
|
|
77
|
+
def has_permission(self, request: Request, namespace: str) -> bool:
|
|
78
|
+
return self.permission_class.has_permission(request, None)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import traceback
|
|
3
|
+
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.core.exceptions import \
|
|
6
|
+
MultipleObjectsReturned as DjangoMultipleObjectsReturned
|
|
7
|
+
from django.core.exceptions import ObjectDoesNotExist
|
|
8
|
+
from django.core.exceptions import PermissionDenied as DjangoPermissionDenied
|
|
9
|
+
from django.core.exceptions import ValidationError as DjangoValidationError
|
|
10
|
+
# Import Django/DRF exceptions.
|
|
11
|
+
from django.db.models import Model
|
|
12
|
+
from django.http import Http404
|
|
13
|
+
from fastapi.encoders import jsonable_encoder # Requires fastapi dependency
|
|
14
|
+
from rest_framework import status
|
|
15
|
+
from rest_framework.exceptions import NotFound as DRFNotFound
|
|
16
|
+
from rest_framework.exceptions import PermissionDenied as DRFPermissionDenied
|
|
17
|
+
from rest_framework.exceptions import ValidationError as DRFValidationError
|
|
18
|
+
from rest_framework.response import Response
|
|
19
|
+
|
|
20
|
+
# Import your custom StateZero exception types.
|
|
21
|
+
from statezero.core.exceptions import (ErrorDetail, StateZeroError,
|
|
22
|
+
MultipleObjectsReturned, NotFound,
|
|
23
|
+
PermissionDenied, ValidationError)
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
logger.setLevel(logging.DEBUG)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def map_exception(exc):
|
|
30
|
+
"""
|
|
31
|
+
Map Django/DRF exceptions to your library’s errors.
|
|
32
|
+
If the exception is already one of your library's types, return it as is.
|
|
33
|
+
"""
|
|
34
|
+
logger.debug("Mapping exception type: %s", type(exc))
|
|
35
|
+
|
|
36
|
+
if isinstance(exc, StateZeroError):
|
|
37
|
+
return exc
|
|
38
|
+
|
|
39
|
+
if isinstance(exc, type) and issubclass(exc, Model.DoesNotExist):
|
|
40
|
+
return NotFound(detail=str(exc))
|
|
41
|
+
|
|
42
|
+
if isinstance(exc, DjangoMultipleObjectsReturned):
|
|
43
|
+
return MultipleObjectsReturned(detail=str(exc))
|
|
44
|
+
if isinstance(exc, (ObjectDoesNotExist, Http404)):
|
|
45
|
+
return NotFound(detail=str(exc))
|
|
46
|
+
if isinstance(exc, DjangoValidationError):
|
|
47
|
+
detail = getattr(exc, "message_dict", getattr(exc, "messages", str(exc)))
|
|
48
|
+
return ValidationError(detail=detail)
|
|
49
|
+
if isinstance(exc, DjangoPermissionDenied):
|
|
50
|
+
return PermissionDenied(detail=str(exc))
|
|
51
|
+
|
|
52
|
+
if isinstance(exc, DRFValidationError):
|
|
53
|
+
return ValidationError(detail=exc.detail)
|
|
54
|
+
if isinstance(exc, DRFNotFound):
|
|
55
|
+
return NotFound(detail=str(exc))
|
|
56
|
+
if isinstance(exc, DRFPermissionDenied):
|
|
57
|
+
return PermissionDenied(detail=str(exc))
|
|
58
|
+
|
|
59
|
+
return exc
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def explicit_exception_handler(exc):
|
|
63
|
+
"""
|
|
64
|
+
Extended explicit exception handler that builds a structured JSON response.
|
|
65
|
+
It maps known Django/DRF exceptions to StateZero's standard errors and
|
|
66
|
+
uses jsonable_encoder to ensure the output is JSON serializable.
|
|
67
|
+
"""
|
|
68
|
+
traceback.print_exc()
|
|
69
|
+
exc = map_exception(exc)
|
|
70
|
+
logger.debug("Using exception type after mapping: %s", type(exc))
|
|
71
|
+
|
|
72
|
+
if isinstance(exc, NotFound):
|
|
73
|
+
status_code = status.HTTP_404_NOT_FOUND
|
|
74
|
+
elif isinstance(exc, PermissionDenied):
|
|
75
|
+
status_code = status.HTTP_403_FORBIDDEN
|
|
76
|
+
elif isinstance(exc, ValidationError):
|
|
77
|
+
status_code = status.HTTP_400_BAD_REQUEST
|
|
78
|
+
else:
|
|
79
|
+
status_code = getattr(exc, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
80
|
+
|
|
81
|
+
# Only show detailed errors for 400 and 404 in production
|
|
82
|
+
# For 403 and 500 errors, only show details in debug mode
|
|
83
|
+
if status_code in [status.HTTP_403_FORBIDDEN, status.HTTP_500_INTERNAL_SERVER_ERROR] and not settings.DEBUG:
|
|
84
|
+
if status_code == status.HTTP_403_FORBIDDEN:
|
|
85
|
+
detail = "Permission denied"
|
|
86
|
+
else:
|
|
87
|
+
detail = "Internal server error"
|
|
88
|
+
else:
|
|
89
|
+
detail = jsonable_encoder(exc.detail) if hasattr(exc, "detail") else str(exc)
|
|
90
|
+
|
|
91
|
+
error_data = {
|
|
92
|
+
"status": status_code,
|
|
93
|
+
"type": exc.__class__.__name__,
|
|
94
|
+
"detail": detail,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
logger.error("Exception handled explicitly: %s", error_data)
|
|
98
|
+
return Response(error_data, status=status_code)
|
|
File without changes
|
|
File without changes
|