statezero 0.1.0b14__tar.gz → 0.1.0b20__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {statezero-0.1.0b14 → statezero-0.1.0b20}/PKG-INFO +1 -1
  2. {statezero-0.1.0b14 → statezero-0.1.0b20}/pyproject.toml +1 -1
  3. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/actions.py +8 -0
  4. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/orm.py +9 -0
  5. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/schemas.py +17 -4
  6. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/serializers.py +39 -24
  7. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/urls.py +2 -1
  8. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/views.py +101 -0
  9. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/core/ast_parser.py +2 -13
  10. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/core/classes.py +1 -0
  11. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/core/config.py +0 -2
  12. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/core/interfaces.py +10 -0
  13. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero.egg-info/PKG-INFO +1 -1
  14. {statezero-0.1.0b14 → statezero-0.1.0b20}/README.md +0 -0
  15. {statezero-0.1.0b14 → statezero-0.1.0b20}/license.md +0 -0
  16. {statezero-0.1.0b14 → statezero-0.1.0b20}/requirements.txt +0 -0
  17. {statezero-0.1.0b14 → statezero-0.1.0b20}/setup.cfg +0 -0
  18. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/__init__.py +0 -0
  19. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/__init__.py +0 -0
  20. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/__init__.py +0 -0
  21. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/apps.py +0 -0
  22. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/config.py +0 -0
  23. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/context_manager.py +0 -0
  24. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/event_emitters.py +0 -0
  25. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/exception_handler.py +0 -0
  26. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/extensions/__init__.py +0 -0
  27. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/extensions/custom_field_serializers/__init__.py +0 -0
  28. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/extensions/custom_field_serializers/file_fields.py +0 -0
  29. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/extensions/custom_field_serializers/money_field.py +0 -0
  30. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/f_handler.py +0 -0
  31. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/helpers.py +0 -0
  32. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/middleware.py +0 -0
  33. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/migrations/0001_initial.py +0 -0
  34. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/migrations/0002_delete_modelviewsubscription.py +0 -0
  35. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/migrations/__init__.py +0 -0
  36. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/permissions.py +0 -0
  37. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/query_optimizer.py +0 -0
  38. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/search_providers/__init__.py +0 -0
  39. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/search_providers/basic_search.py +0 -0
  40. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/adaptors/django/search_providers/postgres_search.py +0 -0
  41. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/core/__init__.py +0 -0
  42. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/core/actions.py +0 -0
  43. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/core/ast_validator.py +0 -0
  44. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/core/context_storage.py +0 -0
  45. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/core/event_bus.py +0 -0
  46. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/core/event_emitters.py +0 -0
  47. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/core/exceptions.py +0 -0
  48. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/core/hook_checks.py +0 -0
  49. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/core/process_request.py +0 -0
  50. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero/core/types.py +0 -0
  51. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero.egg-info/SOURCES.txt +0 -0
  52. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero.egg-info/dependency_links.txt +0 -0
  53. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero.egg-info/requires.txt +0 -0
  54. {statezero-0.1.0b14 → statezero-0.1.0b20}/statezero.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statezero
3
- Version: 0.1.0b14
3
+ Version: 0.1.0b20
4
4
  Summary: Connect your Python backend to a modern JavaScript SPA frontend with 90% less complexity.
5
5
  Author-email: Robert <robert.herring@statezero.dev>
6
6
  Project-URL: homepage, https://www.statezero.dev
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "statezero"
7
- version = "0.1.0b14"
7
+ version = "0.1.0b20"
8
8
  description = "Connect your Python backend to a modern JavaScript SPA frontend with 90% less complexity."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -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",
@@ -763,6 +763,7 @@ class DjangoORMAdapter(AbstractORMProvider):
763
763
  def get_fields(self, model: models.Model) -> Set[str]:
764
764
  """
765
765
  Return a set of the model fields.
766
+ Includes both database fields and additional_fields (computed fields).
766
767
  """
767
768
  model_config = registry.get_config(model)
768
769
  if model_config.fields and "__all__" != model_config.fields:
@@ -775,6 +776,14 @@ class DjangoORMAdapter(AbstractORMProvider):
775
776
  resolved_fields = resolved_fields.union(additional_fields)
776
777
  return resolved_fields
777
778
 
779
+ def get_db_fields(self, model: models.Model) -> Set[str]:
780
+ """
781
+ Return only actual database fields for the model.
782
+ Excludes read-only additional_fields (computed fields).
783
+ Used for deserialization - hooks can write to any DB field.
784
+ """
785
+ return set(field.name for field in model._meta.get_fields())
786
+
778
787
  def build_model_graph(
779
788
  self, model: Type[models.Model], model_graph: nx.DiGraph = None
780
789
  ) -> nx.DiGraph:
@@ -41,9 +41,9 @@ class DjangoSchemaGenerator(AbstractSchemaGenerator):
41
41
  all_field_names: Set[str] = set()
