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.

Files changed (54) hide show
  1. {statezero-0.1.0b11 → statezero-0.1.0b22}/PKG-INFO +1 -1
  2. {statezero-0.1.0b11 → statezero-0.1.0b22}/pyproject.toml +1 -1
  3. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/actions.py +33 -0
  4. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/extensions/custom_field_serializers/money_field.py +8 -0
  5. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/orm.py +29 -5
  6. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/permissions.py +6 -0
  7. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/query_optimizer.py +40 -2
  8. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/schemas.py +38 -2
  9. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/serializers.py +84 -36
  10. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/urls.py +2 -1
  11. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/views.py +101 -0
  12. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/actions.py +6 -2
  13. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/ast_parser.py +43 -0
  14. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/classes.py +58 -1
  15. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/config.py +4 -37
  16. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/interfaces.py +29 -4
  17. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/process_request.py +0 -1
  18. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/types.py +1 -0
  19. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero.egg-info/PKG-INFO +1 -1
  20. {statezero-0.1.0b11 → statezero-0.1.0b22}/README.md +0 -0
  21. {statezero-0.1.0b11 → statezero-0.1.0b22}/license.md +0 -0
  22. {statezero-0.1.0b11 → statezero-0.1.0b22}/requirements.txt +0 -0
  23. {statezero-0.1.0b11 → statezero-0.1.0b22}/setup.cfg +0 -0
  24. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/__init__.py +0 -0
  25. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/__init__.py +0 -0
  26. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/__init__.py +0 -0
  27. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/apps.py +0 -0
  28. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/config.py +0 -0
  29. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/context_manager.py +0 -0
  30. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/event_emitters.py +0 -0
  31. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/exception_handler.py +0 -0
  32. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/extensions/__init__.py +0 -0
  33. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/extensions/custom_field_serializers/__init__.py +0 -0
  34. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/extensions/custom_field_serializers/file_fields.py +0 -0
  35. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/f_handler.py +0 -0
  36. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/helpers.py +0 -0
  37. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/middleware.py +0 -0
  38. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/migrations/0001_initial.py +0 -0
  39. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/migrations/0002_delete_modelviewsubscription.py +0 -0
  40. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/migrations/__init__.py +0 -0
  41. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/search_providers/__init__.py +0 -0
  42. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/search_providers/basic_search.py +0 -0
  43. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/adaptors/django/search_providers/postgres_search.py +0 -0
  44. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/__init__.py +0 -0
  45. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/ast_validator.py +0 -0
  46. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/context_storage.py +0 -0
  47. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/event_bus.py +0 -0
  48. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/event_emitters.py +0 -0
  49. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/exceptions.py +0 -0
  50. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero/core/hook_checks.py +0 -0
  51. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero.egg-info/SOURCES.txt +0 -0
  52. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero.egg-info/dependency_links.txt +0 -0
  53. {statezero-0.1.0b11 → statezero-0.1.0b22}/statezero.egg-info/requires.txt +0 -0
  54. {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.0b11
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.0b11"
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
- related_fields_to_fetch.update(fields_map[related_model_name])
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
- root_fields_to_fetch.add(field_name)
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.blank
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
- ) -> Dict[str, Any]:
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
- # Use the context manager for the duration of deserialization
488
- with fields_map_context(fields_map):
489
- # Create serializer class
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
- try:
494
- model_config = registry.get_config(model)
495
- if model_config.pre_hooks:
496
- for hook in model_config.pre_hooks:
497
- hook_result = hook(data, request=request)
498
- if settings.DEBUG:
499
- data = _check_pre_hook_result(
500
- original_data=data,
501
- result_data=hook_result,
502
- model=model,
503
- serializer_fields=available_fields
504
- )
505
- else:
506
- data = hook_result or data
507
- except ValueError:
508
- # No model config available
509
- model_config = None
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
- for hook in model_config.post_hooks:
522
- hook_result = hook(validated_data, request=request)
523
- if settings.DEBUG:
524
- validated_data = _check_post_hook_result(
525
- original_data=validated_data,
526
- result_data=hook_result,
527
- model=model
528
- )
529
- else:
530
- validated_data = hook_result or validated_data
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
- custom query sets, and any model-specific permission restrictions.
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
- ) -> dict:
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
  """
@@ -113,7 +113,6 @@ class RequestProcessor:
113
113
  req=req,
114
114
  model=model,
115
115
  initial_ast=initial_query_ast,
116
- custom_querysets=model_config.custom_querysets,
117
116
  registered_permissions=model_config.permissions,
118
117
  )
119
118
 
@@ -22,6 +22,7 @@ class ActionType(Enum):
22
22
  READ = "read"
23
23
  UPDATE = "update"
24
24
  DELETE = "delete"
25
+ BULK_CREATE = "bulk_create"
25
26
  BULK_UPDATE = "bulk_update"
26
27
  BULK_DELETE = "bulk_delete"
27
28
  # new pre-operation types
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statezero
3
- Version: 0.1.0b11
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