statezero 0.1.0b12__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.0b12 → statezero-0.1.0b22}/PKG-INFO +1 -1
  2. {statezero-0.1.0b12 → statezero-0.1.0b22}/pyproject.toml +1 -1
  3. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/actions.py +8 -0
  4. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/extensions/custom_field_serializers/money_field.py +8 -0
  5. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/orm.py +29 -5
  6. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/permissions.py +6 -0
  7. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/query_optimizer.py +40 -2
  8. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/schemas.py +15 -2
  9. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/serializers.py +84 -36
  10. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/urls.py +2 -1
  11. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/views.py +101 -0
  12. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/core/ast_parser.py +43 -0
  13. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/core/classes.py +1 -0
  14. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/core/config.py +0 -37
  15. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/core/interfaces.py +29 -4
  16. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/core/process_request.py +0 -1
  17. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/core/types.py +1 -0
  18. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero.egg-info/PKG-INFO +1 -1
  19. {statezero-0.1.0b12 → statezero-0.1.0b22}/README.md +0 -0
  20. {statezero-0.1.0b12 → statezero-0.1.0b22}/license.md +0 -0
  21. {statezero-0.1.0b12 → statezero-0.1.0b22}/requirements.txt +0 -0
  22. {statezero-0.1.0b12 → statezero-0.1.0b22}/setup.cfg +0 -0
  23. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/__init__.py +0 -0
  24. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/__init__.py +0 -0
  25. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/__init__.py +0 -0
  26. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/apps.py +0 -0
  27. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/config.py +0 -0
  28. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/context_manager.py +0 -0
  29. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/event_emitters.py +0 -0
  30. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/exception_handler.py +0 -0
  31. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/extensions/__init__.py +0 -0
  32. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/extensions/custom_field_serializers/__init__.py +0 -0
  33. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/extensions/custom_field_serializers/file_fields.py +0 -0
  34. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/f_handler.py +0 -0
  35. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/helpers.py +0 -0
  36. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/middleware.py +0 -0
  37. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/migrations/0001_initial.py +0 -0
  38. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/migrations/0002_delete_modelviewsubscription.py +0 -0
  39. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/migrations/__init__.py +0 -0
  40. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/search_providers/__init__.py +0 -0
  41. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/search_providers/basic_search.py +0 -0
  42. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/adaptors/django/search_providers/postgres_search.py +0 -0
  43. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/core/__init__.py +0 -0
  44. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/core/actions.py +0 -0
  45. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/core/ast_validator.py +0 -0
  46. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/core/context_storage.py +0 -0
  47. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/core/event_bus.py +0 -0
  48. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/core/event_emitters.py +0 -0
  49. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/core/exceptions.py +0 -0
  50. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero/core/hook_checks.py +0 -0
  51. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero.egg-info/SOURCES.txt +0 -0
  52. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero.egg-info/dependency_links.txt +0 -0
  53. {statezero-0.1.0b12 → statezero-0.1.0b22}/statezero.egg-info/requires.txt +0 -0
  54. {statezero-0.1.0b12 → 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.0b12
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.0b12"
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" }
@@ -125,6 +125,14 @@ class DjangoActionSchemaGenerator:
125
125
  return "string"
126
126
  return "integer"
127
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
+
128
136
  type_mapping = {
129
137
  fields.BooleanField: "boolean",
130
138
  fields.CharField: "string",
@@ -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)
@@ -178,6 +178,7 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
178
178
  nullable=False,
179
179
  format=FieldFormat.ID,
180
180
  description=description,
181
+ read_only=True,
181
182
  )
182
183
  elif isinstance(field, models.UUIDField):
183
184
  return SchemaFieldMetadata(
@@ -187,6 +188,7 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
187
188
  nullable=False,
188
189
  format=FieldFormat.UUID,
189
190
  description=description,
191
+ read_only=True,
190
192
  )
191
193
  elif isinstance(field, models.CharField):
192
194
  return SchemaFieldMetadata(
@@ -197,6 +199,7 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
197
199
  format=FieldFormat.ID,
198
200
  max_length=field.max_length,
199
201
  description=description,
202
+ read_only=True,
200
203
  )
