statezero 0.1.0b5__tar.gz → 0.1.0b20__tar.gz
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-0.1.0b5 → statezero-0.1.0b20}/PKG-INFO +2 -14
- {statezero-0.1.0b5 → statezero-0.1.0b20}/README.md +2 -14
- {statezero-0.1.0b5 → statezero-0.1.0b20}/pyproject.toml +1 -1
- statezero-0.1.0b20/statezero/adaptors/django/actions.py +248 -0
- statezero-0.1.0b20/statezero/adaptors/django/apps.py +137 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/exception_handler.py +1 -1
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/extensions/custom_field_serializers/money_field.py +8 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/orm.py +109 -1
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/query_optimizer.py +40 -2
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/schemas.py +38 -2
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/serializers.py +39 -24
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/urls.py +7 -3
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/views.py +283 -31
- statezero-0.1.0b20/statezero/core/actions.py +92 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/core/ast_parser.py +26 -26
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/core/classes.py +58 -1
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/core/config.py +4 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/core/interfaces.py +69 -8
- statezero-0.1.0b20/statezero/core/types.py +29 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero.egg-info/PKG-INFO +2 -14
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero.egg-info/SOURCES.txt +2 -0
- statezero-0.1.0b5/statezero/adaptors/django/apps.py +0 -97
- statezero-0.1.0b5/statezero/core/types.py +0 -63
- {statezero-0.1.0b5 → statezero-0.1.0b20}/license.md +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/requirements.txt +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/setup.cfg +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/__init__.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/__init__.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/__init__.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/config.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/context_manager.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/event_emitters.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/extensions/__init__.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/extensions/custom_field_serializers/__init__.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/extensions/custom_field_serializers/file_fields.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/f_handler.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/helpers.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/middleware.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/migrations/0001_initial.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/migrations/0002_delete_modelviewsubscription.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/migrations/__init__.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/permissions.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/search_providers/__init__.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/search_providers/basic_search.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/adaptors/django/search_providers/postgres_search.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/core/__init__.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/core/ast_validator.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/core/context_storage.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/core/event_bus.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/core/event_emitters.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/core/exceptions.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/core/hook_checks.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero/core/process_request.py +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero.egg-info/dependency_links.txt +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero.egg-info/requires.txt +0 -0
- {statezero-0.1.0b5 → statezero-0.1.0b20}/statezero.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: statezero
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.0b20
|
|
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...
|
|
@@ -240,15 +240,3 @@ npx statezero sync-models
|
|
|
240
240
|
Check out the docs at [Statezero Docs](https://statezero.dev)
|
|
241
241
|
|
|
242
242
|
Run `pip install statezero` and `npm i @statezero/core` to begin.
|
|
243
|
-
|
|
244
|
-
## Pricing
|
|
245
|
-
|
|
246
|
-
StateZero uses a no-rugpull license model:
|
|
247
|
-
|
|
248
|
-
- **$0/month** for companies with revenue up to $3M
|
|
249
|
-
- **$75/month** for companies with revenue up to $7.5M
|
|
250
|
-
- **$200/month** for companies with revenue up to $20M
|
|
251
|
-
- **$500/month** for companies with revenue up to $100M
|
|
252
|
-
- **$1,000/month** for companies with revenue above $100M
|
|
253
|
-
|
|
254
|
-
Lock in your rate forever by signing up early. We can't change your fee or cancel your license.
|
|
@@ -192,7 +192,7 @@ npm i @statezero/core
|
|
|
192
192
|
### Generate TypeScript Models
|
|
193
193
|
|
|
194
194
|
```bash
|
|
195
|
-
npx statezero sync
|
|
195
|
+
npx statezero sync
|
|
196
196
|
```
|
|
197
197
|
|
|
198
198
|
## Why Choose StateZero Over...
|
|
@@ -209,16 +209,4 @@ npx statezero sync-models
|
|
|
209
209
|
|
|
210
210
|
Check out the docs at [Statezero Docs](https://statezero.dev)
|
|
211
211
|
|
|
212
|
-
Run `pip install statezero` and `npm i @statezero/core` to begin.
|
|
213
|
-
|
|
214
|
-
## Pricing
|
|
215
|
-
|
|
216
|
-
StateZero uses a no-rugpull license model:
|
|
217
|
-
|
|
218
|
-
- **$0/month** for companies with revenue up to $3M
|
|
219
|
-
- **$75/month** for companies with revenue up to $7.5M
|
|
220
|
-
- **$200/month** for companies with revenue up to $20M
|
|
221
|
-
- **$500/month** for companies with revenue up to $100M
|
|
222
|
-
- **$1,000/month** for companies with revenue above $100M
|
|
223
|
-
|
|
224
|
-
Lock in your rate forever by signing up early. We can't change your fee or cancel your license.
|
|
212
|
+
Run `pip install statezero` and `npm i @statezero/core` to begin.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "statezero"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.0b20"
|
|
8
8
|
description = "Connect your Python backend to a modern JavaScript SPA frontend with 90% less complexity."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -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)
|
|
@@ -62,7 +62,7 @@ def map_exception(exc):
|
|
|
62
62
|
def explicit_exception_handler(exc):
|
|
63
63
|
"""
|
|
64
64
|
Extended explicit exception handler that builds a structured JSON response.
|
|
65
|
-
It maps known Django/DRF exceptions to
|
|
65
|
+
It maps known Django/DRF exceptions to StateZero's standard errors and
|
|
66
66
|
uses jsonable_encoder to ensure the output is JSON serializable.
|
|
67
67
|
"""
|
|
68
68
|
traceback.print_exc()
|
|
@@ -15,6 +15,14 @@ class MoneyFieldSerializer(serializers.Field):
|
|
|
15
15
|
self.decimal_places = kwargs.pop("decimal_places", 2)
|
|
16
16
|
super().__init__(**kwargs)
|
|
17
17
|
|
|
18
|
+
@classmethod
|
|
19
|
+
def get_prefetch_db_fields(cls, field_name: str):
|
|
20
|
+
"""
|
|
21
|
+
Return all database fields required for this field to serialize.
|
|
22
|
+
MoneyField creates two database columns: field_name and field_name_currency.
|
|
23
|
+
"""
|
|
24
|
+
return [field_name, f"{field_name}_currency"]
|
|
25
|
+
|
|
18
26
|
def to_representation(self, value):
|
|
19
27
|
djmoney_field = MoneyField(
|
|
20
28
|
max_digits=self.max_digits, decimal_places=self.decimal_places
|
|
@@ -763,6 +763,7 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
763
763
|
def get_fields(self, model: models.Model) -> Set[str]:
|
|
764
764
|
"""
|
|
765
765
|
Return a set of the model fields.
|
|
766
|
+
Includes both database fields and additional_fields (computed fields).
|
|
766
767
|
"""
|
|
767
768
|
model_config = registry.get_config(model)
|
|
768
769
|
if model_config.fields and "__all__" != model_config.fields:
|
|
@@ -775,6 +776,14 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
775
776
|
resolved_fields = resolved_fields.union(additional_fields)
|
|
776
777
|
return resolved_fields
|
|
777
778
|
|
|
779
|
+
def get_db_fields(self, model: models.Model) -> Set[str]:
|
|
780
|
+
"""
|
|
781
|
+
Return only actual database fields for the model.
|
|
782
|
+
Excludes read-only additional_fields (computed fields).
|
|
783
|
+
Used for deserialization - hooks can write to any DB field.
|
|
784
|
+
"""
|
|
785
|
+
return set(field.name for field in model._meta.get_fields())
|
|
786
|
+
|
|
778
787
|
def build_model_graph(
|
|
779
788
|
self, model: Type[models.Model], model_graph: nx.DiGraph = None
|
|
780
789
|
) -> nx.DiGraph:
|
|
@@ -962,4 +971,103 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
962
971
|
|
|
963
972
|
def get_user(self, request):
|
|
964
973
|
"""Return the user from the request."""
|
|
965
|
-
return request.user
|
|
974
|
+
return request.user
|
|
975
|
+
|
|
976
|
+
def validate(
|
|
977
|
+
self,
|
|
978
|
+
model: Type[models.Model],
|
|
979
|
+
data: Dict[str, Any],
|
|
980
|
+
validate_type: str,
|
|
981
|
+
partial: bool,
|
|
982
|
+
request: RequestType,
|
|
983
|
+
permissions: List[Type[AbstractPermission]],
|
|
984
|
+
serializer,
|
|
985
|
+
) -> bool:
|
|
986
|
+
"""
|
|
987
|
+
Fast validation without database queries.
|
|
988
|
+
Only checks model-level permissions and serializer validation.
|
|
989
|
+
|
|
990
|
+
Args:
|
|
991
|
+
model: Django model class
|
|
992
|
+
data: Data to validate
|
|
993
|
+
validate_type: 'create' or 'update'
|
|
994
|
+
partial: Whether to allow partial validation (only validate provided fields)
|
|
995
|
+
request: Request object
|
|
996
|
+
permissions: Permission classes
|
|
997
|
+
serializer: Serializer instance
|
|
998
|
+
|
|
999
|
+
Returns:
|
|
1000
|
+
bool: True if validation passes
|
|
1001
|
+
|
|
1002
|
+
Raises:
|
|
1003
|
+
ValidationError: For serializer validation failures
|
|
1004
|
+
PermissionDenied: For permission failures
|
|
1005
|
+
"""
|
|
1006
|
+
# Basic model-level permission check (no DB query)
|
|
1007
|
+
required_action = (
|
|
1008
|
+
ActionType.CREATE if validate_type == "create" else ActionType.UPDATE
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
has_permission = False
|
|
1012
|
+
for permission_class in permissions:
|
|
1013
|
+
perm_instance = permission_class()
|
|
1014
|
+
allowed_actions = perm_instance.allowed_actions(request, model)
|
|
1015
|
+
if required_action in allowed_actions:
|
|
1016
|
+
has_permission = True
|
|
1017
|
+
break
|
|
1018
|
+
|
|
1019
|
+
if not has_permission:
|
|
1020
|
+
# Let StateZero exception handling deal with this
|
|
1021
|
+
raise PermissionDenied(f"{validate_type.title()} not allowed")
|
|
1022
|
+
|
|
1023
|
+
# Get field permissions
|
|
1024
|
+
allowed_fields = self._get_allowed_fields(
|
|
1025
|
+
model, permissions, request, validate_type
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
# Filter data to only allowed fields
|
|
1029
|
+
filtered_data = {k: v for k, v in data.items() if k in allowed_fields}
|
|
1030
|
+
|
|
1031
|
+
# Create minimal fields map for serializer
|
|
1032
|
+
model_name = config.orm_provider.get_model_name(model)
|
|
1033
|
+
fields_map = {model_name: allowed_fields}
|
|
1034
|
+
|
|
1035
|
+
# Validate using serializer with partial flag - let ValidationError bubble up naturally
|
|
1036
|
+
serializer.deserialize(
|
|
1037
|
+
model=model,
|
|
1038
|
+
data=filtered_data,
|
|
1039
|
+
partial=partial,
|
|
1040
|
+
request=request,
|
|
1041
|
+
fields_map=fields_map,
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
# Only return success case - exceptions handle failures
|
|
1045
|
+
return True
|
|
1046
|
+
|
|
1047
|
+
def _get_allowed_fields(
|
|
1048
|
+
self,
|
|
1049
|
+
model: Type[models.Model],
|
|
1050
|
+
permissions: List[Type[AbstractPermission]],
|
|
1051
|
+
request: RequestType,
|
|
1052
|
+
validate_type: str,
|
|
1053
|
+
) -> Set[str]:
|
|
1054
|
+
"""Helper to get allowed fields based on validate_type."""
|
|
1055
|
+
allowed_fields = set()
|
|
1056
|
+
|
|
1057
|
+
for permission_class in permissions:
|
|
1058
|
+
perm_instance = permission_class()
|
|
1059
|
+
|
|
1060
|
+
if validate_type == "create":
|
|
1061
|
+
create_fields = perm_instance.create_fields(request, model)
|
|
1062
|
+
if create_fields == "__all__":
|
|
1063
|
+
return config.orm_provider.get_fields(model)
|
|
1064
|
+
elif isinstance(create_fields, set):
|
|
1065
|
+
allowed_fields.update(create_fields)
|
|
1066
|
+
else: # update
|
|
1067
|
+
editable_fields = perm_instance.editable_fields(request, model)
|
|
1068
|
+
if editable_fields == "__all__":
|
|
1069
|
+
return config.orm_provider.get_fields(model)
|
|
1070
|
+
elif isinstance(editable_fields, set):
|
|
1071
|
+
allowed_fields.update(editable_fields)
|
|
1072
|
+
|
|
1073
|
+
return allowed_fields
|
|
@@ -477,7 +477,34 @@ def optimize_query(queryset, fields=None, fields_map=None, depth=0, use_only=Tru
|
|
|
477
477
|
related_fields_to_fetch = set()
|
|
478
478
|
|
|
479
479
|
if fields_map and related_model_name in fields_map:
|
|
480
|
-
|
|
480
|
+
# Process each field, checking for custom serializers
|
|
481
|
+
from statezero.adaptors.django.serializers import get_custom_serializer
|
|
482
|
+
related_meta = _get_model_meta(related_model)
|
|
483
|
+
for field_name in fields_map[related_model_name]:
|
|
484
|
+
try:
|
|
485
|
+
field_obj = related_meta.get_field(field_name)
|
|
486
|
+
if not field_obj.is_relation:
|
|
487
|
+
# Check if this field has a custom serializer with explicit DB field requirements
|
|
488
|
+
custom_serializer = get_custom_serializer(field_obj.__class__)
|
|
489
|
+
if custom_serializer and hasattr(custom_serializer, 'get_prefetch_db_fields'):
|
|
490
|
+
# Use the explicit list from the custom serializer
|
|
491
|
+
db_fields = custom_serializer.get_prefetch_db_fields(field_name)
|
|
492
|
+
for db_field in db_fields:
|
|
493
|
+
related_fields_to_fetch.add(db_field)
|
|
494
|
+
logger.debug(f"Using custom DB fields {db_fields} for field '{field_name}' in {related_model_name}")
|
|
495
|
+
else:
|
|
496
|
+
# No custom serializer, just add the field itself
|
|
497
|
+
related_fields_to_fetch.add(field_name)
|
|
498
|
+
else:
|
|
499
|
+
# Relation field, add as-is
|
|
500
|
+
related_fields_to_fetch.add(field_name)
|
|
501
|
+
except FieldDoesNotExist:
|
|
502
|
+
# Field doesn't exist, add it anyway (might be computed)
|
|
503
|
+
related_fields_to_fetch.add(field_name)
|
|
504
|
+
except Exception as e:
|
|
505
|
+
logger.error(f"Error checking custom serializer for field '{field_name}' in {related_model_name}: {e}")
|
|
506
|
+
# On error, add the field anyway to be safe
|
|
507
|
+
related_fields_to_fetch.add(field_name)
|
|
481
508
|
else:
|
|
482
509
|
# If no field restrictions are provided, get all fields
|
|
483
510
|
all_fields = [f.name for f in related_model._meta.get_fields() if f.concrete]
|
|
@@ -531,7 +558,18 @@ def optimize_query(queryset, fields=None, fields_map=None, depth=0, use_only=Tru
|
|
|
531
558
|
try:
|
|
532
559
|
field_obj = root_meta.get_field(field_name)
|
|
533
560
|
if not field_obj.is_relation:
|
|
534
|
-
|
|
561
|
+
# Check if this field has a custom serializer with explicit DB field requirements
|
|
562
|
+
from statezero.adaptors.django.serializers import get_custom_serializer
|
|
563
|
+
custom_serializer = get_custom_serializer(field_obj.__class__)
|
|
564
|
+
if custom_serializer and hasattr(custom_serializer, 'get_prefetch_db_fields'):
|
|
565
|
+
# Use the explicit list from the custom serializer
|
|
566
|
+
db_fields = custom_serializer.get_prefetch_db_fields(field_name)
|
|
567
|
+
for db_field in db_fields:
|
|
568
|
+
root_fields_to_fetch.add(db_field)
|
|
569
|
+
logger.debug(f"Using custom DB fields {db_fields} for field '{field_name}'")
|
|
570
|
+
else:
|
|
571
|
+
# No custom serializer, just add the field itself
|
|
572
|
+
root_fields_to_fetch.add(field_name)
|
|
535
573
|
elif isinstance(field_obj, (ForeignKey, OneToOneField)):
|
|
536
574
|
# If FK/O2O itself is requested directly, include its id field
|
|
537
575
|
root_fields_to_fetch.add(field_obj.attname)
|