42
42
  db_field_names: Set[str] = set()
43
43
 
44
- if model_config.frontend_fields != "__all__":
44
+ if model_config.fields != "__all__":
45
45
  all_fields = [
46
- field for field in all_fields if field.name in model_config.frontend_fields
46
+ field for field in all_fields if field.name in model_config.fields
47
47
  ]
48
48
 
49
49
  for field in all_fields:
@@ -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,7 @@ 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
10
  from zen_queries import queries_disabled
11
11
 
12
12
  from statezero.adaptors.django.config import config, registry
@@ -484,33 +484,48 @@ class DRFDynamicSerializer(AbstractDataSerializer):
484
484
  # Serious security issue if fields_map is None
485
485
  assert fields_map is not None, "fields_map is required and cannot be None"
486
486
 
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())
487
+ # Get model name and allowed fields from fields_map
488
+ model_name = config.orm_provider.get_model_name(model)
489
+ allowed_fields = fields_map.get(model_name, set())
492
490
 
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
491
+ # Filter user input to only allowed fields (security boundary)
492
+ data = dict(keyfilter(lambda k: k in allowed_fields, data))
493
+
494
+ try:
495
+ model_config = registry.get_config(model)
496
+ except ValueError:
497
+ # No model config available
498
+ model_config = None
499
+
500
+ # Run pre-hooks on filtered data (hooks can add any DB fields)
501
+ if model_config and model_config.pre_hooks:
502
+ for hook in model_config.pre_hooks:
503
+ hook_result = hook(data, request=request)
504
+ if settings.DEBUG:
505
+ # Note: available_fields check removed since hooks can add any DB field
506
+ data = hook_result or data
507
+ else:
508
+ data = hook_result or data
509
+
510
+ # Expand fields_map to include fields that hooks may have added
511
+ # For partial updates, only include allowed_fields + any fields in the data
512
+ # This prevents validation errors on required fields that were filtered out
513
+ if partial:
514
+ # For partial updates: only include fields that are either allowed or in the data
515
+ expanded_fields = allowed_fields | set(data.keys())
516
+ else:
517
+ # For creates: include all DB fields to allow hooks to add any field
518
+ expanded_fields = config.orm_provider.get_db_fields(model)
519
+ expanded_fields_map = {model_name: expanded_fields}
520
+
521
+ # Use the context manager with expanded fields map
522
+ with fields_map_context(expanded_fields_map):
523
+ # Create serializer class with all DB fields available
524
+ serializer_class = DynamicModelSerializer.for_model(model)
510
525
 
511
526
  # Create serializer
512
527
  serializer = serializer_class(
513
- data=data,
528
+ data=data,
514
529
  partial=partial,
515
530
  request=request
516
531
  )
@@ -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
@@ -386,19 +386,8 @@ class ASTParser:
386
386
 
387
387
  # If any permission allows all fields
388
388
  if fields == "__all__":
389
- # NEW: For read operations, default "__all__" to frontend_fields
390
- if operation_type == "read":
391
- # If frontend_fields is also "__all__", then return all fields
392
- if model_config.frontend_fields == "__all__":
393
- return all_fields
394
- # Otherwise, use frontend_fields as the default for "__all__"
395
- else:
396
- fields = model_config.frontend_fields
397
- fields &= all_fields # Ensure fields actually exist
398
- allowed_fields |= fields
399
- else:
400
- # For create/update operations, "__all__" means truly all fields
401
- return all_fields
389
+ return all_fields
390
+
402
391
  # Add allowed fields from this permission
403
392
  else: # Ensure we're not operating on the string "__all__"
404
393
  fields &= all_fields # Ensure fields actually exist
@@ -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"
@@ -181,7 +181,6 @@ class ModelConfig:
181
181
  searchable_fields: Optional[Union[Set[str], Literal["__all__"]]] = None,
182
182
  ordering_fields: Optional[Union[Set[str], Literal["__all__"]]] = None,
183
183
  fields: Optional[Union[Set[str], Literal["__all__"]]] = None,
184
- frontend_fields: Optional[Union[Set[str], Literal["__all__"]]] = None,
185
184
  display: Optional[Any] = None,
186
185
  DEBUG: bool = False,
187
186
  ):
@@ -196,7 +195,6 @@ class ModelConfig:
196
195
  self.searchable_fields = searchable_fields or set()
197
196
  self.ordering_fields = ordering_fields or set()
198
197
  self.fields = fields or "__all__"
199
- self.frontend_fields = frontend_fields or self.fields
200
198
  self.display = display
201
199
  self.DEBUG = DEBUG or False
202
200
 
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statezero
3
- Version: 0.1.0b14
3
+ Version: 0.1.0b20
4
4
  Summary: Connect your Python backend to a modern JavaScript SPA frontend with 90% less complexity.
5
5
  Author-email: Robert <robert.herring@statezero.dev>
6
6
  Project-URL: homepage, https://www.statezero.dev
File without changes
File without changes
File without changes