statezero 0.1.0b11__tar.gz → 0.1.0b22__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.
Potentially problematic release.
This version of statezero might be problematic. Click here for more details.
- {statezero-0.1.0b11 → statezero-0.1.0b22}/PKG-INFO +1 -1
- {statezero-0.1.0b11 → statezero-0.1.0b22}/pyproject.toml +1 -1
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/actions.py +33 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/extensions/custom_field_serializers/money_field.py +8 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/orm.py +29 -5
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/permissions.py +6 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/query_optimizer.py +40 -2
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/schemas.py +38 -2
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/serializers.py +84 -36
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/urls.py +2 -1
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/views.py +101 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/actions.py +6 -2
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/ast_parser.py +43 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/classes.py +58 -1
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/config.py +4 -37
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/interfaces.py +29 -4
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/process_request.py +0 -1
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/types.py +1 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero.egg-info/PKG-INFO +1 -1
- {statezero-0.1.0b11 → statezero-0.1.0b22}/README.md +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/license.md +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/requirements.txt +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/setup.cfg +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/__init__.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/__init__.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/__init__.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/apps.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/config.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/context_manager.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/event_emitters.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/exception_handler.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/extensions/__init__.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/extensions/custom_field_serializers/__init__.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/extensions/custom_field_serializers/file_fields.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/f_handler.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/helpers.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/middleware.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/migrations/0001_initial.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/migrations/0002_delete_modelviewsubscription.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/migrations/__init__.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/search_providers/__init__.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/search_providers/basic_search.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/search_providers/postgres_search.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/__init__.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/ast_validator.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/context_storage.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/event_bus.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/event_emitters.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/exceptions.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/hook_checks.py +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero.egg-info/SOURCES.txt +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero.egg-info/dependency_links.txt +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero.egg-info/requires.txt +0 -0
- {statezero-0.1.0b11 → statezero-0.1.0b22}/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.0b22
|
|
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
|
|
@@ -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.0b22"
|
|
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" }
|
|
@@ -48,6 +48,13 @@ class DjangoActionSchemaGenerator:
|
|
|
48
48
|
action_config["response_serializer"]
|
|
49
49
|
)
|
|
50
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
|
+
|
|
51
58
|
schema_info = {
|
|
52
59
|
"action_name": action_name,
|
|
53
60
|
"app": app_name,
|
|
@@ -62,6 +69,7 @@ class DjangoActionSchemaGenerator:
|
|
|
62
69
|
"permissions": [
|
|
63
70
|
perm.__name__ for perm in action_config.get("permissions", [])
|
|
64
71
|
],
|
|
72
|
+
"display": display_data,
|
|
65
73
|
}
|
|
66
74
|
actions_schema[action_name] = schema_info
|
|
67
75
|
|
|
@@ -117,6 +125,14 @@ class DjangoActionSchemaGenerator:
|
|
|
117
125
|
return "string"
|
|
118
126
|
return "integer"
|
|
119
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
|
+
|
|
120
136
|
type_mapping = {
|
|
121
137
|
fields.BooleanField: "boolean",
|
|
122
138
|
fields.CharField: "string",
|
|
@@ -212,4 +228,21 @@ class DjangoActionSchemaGenerator:
|
|
|
212
228
|
"class_name": model.__name__,
|
|
213
229
|
"primary_key_field": model._meta.pk.name,
|
|
214
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
|
+
|
|
215
248
|
return None
|
|
@@ -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
|
|
@@ -258,6 +258,26 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
258
258
|
fields_map=fields_map,
|
|
259
259
|
)
|
|
260
260
|
|
|
261
|
+
def bulk_create(
|
|
262
|
+
self,
|
|
263
|
+
model: Type[models.Model],
|
|
264
|
+
data_list: List[Dict[str, Any]],
|
|
265
|
+
serializer,
|
|
266
|
+
req,
|
|
267
|
+
fields_map,
|
|
268
|
+
) -> List[models.Model]:
|
|
269
|
+
"""Create multiple model instances using Django's bulk_create."""
|
|
270
|
+
# Create instances without saving to DB yet
|
|
271
|
+
instances = [model(**data) for data in data_list]
|
|
272
|
+
|
|
273
|
+
# Use Django's bulk_create for efficiency
|
|
274
|
+
created_instances = model.objects.bulk_create(instances)
|
|
275
|
+
|
|
276
|
+
# Emit bulk create event for cache invalidation and frontend notification
|
|
277
|
+
config.event_bus.emit_bulk_event(ActionType.BULK_CREATE, created_instances)
|
|
278
|
+
|
|
279
|
+
return created_instances
|
|
280
|
+
|
|
261
281
|
def update_instance(
|
|
262
282
|
self,
|
|
263
283
|
model: Type[models.Model],
|
|
@@ -750,19 +770,15 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
750
770
|
req: RequestType,
|
|
751
771
|
model: Type,
|
|
752
772
|
initial_ast: Dict[str, Any],
|
|
753
|
-
custom_querysets: Dict[str, Type[AbstractCustomQueryset]],
|
|
754
773
|
registered_permissions: List[Type[AbstractPermission]],
|
|
755
774
|
) -> Any:
|
|
756
775
|
"""Assemble and return the base QuerySet for the given model."""
|
|
757
|
-
custom_name = initial_ast.get("custom_queryset")
|
|
758
|
-
if custom_name and custom_name in custom_querysets:
|
|
759
|
-
custom_queryset_class = custom_querysets[custom_name]
|
|
760
|
-
return custom_queryset_class().get_queryset(req)
|
|
761
776
|
return model.objects.all()
|
|
762
777
|
|
|
763
778
|
def get_fields(self, model: models.Model) -> Set[str]:
|
|
764
779
|
"""
|
|
765
780
|
Return a set of the model fields.
|
|
781
|
+
Includes both database fields and additional_fields (computed fields).
|
|
766
782
|
"""
|
|
767
783
|
model_config = registry.get_config(model)
|
|
768
784
|
if model_config.fields and "__all__" != model_config.fields:
|
|
@@ -775,6 +791,14 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
775
791
|
resolved_fields = resolved_fields.union(additional_fields)
|
|
776
792
|
return resolved_fields
|
|
777
793
|
|
|
794
|
+
def get_db_fields(self, model: models.Model) -> Set[str]:
|
|
795
|
+
"""
|
|
796
|
+
Return only actual database fields for the model.
|
|
797
|
+
Excludes read-only additional_fields (computed fields).
|
|
798
|
+
Used for deserialization - hooks can write to any DB field.
|
|
799
|
+
"""
|
|
800
|
+
return set(field.name for field in model._meta.get_fields())
|
|
801
|
+
|
|
778
802
|
def build_model_graph(
|
|
779
803
|
self, model: Type[models.Model], model_graph: nx.DiGraph = None
|
|
780
804
|
) -> nx.DiGraph:
|
|
@@ -25,6 +25,7 @@ class AllowAllPermission(AbstractPermission):
|
|
|
25
25
|
ActionType.DELETE,
|
|
26
26
|
ActionType.READ,
|
|
27
27
|
ActionType.UPDATE,
|
|
28
|
+
ActionType.BULK_CREATE,
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
def allowed_object_actions(self, request, obj, model: Type[ORMModel]) -> Set[ActionType]: # type: ignore
|
|
@@ -33,6 +34,7 @@ class AllowAllPermission(AbstractPermission):
|
|
|
33
34
|
ActionType.DELETE,
|
|
34
35
|
ActionType.READ,
|
|
35
36
|
ActionType.UPDATE,
|
|
37
|
+
ActionType.BULK_CREATE,
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
def _get_user_fields(self) -> Set[str]:
|
|
@@ -74,6 +76,7 @@ class IsAuthenticatedPermission(AbstractPermission):
|
|
|
74
76
|
ActionType.DELETE,
|
|
75
77
|
ActionType.READ,
|
|
76
78
|
ActionType.UPDATE,
|
|
79
|
+
ActionType.BULK_CREATE,
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
def allowed_object_actions(
|
|
@@ -86,6 +89,7 @@ class IsAuthenticatedPermission(AbstractPermission):
|
|
|
86
89
|
ActionType.DELETE,
|
|
87
90
|
ActionType.READ,
|
|
88
91
|
ActionType.UPDATE,
|
|
92
|
+
ActionType.BULK_CREATE,
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
def _get_user_fields(self) -> Set[str]:
|
|
@@ -134,6 +138,7 @@ class IsStaffPermission(AbstractPermission):
|
|
|
134
138
|
ActionType.DELETE,
|
|
135
139
|
ActionType.READ,
|
|
136
140
|
ActionType.UPDATE,
|
|
141
|
+
ActionType.BULK_CREATE,
|
|
137
142
|
}
|
|
138
143
|
|
|
139
144
|
def allowed_object_actions(
|
|
@@ -146,6 +151,7 @@ class IsStaffPermission(AbstractPermission):
|
|
|
146
151
|
ActionType.DELETE,
|
|
147
152
|
ActionType.READ,
|
|
148
153
|
ActionType.UPDATE,
|
|
154
|
+
ActionType.BULK_CREATE,
|
|
149
155
|
}
|
|
150
156
|
|
|
151
157
|
def _get_user_fields(self) -> Set[str]:
|
|
@@ -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)
|
|
@@ -127,6 +127,11 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
|
|
|
127
127
|
if hasattr(model._meta, "ordering") and model._meta.ordering:
|
|
128
128
|
default_ordering = list(model._meta.ordering)
|
|
129
129
|
|
|
130
|
+
# Serialize display metadata if present
|
|
131
|
+
display_data = None
|
|
132
|
+
if model_config.display:
|
|
133
|
+
display_data = self._serialize_display_metadata(model_config.display)
|
|
134
|
+
|
|
130
135
|
schema_meta = ModelSchemaMetadata(
|
|
131
136
|
model_name=config.orm_provider.get_model_name(model),
|
|
132
137
|
title=model._meta.verbose_name.title(),
|
|
@@ -153,6 +158,7 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
|
|
|
153
158
|
time_format=getattr(settings, "REST_FRAMEWORK", {}).get(
|
|
154
159
|
"TIME_FORMAT", "iso-8601"
|
|
155
160
|
),
|
|
161
|
+
display=display_data,
|
|
156
162
|
)
|
|
157
163
|
return schema_meta
|
|
158
164
|
|
|
@@ -172,6 +178,7 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
|
|
|
172
178
|
nullable=False,
|
|
173
179
|
format=FieldFormat.ID,
|
|
174
180
|
description=description,
|
|
181
|
+
read_only=True,
|
|
175
182
|
)
|
|
176
183
|
elif isinstance(field, models.UUIDField):
|
|
177
184
|
return SchemaFieldMetadata(
|
|
@@ -181,6 +188,7 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
|
|
|
181
188
|
nullable=False,
|
|
182
189
|
format=FieldFormat.UUID,
|
|
183
190
|
description=description,
|
|
191
|
+
read_only=True,
|
|
184
192
|
)
|
|
185
193
|
elif isinstance(field, models.CharField):
|
|
186
194
|
return SchemaFieldMetadata(
|
|
@@ -191,6 +199,7 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
|
|
|
191
199
|
format=FieldFormat.ID,
|
|
192
200
|
max_length=field.max_length,
|
|
193
201
|
description=description,
|
|
202
|
+
read_only=True,
|
|
194
203
|
)
|
|
195
204
|
else:
|
|
196
205
|
return SchemaFieldMetadata(
|
|
@@ -200,6 +209,7 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
|
|
|
200
209
|
nullable=False,
|
|
201
210
|
format=FieldFormat.ID,
|
|
202
211
|
description=description,
|
|
212
|
+
read_only=True,
|
|
203
213
|
)
|
|
204
214
|
|
|
205
215
|
def get_field_metadata(
|
|
@@ -250,6 +260,9 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
|
|
|
250
260
|
elif isinstance(field, models.DateField):
|
|
251
261
|
field_type = FieldType.STRING
|
|
252
262
|
field_format = FieldFormat.DATE
|
|
263
|
+
elif isinstance(field, models.TimeField):
|
|
264
|
+
field_type = FieldType.STRING
|
|
265
|
+
field_format = FieldFormat.TIME
|
|
253
266
|
elif isinstance(field, (models.ForeignKey, models.OneToOneField)):
|
|
254
267
|
field_type = self.get_pk_type(field)
|
|
255
268
|
field_format = self.get_relation_type(field)
|
|
@@ -280,6 +293,12 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
|
|
|
280
293
|
elif callable(default):
|
|
281
294
|
default = default()
|
|
282
295
|
|
|
296
|
+
# Check if field should be read-only (auto_now or auto_now_add)
|
|
297
|
+
read_only = False
|
|
298
|
+
if isinstance(field, (models.DateTimeField, models.DateField, models.TimeField)):
|
|
299
|
+
if getattr(field, "auto_now", False) or getattr(field, "auto_now_add", False):
|
|
300
|
+
read_only = True
|
|
301
|
+
|
|
283
302
|
return SchemaFieldMetadata(
|
|
284
303
|
type=field_type,
|
|
285
304
|
title=title,
|
|
@@ -293,6 +312,7 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
|
|
|
293
312
|
max_digits=max_digits,
|
|
294
313
|
decimal_places=decimal_places,
|
|
295
314
|
description=description,
|
|
315
|
+
read_only=read_only,
|
|
296
316
|
)
|
|
297
317
|
|
|
298
318
|
def get_field_title(self, field: models.Field) -> str:
|
|
@@ -303,8 +323,7 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
|
|
|
303
323
|
@staticmethod
|
|
304
324
|
def is_field_required(field: models.Field) -> bool:
|
|
305
325
|
return (
|
|
306
|
-
not field.
|
|
307
|
-
and not field.null
|
|
326
|
+
not field.null
|
|
308
327
|
and field.default == models.fields.NOT_PROVIDED
|
|
309
328
|
)
|
|
310
329
|
|
|
@@ -322,3 +341,20 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
|
|
|
322
341
|
if isinstance(target_field, (models.UUIDField, models.CharField)):
|
|
323
342
|
return FieldType.STRING
|
|
324
343
|
return FieldType.INTEGER
|
|
344
|
+
|
|
345
|
+
@staticmethod
|
|
346
|
+
def _serialize_display_metadata(display) -> Dict[str, Any]:
|
|
347
|
+
"""Convert DisplayMetadata dataclass to dict for JSON serialization"""
|
|
348
|
+
from dataclasses import asdict, is_dataclass
|
|
349
|
+
|
|
350
|
+
if display is None:
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
if is_dataclass(display):
|
|
354
|
+
return asdict(display)
|
|
355
|
+
|
|
356
|
+
# If it's already a dict, return as-is
|
|
357
|
+
if isinstance(display, dict):
|
|
358
|
+
return display
|
|
359
|
+
|
|
360
|
+
return None
|
|
@@ -6,7 +6,8 @@ from rest_framework import serializers
|
|
|
6
6
|
import contextvars
|
|
7
7
|
from contextlib import contextmanager
|
|
8
8
|
import logging
|
|
9
|
-
from cytoolz import pluck
|
|
9
|
+
from cytoolz import pluck, keyfilter
|
|
10
|
+
from cytoolz.functoolz import thread_first
|
|
10
11
|
from zen_queries import queries_disabled
|
|
11
12
|
|
|
12
13
|
from statezero.adaptors.django.config import config, registry
|
|
@@ -476,41 +477,78 @@ class DRFDynamicSerializer(AbstractDataSerializer):
|
|
|
476
477
|
def deserialize(
|
|
477
478
|
self,
|
|
478
479
|
model: Type[models.Model],
|
|
479
|
-
data: Dict[str, Any],
|
|
480
|
+
data: Union[Dict[str, Any], List[Dict[str, Any]]],
|
|
480
481
|
fields_map: Optional[Dict[str, Set[str]]],
|
|
481
482
|
partial: bool = False,
|
|
482
483
|
request: Optional[RequestType] = None,
|
|
483
|
-
|
|
484
|
+
many: bool = False,
|
|
485
|
+
) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
|
|
484
486
|
# Serious security issue if fields_map is None
|
|
485
487
|
assert fields_map is not None, "fields_map is required and cannot be None"
|
|
486
488
|
|
|
487
|
-
#
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
serializer_class = DynamicModelSerializer.for_model(model)
|
|
491
|
-
available_fields = set(serializer_class().fields.keys())
|
|
489
|
+
# Get model name and allowed fields from fields_map
|
|
490
|
+
model_name = config.orm_provider.get_model_name(model)
|
|
491
|
+
allowed_fields = fields_map.get(model_name, set())
|
|
492
492
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
493
|
+
# Filter user input to only allowed fields (security boundary)
|
|
494
|
+
if many:
|
|
495
|
+
data = [dict(keyfilter(lambda k: k in allowed_fields, item)) for item in data]
|
|
496
|
+
else:
|
|
497
|
+
data = dict(keyfilter(lambda k: k in allowed_fields, data))
|
|
498
|
+
|
|
499
|
+
try:
|
|
500
|
+
model_config = registry.get_config(model)
|
|
501
|
+
except ValueError:
|
|
502
|
+
# No model config available
|
|
503
|
+
model_config = None
|
|
504
|
+
|
|
505
|
+
# Run pre-hooks on filtered data (hooks can add any DB fields)
|
|
506
|
+
if model_config and model_config.pre_hooks:
|
|
507
|
+
# Wrap hooks with validation in DEBUG mode
|
|
508
|
+
if settings.DEBUG:
|
|
509
|
+
# Get all DB fields for validation
|
|
510
|
+
all_db_fields = config.orm_provider.get_db_fields(model)
|
|
511
|
+
|
|
512
|
+
def make_validated_pre_hook(hook):
|
|
513
|
+
def validated_hook(data, request=None):
|
|
514
|
+
original_data = data
|
|
515
|
+
result = hook(data, request=request)
|
|
516
|
+
return _check_pre_hook_result(
|
|
517
|
+
original_data=original_data,
|
|
518
|
+
result_data=result,
|
|
519
|
+
model=model,
|
|
520
|
+
serializer_fields=all_db_fields
|
|
521
|
+
)
|
|
522
|
+
return validated_hook
|
|
523
|
+
hook_funcs = [(make_validated_pre_hook(hook), request) for hook in model_config.pre_hooks]
|
|
524
|
+
else:
|
|
525
|
+
hook_funcs = [(hook, request) for hook in model_config.pre_hooks]
|
|
526
|
+
|
|
527
|
+
if many:
|
|
528
|
+
data = [thread_first(item, *hook_funcs) for item in data]
|
|
529
|
+
else:
|
|
530
|
+
data = thread_first(data, *hook_funcs)
|
|
531
|
+
|
|
532
|
+
# Expand fields_map to include fields that hooks may have added
|
|
533
|
+
# For partial updates, only include allowed_fields + any fields in the data
|
|
534
|
+
# This prevents validation errors on required fields that were filtered out
|
|
535
|
+
if partial:
|
|
536
|
+
# For partial updates: only include fields that are either allowed or in the data
|
|
537
|
+
expanded_fields = allowed_fields | set(data.keys())
|
|
538
|
+
else:
|
|
539
|
+
# For creates: include all DB fields to allow hooks to add any field
|
|
540
|
+
expanded_fields = config.orm_provider.get_db_fields(model)
|
|
541
|
+
expanded_fields_map = {model_name: expanded_fields}
|
|
542
|
+
|
|
543
|
+
# Use the context manager with expanded fields map
|
|
544
|
+
with fields_map_context(expanded_fields_map):
|
|
545
|
+
# Create serializer class with all DB fields available
|
|
546
|
+
serializer_class = DynamicModelSerializer.for_model(model)
|
|
510
547
|
|
|
511
548
|
# Create serializer
|
|
512
549
|
serializer = serializer_class(
|
|
513
|
-
data=data,
|
|
550
|
+
data=data,
|
|
551
|
+
many=many,
|
|
514
552
|
partial=partial,
|
|
515
553
|
request=request
|
|
516
554
|
)
|
|
@@ -518,16 +556,26 @@ class DRFDynamicSerializer(AbstractDataSerializer):
|
|
|
518
556
|
validated_data = serializer.validated_data
|
|
519
557
|
|
|
520
558
|
if model_config and model_config.post_hooks:
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
original_data=
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
559
|
+
# Wrap hooks with validation in DEBUG mode
|
|
560
|
+
if settings.DEBUG:
|
|
561
|
+
def make_validated_hook(hook):
|
|
562
|
+
def validated_hook(data, request=None):
|
|
563
|
+
original_data = data
|
|
564
|
+
result = hook(data, request=request)
|
|
565
|
+
return _check_post_hook_result(
|
|
566
|
+
original_data=original_data,
|
|
567
|
+
result_data=result,
|
|
568
|
+
model=model
|
|
569
|
+
)
|
|
570
|
+
return validated_hook
|
|
571
|
+
hook_funcs = [(make_validated_hook(hook), request) for hook in model_config.post_hooks]
|
|
572
|
+
else:
|
|
573
|
+
hook_funcs = [(hook, request) for hook in model_config.post_hooks]
|
|
574
|
+
|
|
575
|
+
if many:
|
|
576
|
+
validated_data = [thread_first(item, *hook_funcs) for item in validated_data]
|
|
577
|
+
else:
|
|
578
|
+
validated_data = thread_first(validated_data, *hook_funcs)
|
|
531
579
|
|
|
532
580
|
return validated_data
|
|
533
581
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from django.urls import path
|
|
2
2
|
|
|
3
|
-
from .views import EventsAuthView, ModelListView, ModelView, SchemaView, FileUploadView, FastUploadView, ActionSchemaView, ActionView, ValidateView
|
|
3
|
+
from .views import EventsAuthView, ModelListView, ModelView, SchemaView, FileUploadView, FastUploadView, ActionSchemaView, ActionView, ValidateView, FieldPermissionsView
|
|
4
4
|
|
|
5
5
|
app_name = "statezero"
|
|
6
6
|
|
|
@@ -12,6 +12,7 @@ urlpatterns = [
|
|
|
12
12
|
path("actions/<str:action_name>/", ActionView.as_view(), name="action"),
|
|
13
13
|
path("actions-schema/", ActionSchemaView.as_view(), name="actions_schema"),
|
|
14
14
|
path("<str:model_name>/validate/", ValidateView.as_view(), name="validate"),
|
|
15
|
+
path("<str:model_name>/field-permissions/", FieldPermissionsView.as_view(), name="field_permissions"),
|
|
15
16
|
path("<str:model_name>/get-schema/", SchemaView.as_view(), name="schema_view"),
|
|
16
17
|
path("<str:model_name>/", ModelView.as_view(), name="model_view"),
|
|
17
18
|
]
|
|
@@ -485,3 +485,104 @@ class ValidateView(APIView):
|
|
|
485
485
|
except Exception as original_exception:
|
|
486
486
|
# Let StateZero's exception handler deal with ValidationError, PermissionDenied, etc.
|
|
487
487
|
return explicit_exception_handler(original_exception)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class FieldPermissionsView(APIView):
|
|
491
|
+
"""
|
|
492
|
+
Returns user-specific field permissions for a given model.
|
|
493
|
+
Used by frontend forms to determine which fields to show/enable at runtime.
|
|
494
|
+
"""
|
|
495
|
+
|
|
496
|
+
permission_classes = [permission_class]
|
|
497
|
+
|
|
498
|
+
def get(self, request, model_name):
|
|
499
|
+
"""Get field permissions for the current user."""
|
|
500
|
+
try:
|
|
501
|
+
# Create processor following the same pattern as other views
|
|
502
|
+
processor = RequestProcessor(config=config, registry=registry)
|
|
503
|
+
|
|
504
|
+
# Get model using the processor's ORM provider
|
|
505
|
+
try:
|
|
506
|
+
model = processor.orm_provider.get_model_by_name(model_name)
|
|
507
|
+
except (LookupError, ValueError):
|
|
508
|
+
return Response({"error": f"Model {model_name} not found"}, status=404)
|
|
509
|
+
|
|
510
|
+
if not model:
|
|
511
|
+
return Response({"error": f"Model {model_name} not found"}, status=404)
|
|
512
|
+
|
|
513
|
+
try:
|
|
514
|
+
model_config = processor.registry.get_config(model)
|
|
515
|
+
except ValueError:
|
|
516
|
+
return Response(
|
|
517
|
+
{"error": f"Model {model_name} not registered"}, status=404
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
# Compute field permissions using the same logic as ASTParser._get_operation_fields
|
|
521
|
+
all_fields = processor.orm_provider.get_fields(model)
|
|
522
|
+
|
|
523
|
+
visible_fields = self._compute_operation_fields(
|
|
524
|
+
model, model_config, all_fields, request, "read"
|
|
525
|
+
)
|
|
526
|
+
creatable_fields = self._compute_operation_fields(
|
|
527
|
+
model, model_config, all_fields, request, "create"
|
|
528
|
+
)
|
|
529
|
+
editable_fields = self._compute_operation_fields(
|
|
530
|
+
model, model_config, all_fields, request, "update"
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
return Response(
|
|
534
|
+
{
|
|
535
|
+
"visible_fields": list(visible_fields),
|
|
536
|
+
"creatable_fields": list(creatable_fields),
|
|
537
|
+
"editable_fields": list(editable_fields),
|
|
538
|
+
},
|
|
539
|
+
status=200,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
except Exception as original_exception:
|
|
543
|
+
# Let StateZero's exception handler deal with errors
|
|
544
|
+
return explicit_exception_handler(original_exception)
|
|
545
|
+
|
|
546
|
+
def _compute_operation_fields(self, model, model_config, all_fields, request, operation_type):
|
|
547
|
+
"""
|
|
548
|
+
Compute allowed fields for a specific operation type.
|
|
549
|
+
Replicates the logic from ASTParser._get_operation_fields.
|
|
550
|
+
"""
|
|
551
|
+
from typing import Union, Set, Literal
|
|
552
|
+
|
|
553
|
+
allowed_fields = set()
|
|
554
|
+
|
|
555
|
+
for permission_cls in model_config.permissions:
|
|
556
|
+
permission = permission_cls()
|
|
557
|
+
|
|
558
|
+
# Get the appropriate field set based on operation
|
|
559
|
+
if operation_type == "read":
|
|
560
|
+
fields = permission.visible_fields(request, model)
|
|
561
|
+
elif operation_type == "create":
|
|
562
|
+
fields = permission.create_fields(request, model)
|
|
563
|
+
elif operation_type == "update":
|
|
564
|
+
fields = permission.editable_fields(request, model)
|
|
565
|
+
else:
|
|
566
|
+
fields = set()
|
|
567
|
+
|
|
568
|
+
# If any permission allows all fields
|
|
569
|
+
if fields == "__all__":
|
|
570
|
+
# For read operations, default "__all__" to frontend_fields
|
|
571
|
+
if operation_type == "read":
|
|
572
|
+
# If frontend_fields is also "__all__", then return all fields
|
|
573
|
+
if model_config.fields == "__all__":
|
|
574
|
+
return all_fields
|
|
575
|
+
# Otherwise, use frontend_fields as the default for "__all__"
|
|
576
|
+
else:
|
|
577
|
+
fields = model_config.fields
|
|
578
|
+
fields &= all_fields # Ensure fields actually exist
|
|
579
|
+
allowed_fields |= fields
|
|
580
|
+
else:
|
|
581
|
+
# For create/update operations, "__all__" means truly all fields
|
|
582
|
+
return all_fields
|
|
583
|
+
else:
|
|
584
|
+
# Add allowed fields from this permission
|
|
585
|
+
fields &= all_fields # Ensure fields actually exist
|
|
586
|
+
allowed_fields |= fields
|
|
587
|
+
|
|
588
|
+
return allowed_fields
|
|
@@ -20,8 +20,9 @@ class ActionRegistry:
|
|
|
20
20
|
List[AbstractActionPermission], AbstractActionPermission, None
|
|
21
21
|
] = None,
|
|
22
22
|
name: Optional[str] = None,
|
|
23
|
+
display: Optional[Any] = None,
|
|
23
24
|
):
|
|
24
|
-
"""Register an action function with an optional, explicit docstring."""
|
|
25
|
+
"""Register an action function with an optional, explicit docstring and display metadata."""
|
|
25
26
|
|
|
26
27
|
def decorator(func: Callable):
|
|
27
28
|
action_name = name or func.__name__
|
|
@@ -47,6 +48,7 @@ class ActionRegistry:
|
|
|
47
48
|
"name": action_name,
|
|
48
49
|
"module": func.__module__,
|
|
49
50
|
"docstring": final_docstring, # Store the determined docstring
|
|
51
|
+
"display": display, # Store display metadata
|
|
50
52
|
}
|
|
51
53
|
return func
|
|
52
54
|
|
|
@@ -76,8 +78,9 @@ def action(
|
|
|
76
78
|
List[AbstractActionPermission], AbstractActionPermission, None
|
|
77
79
|
] = None,
|
|
78
80
|
name: Optional[str] = None,
|
|
81
|
+
display: Optional[Any] = None,
|
|
79
82
|
):
|
|
80
|
-
"""Framework-agnostic decorator to register an action."""
|
|
83
|
+
"""Framework-agnostic decorator to register an action with optional display metadata."""
|
|
81
84
|
return action_registry.register(
|
|
82
85
|
func,
|
|
83
86
|
docstring=docstring,
|
|
@@ -85,4 +88,5 @@ def action(
|
|
|
85
88
|
response_serializer=response_serializer,
|
|
86
89
|
permissions=permissions,
|
|
87
90
|
name=name,
|
|
91
|
+
display=display,
|
|
88
92
|
)
|
|
@@ -5,6 +5,7 @@ import networkx as nx
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
from statezero.core.config import AppConfig, Registry
|
|
8
|
+
from statezero.core.exceptions import PermissionDenied
|
|
8
9
|
from statezero.core.interfaces import (
|
|
9
10
|
AbstractDataSerializer,
|
|
10
11
|
AbstractPermission,
|
|
@@ -86,6 +87,7 @@ class ASTParser:
|
|
|
86
87
|
# Lookup table mapping AST op types to handler methods.
|
|
87
88
|
self.handlers: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
|
|
88
89
|
"create": self._handle_create,
|
|
90
|
+
"bulk_create": self._handle_bulk_create,
|
|
89
91
|
"update": self._handle_update,
|
|
90
92
|
"delete": self._handle_delete,
|
|
91
93
|
"get": self._handle_get,
|
|
@@ -525,6 +527,46 @@ class ASTParser:
|
|
|
525
527
|
"metadata": {"created": True, "response_type": ResponseType.INSTANCE.value},
|
|
526
528
|
}
|
|
527
529
|
|
|
530
|
+
def _handle_bulk_create(self, ast: Dict[str, Any]) -> Dict[str, Any]:
|
|
531
|
+
""" Handle bulk create operation."""
|
|
532
|
+
data_list = ast.get("data", [])
|
|
533
|
+
|
|
534
|
+
# Check model-level CREATE permission
|
|
535
|
+
if not self._has_operation_permission(self.model, operation_type="create"):
|
|
536
|
+
raise PermissionDenied("Create not allowed")
|
|
537
|
+
|
|
538
|
+
# Validate all data items using many=True
|
|
539
|
+
validated_data_list = self.serializer.deserialize(
|
|
540
|
+
model=self.model,
|
|
541
|
+
data=data_list,
|
|
542
|
+
partial=False,
|
|
543
|
+
request=self.request,
|
|
544
|
+
fields_map=self.create_fields_map,
|
|
545
|
+
many=True,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Bulk create all records
|
|
549
|
+
records = self.engine.bulk_create(
|
|
550
|
+
self.model,
|
|
551
|
+
validated_data_list,
|
|
552
|
+
self.serializer,
|
|
553
|
+
self.request,
|
|
554
|
+
self.create_fields_map,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# Serialize the created records
|
|
558
|
+
serialized = self.serializer.serialize(
|
|
559
|
+
records,
|
|
560
|
+
self.model,
|
|
561
|
+
many=True,
|
|
562
|
+
depth=self.depth,
|
|
563
|
+
fields_map=self.read_fields_map,
|
|
564
|
+
)
|
|
565
|
+
return {
|
|
566
|
+
"data": serialized,
|
|
567
|
+
"metadata": {"created": True, "response_type": ResponseType.QUERYSET.value},
|
|
568
|
+
}
|
|
569
|
+
|
|
528
570
|
def _handle_update(self, ast: Dict[str, Any]) -> Dict[str, Any]:
|
|
529
571
|
""" Pass current queryset to update method."""
|
|
530
572
|
data = ast.get("data", {})
|
|
@@ -942,6 +984,7 @@ class ASTParser:
|
|
|
942
984
|
all_ops = ASTParser._extract_all_operations(ast)
|
|
943
985
|
OPERATION_MAPPING = {
|
|
944
986
|
"create": ActionType.CREATE,
|
|
987
|
+
"bulk_create": ActionType.BULK_CREATE,
|
|
945
988
|
"update": ActionType.UPDATE,
|
|
946
989
|
"update_or_create": ActionType.UPDATE,
|
|
947
990
|
"delete": ActionType.DELETE,
|
|
@@ -71,6 +71,7 @@ class FieldFormat(str, Enum):
|
|
|
71
71
|
TEXT = "text"
|
|
72
72
|
DATE = "date"
|
|
73
73
|
DATETIME = "date-time"
|
|
74
|
+
TIME = "time"
|
|
74
75
|
FOREIGN_KEY = "foreign-key"
|
|
75
76
|
ONE_TO_ONE = "one-to-one"
|
|
76
77
|
MANY_TO_MANY = "many-to-many"
|
|
@@ -131,12 +132,15 @@ class ModelSchemaMetadata(BaseModel):
|
|
|
131
132
|
default_ordering: Optional[List[str]] = None
|
|
132
133
|
# Extra definitions (for schemas referenced via $ref) are merged in if provided.
|
|
133
134
|
definitions: Dict[str, Any] = field(default_factory=dict)
|
|
134
|
-
|
|
135
|
+
|
|
135
136
|
# Date / time formatting templates
|
|
136
137
|
datetime_format: Optional[str] = None
|
|
137
138
|
date_format: Optional[str] = None
|
|
138
139
|
time_format: Optional[str] = None
|
|
139
140
|
|
|
141
|
+
# Display customization
|
|
142
|
+
display: Optional[Dict[str, Any]] = None
|
|
143
|
+
|
|
140
144
|
@dataclass
|
|
141
145
|
class ModelSummaryRepresentation:
|
|
142
146
|
pk: Any
|
|
@@ -165,3 +169,56 @@ class FieldNode:
|
|
|
165
169
|
is_relation: bool
|
|
166
170
|
related_model: Optional[str] = None # The object name of the related model, if any
|
|
167
171
|
type: str = "field"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class FieldDisplayConfig:
|
|
176
|
+
"""
|
|
177
|
+
Configuration for customizing how a field is displayed in the frontend.
|
|
178
|
+
|
|
179
|
+
Attributes:
|
|
180
|
+
field_name: The name of the field this config applies to
|
|
181
|
+
display_component: Custom UI component name (e.g., "AddressAutocomplete", "DatePicker")
|
|
182
|
+
filter_queryset: Filter options for select/multi-select fields (dict passed to backend)
|
|
183
|
+
display_help_text: Additional help text for the field
|
|
184
|
+
extra: Additional custom metadata for framework-specific or UI-specific extensions
|
|
185
|
+
"""
|
|
186
|
+
field_name: str
|
|
187
|
+
display_component: Optional[str] = None
|
|
188
|
+
filter_queryset: Optional[Dict[str, Any]] = None
|
|
189
|
+
display_help_text: Optional[str] = None
|
|
190
|
+
extra: Optional[Dict[str, Any]] = None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass
|
|
194
|
+
class FieldGroup:
|
|
195
|
+
"""
|
|
196
|
+
Group related fields together for better UX.
|
|
197
|
+
|
|
198
|
+
Attributes:
|
|
199
|
+
display_title: Group heading
|
|
200
|
+
display_description: Group description
|
|
201
|
+
field_names: List of field names in this group
|
|
202
|
+
"""
|
|
203
|
+
display_title: str
|
|
204
|
+
display_description: Optional[str] = None
|
|
205
|
+
field_names: Optional[List[str]] = None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass
|
|
209
|
+
class DisplayMetadata:
|
|
210
|
+
"""
|
|
211
|
+
Rich display information for models and actions to customize frontend rendering.
|
|
212
|
+
|
|
213
|
+
Attributes:
|
|
214
|
+
display_title: Main heading/title override
|
|
215
|
+
display_description: Explanatory text about the model/action
|
|
216
|
+
field_groups: Logical grouping of fields (e.g., "Contact Info", "Address Details")
|
|
217
|
+
field_display_configs: Per-field customization (custom components, filters, help text)
|
|
218
|
+
extra: Additional custom metadata for framework-specific or UI-specific extensions
|
|
219
|
+
"""
|
|
220
|
+
display_title: Optional[str] = None
|
|
221
|
+
display_description: Optional[str] = None
|
|
222
|
+
field_groups: Optional[List[FieldGroup]] = None
|
|
223
|
+
field_display_configs: Optional[List[FieldDisplayConfig]] = None
|
|
224
|
+
extra: Optional[Dict[str, Any]] = None
|
|
@@ -144,8 +144,6 @@ class ModelConfig:
|
|
|
144
144
|
-----------
|
|
145
145
|
model: Type
|
|
146
146
|
The model class to register
|
|
147
|
-
custom_querysets: Dict[str, Type[AbstractCustomQueryset]], optional
|
|
148
|
-
Custom queryset methods for this model
|
|
149
147
|
permissions: List[Type[AbstractPermission]], optional
|
|
150
148
|
Permission classes that control access to this model
|
|
151
149
|
pre_hooks: List[Callable], optional
|
|
@@ -162,6 +160,8 @@ class ModelConfig:
|
|
|
162
160
|
Fields that can be used for ordering
|
|
163
161
|
fields: Optional[Optional[Union[Set[str], Literal["__all__"]]]]
|
|
164
162
|
Expose just a subset of the model fields
|
|
163
|
+
display: Optional[Any], optional
|
|
164
|
+
Display metadata for frontend customization (DisplayMetadata instance)
|
|
165
165
|
DEBUG: bool, default=False
|
|
166
166
|
Enable debug mode for this model
|
|
167
167
|
"""
|
|
@@ -169,8 +169,6 @@ class ModelConfig:
|
|
|
169
169
|
def __init__(
|
|
170
170
|
self,
|
|
171
171
|
model: Type,
|
|
172
|
-
custom_querysets: Optional[Dict[str, Type[AbstractCustomQueryset]]] = None,
|
|
173
|
-
custom_querysets_user_scoped: Optional[Dict[str, bool]] = None,
|
|
174
172
|
permissions: Optional[List[Type[AbstractPermission]]] = None,
|
|
175
173
|
pre_hooks: Optional[List] = None,
|
|
176
174
|
post_hooks: Optional[List] = None,
|
|
@@ -179,11 +177,10 @@ class ModelConfig:
|
|
|
179
177
|
searchable_fields: Optional[Union[Set[str], Literal["__all__"]]] = None,
|
|
180
178
|
ordering_fields: Optional[Union[Set[str], Literal["__all__"]]] = None,
|
|
181
179
|
fields: Optional[Union[Set[str], Literal["__all__"]]] = None,
|
|
180
|
+
display: Optional[Any] = None,
|
|
182
181
|
DEBUG: bool = False,
|
|
183
182
|
):
|
|
184
183
|
self.model = model
|
|
185
|
-
self._custom_querysets = custom_querysets or {}
|
|
186
|
-
self._custom_querysets_user_scoped = custom_querysets_user_scoped or {}
|
|
187
184
|
self._permissions = permissions or []
|
|
188
185
|
self.pre_hooks = pre_hooks or []
|
|
189
186
|
self.post_hooks = post_hooks or []
|
|
@@ -192,6 +189,7 @@ class ModelConfig:
|
|
|
192
189
|
self.searchable_fields = searchable_fields or set()
|
|
193
190
|
self.ordering_fields = ordering_fields or set()
|
|
194
191
|
self.fields = fields or "__all__"
|
|
192
|
+
self.display = display
|
|
195
193
|
self.DEBUG = DEBUG or False
|
|
196
194
|
|
|
197
195
|
@property
|
|
@@ -210,37 +208,6 @@ class ModelConfig:
|
|
|
210
208
|
resolved.append(perm)
|
|
211
209
|
return resolved
|
|
212
210
|
|
|
213
|
-
@property
|
|
214
|
-
def custom_querysets(self):
|
|
215
|
-
"""Resolve queryset class strings to actual classes on each access"""
|
|
216
|
-
resolved = {}
|
|
217
|
-
for key, queryset in self._custom_querysets.items():
|
|
218
|
-
if isinstance(queryset, str):
|
|
219
|
-
from django.utils.module_loading import import_string
|
|
220
|
-
try:
|
|
221
|
-
qs_class = import_string(queryset)
|
|
222
|
-
resolved[key] = qs_class
|
|
223
|
-
except ImportError:
|
|
224
|
-
raise ImportError(f"Could not import queryset class: {queryset}")
|
|
225
|
-
else:
|
|
226
|
-
resolved[key] = queryset
|
|
227
|
-
return resolved
|
|
228
|
-
|
|
229
|
-
@property
|
|
230
|
-
def custom_querysets_user_scoped(self):
|
|
231
|
-
"""Resolve queryset class strings to actual classes on each access"""
|
|
232
|
-
resolved = {}
|
|
233
|
-
for key, queryset in self._custom_querysets_user_scoped.items():
|
|
234
|
-
if isinstance(queryset, str):
|
|
235
|
-
from django.utils.module_loading import import_string
|
|
236
|
-
try:
|
|
237
|
-
qs_class = import_string(queryset)
|
|
238
|
-
resolved[key] = qs_class
|
|
239
|
-
except ImportError:
|
|
240
|
-
raise ImportError(f"Could not import queryset class: {queryset}")
|
|
241
|
-
else:
|
|
242
|
-
resolved[key] = queryset
|
|
243
|
-
return resolved
|
|
244
211
|
|
|
245
212
|
class Registry:
|
|
246
213
|
"""
|
|
@@ -70,6 +70,16 @@ class AbstractORMProvider(ABC):
|
|
|
70
70
|
def get_fields(self, model: ORMModel) -> Set[str]:
|
|
71
71
|
"""
|
|
72
72
|
Get all of the model fields - doesn't apply permissions check.
|
|
73
|
+
Includes both database fields and additional_fields (computed fields).
|
|
74
|
+
"""
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
def get_db_fields(self, model: ORMModel) -> Set[str]:
|
|
79
|
+
"""
|
|
80
|
+
Get only the actual database fields for a model.
|
|
81
|
+
Excludes read-only additional_fields (computed fields).
|
|
82
|
+
Used for deserialization - hooks can write to any DB field.
|
|
73
83
|
"""
|
|
74
84
|
pass
|
|
75
85
|
|
|
@@ -207,6 +217,20 @@ class AbstractORMProvider(ABC):
|
|
|
207
217
|
"""Create a new record using the model class."""
|
|
208
218
|
pass
|
|
209
219
|
|
|
220
|
+
@abstractmethod
|
|
221
|
+
def bulk_create(
|
|
222
|
+
self,
|
|
223
|
+
model: Type[ORMModel],
|
|
224
|
+
data_list: List[Dict[str, Any]],
|
|
225
|
+
*args,
|
|
226
|
+
**kwargs
|
|
227
|
+
) -> List[Any]:
|
|
228
|
+
"""
|
|
229
|
+
Create multiple records using the model class.
|
|
230
|
+
Returns a list of created instances.
|
|
231
|
+
"""
|
|
232
|
+
pass
|
|
233
|
+
|
|
210
234
|
@abstractmethod
|
|
211
235
|
def update(
|
|
212
236
|
self,
|
|
@@ -315,13 +339,12 @@ class AbstractORMProvider(ABC):
|
|
|
315
339
|
request: RequestType,
|
|
316
340
|
model: ORMModel, # type:ignore
|
|
317
341
|
initial_ast: Dict[str, Any],
|
|
318
|
-
custom_querysets: Dict[str, Type],
|
|
319
342
|
registered_permissions: List[Type],
|
|
320
343
|
) -> Any:
|
|
321
344
|
"""
|
|
322
345
|
Assemble and return the base QuerySet (or equivalent) for the given model.
|
|
323
346
|
This method considers the request context, initial AST (filters, sorting, etc.),
|
|
324
|
-
|
|
347
|
+
and any model-specific permission restrictions.
|
|
325
348
|
"""
|
|
326
349
|
pass
|
|
327
350
|
|
|
@@ -406,13 +429,15 @@ class AbstractDataSerializer(ABC):
|
|
|
406
429
|
def deserialize(
|
|
407
430
|
self,
|
|
408
431
|
model: ORMModel, # type:ignore
|
|
409
|
-
data: dict,
|
|
432
|
+
data: Union[dict, List[dict]],
|
|
410
433
|
allowed_fields: Optional[Dict[str, Set[str]]] = None,
|
|
411
434
|
request: Optional[Any] = None,
|
|
412
|
-
|
|
435
|
+
many: bool = False,
|
|
436
|
+
) -> Union[dict, List[dict]]:
|
|
413
437
|
"""
|
|
414
438
|
Deserialize the input data into validated Python types for the specified model.
|
|
415
439
|
- `allowed_fields`: a mapping (by model name) of fields the user is allowed to edit.
|
|
440
|
+
- `many`: if True, expects data to be a list of dicts and returns a list of validated dicts.
|
|
416
441
|
|
|
417
442
|
Only keys that appear in the allowed set will be processed.
|
|
418
443
|
"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: statezero
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.0b22
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/migrations/0001_initial.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/search_providers/__init__.py
RENAMED
|
File without changes
|
{statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/search_providers/basic_search.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|