201
204
  else:
202
205
  return SchemaFieldMetadata(
@@ -206,6 +209,7 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
206
209
  nullable=False,
207
210
  format=FieldFormat.ID,
208
211
  description=description,
212
+ read_only=True,
209
213
  )
210
214
 
211
215
  def get_field_metadata(
@@ -256,6 +260,9 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
256
260
  elif isinstance(field, models.DateField):
257
261
  field_type = FieldType.STRING
258
262
  field_format = FieldFormat.DATE
263
+ elif isinstance(field, models.TimeField):
264
+ field_type = FieldType.STRING
265
+ field_format = FieldFormat.TIME
259
266
  elif isinstance(field, (models.ForeignKey, models.OneToOneField)):
260
267
  field_type = self.get_pk_type(field)
261
268
  field_format = self.get_relation_type(field)
@@ -286,6 +293,12 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
286
293
  elif callable(default):
287
294
  default = default()
288
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
+
289
302
  return SchemaFieldMetadata(
290
303
  type=field_type,
291
304
  title=title,
@@ -299,6 +312,7 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
299
312
  max_digits=max_digits,
300
313
  decimal_places=decimal_places,
301
314
  description=description,
315
+ read_only=read_only,
302
316
  )
303
317
 
304
318
  def get_field_title(self, field: models.Field) -> str:
@@ -309,8 +323,7 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
309
323
  @staticmethod
310
324
  def is_field_required(field: models.Field) -> bool:
311
325
  return (
312
- not field.blank
313
- and not field.null
326
+ not field.null
314
327
  and field.default == models.fields.NOT_PROVIDED
315
328
  )
316
329
 
@@ -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
@@ -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"
@@ -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
@@ -171,8 +169,6 @@ class ModelConfig:
171
169
  def __init__(
172
170
  self,
173
171
  model: Type,
174
- custom_querysets: Optional[Dict[str, Type[AbstractCustomQueryset]]] = None,
175
- custom_querysets_user_scoped: Optional[Dict[str, bool]] = None,
176
172
  permissions: Optional[List[Type[AbstractPermission]]] = None,
177
173
  pre_hooks: Optional[List] = None,
178
174
  post_hooks: Optional[List] = None,
@@ -185,8 +181,6 @@ class ModelConfig:
185
181
  DEBUG: bool = False,
186
182
  ):
187
183
  self.model = model
188
- self._custom_querysets = custom_querysets or {}
189
- self._custom_querysets_user_scoped = custom_querysets_user_scoped or {}
190
184
  self._permissions = permissions or []
191
185
  self.pre_hooks = pre_hooks or []
192
186
  self.post_hooks = post_hooks or []
@@ -214,37 +208,6 @@ class ModelConfig:
214
208
  resolved.append(perm)
215
209
  return resolved
216
210
 
217
- @property
218
- def custom_querysets(self):
219
- """Resolve queryset class strings to actual classes on each access"""
220
- resolved = {}
221
- for key, queryset in self._custom_querysets.items():
222
- if isinstance(queryset, str):
223
- from django.utils.module_loading import import_string
224
- try:
225
- qs_class = import_string(queryset)
226
- resolved[key] = qs_class
227
- except ImportError:
228
- raise ImportError(f"Could not import queryset class: {queryset}")
229
- else:
230
- resolved[key] = queryset
231
- return resolved
232
-
233
- @property
234
- def custom_querysets_user_scoped(self):
235
- """Resolve queryset class strings to actual classes on each access"""
236
- resolved = {}
237
- for key, queryset in self._custom_querysets_user_scoped.items():
238
- if isinstance(queryset, str):
239
- from django.utils.module_loading import import_string
240
- try:
241
- qs_class = import_string(queryset)
242
- resolved[key] = qs_class
243
- except ImportError:
244
- raise ImportError(f"Could not import queryset class: {queryset}")
245
- else:
246
- resolved[key] = queryset
247
- return resolved
248
211
 
249
212
  class Registry:
250
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.0b12
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