statezero 0.1.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- statezero/__init__.py +0 -0
- statezero/adaptors/__init__.py +0 -0
- statezero/adaptors/django/__init__.py +0 -0
- statezero/adaptors/django/apps.py +97 -0
- statezero/adaptors/django/config.py +99 -0
- statezero/adaptors/django/context_manager.py +12 -0
- statezero/adaptors/django/event_emitters.py +78 -0
- statezero/adaptors/django/exception_handler.py +98 -0
- statezero/adaptors/django/extensions/__init__.py +0 -0
- statezero/adaptors/django/extensions/custom_field_serializers/__init__.py +0 -0
- statezero/adaptors/django/extensions/custom_field_serializers/file_fields.py +141 -0
- statezero/adaptors/django/extensions/custom_field_serializers/money_field.py +75 -0
- statezero/adaptors/django/f_handler.py +312 -0
- statezero/adaptors/django/helpers.py +153 -0
- statezero/adaptors/django/middleware.py +10 -0
- statezero/adaptors/django/migrations/0001_initial.py +33 -0
- statezero/adaptors/django/migrations/0002_delete_modelviewsubscription.py +16 -0
- statezero/adaptors/django/migrations/__init__.py +0 -0
- statezero/adaptors/django/orm.py +915 -0
- statezero/adaptors/django/permissions.py +252 -0
- statezero/adaptors/django/query_optimizer.py +772 -0
- statezero/adaptors/django/schemas.py +324 -0
- statezero/adaptors/django/search_providers/__init__.py +0 -0
- statezero/adaptors/django/search_providers/basic_search.py +24 -0
- statezero/adaptors/django/search_providers/postgres_search.py +51 -0
- statezero/adaptors/django/serializers.py +554 -0
- statezero/adaptors/django/urls.py +14 -0
- statezero/adaptors/django/views.py +336 -0
- statezero/core/__init__.py +34 -0
- statezero/core/ast_parser.py +821 -0
- statezero/core/ast_validator.py +266 -0
- statezero/core/classes.py +167 -0
- statezero/core/config.py +263 -0
- statezero/core/context_storage.py +4 -0
- statezero/core/event_bus.py +175 -0
- statezero/core/event_emitters.py +60 -0
- statezero/core/exceptions.py +106 -0
- statezero/core/interfaces.py +492 -0
- statezero/core/process_request.py +184 -0
- statezero/core/types.py +63 -0
- statezero-0.1.0b1.dist-info/METADATA +252 -0
- statezero-0.1.0b1.dist-info/RECORD +45 -0
- statezero-0.1.0b1.dist-info/WHEEL +5 -0
- statezero-0.1.0b1.dist-info/licenses/license.md +117 -0
- statezero-0.1.0b1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Any, Callable, Dict, List, Set, Type
|
|
3
|
+
|
|
4
|
+
import networkx as nx
|
|
5
|
+
|
|
6
|
+
from statezero.core.config import Registry
|
|
7
|
+
from statezero.core.exceptions import PermissionDenied, ValidationError
|
|
8
|
+
from statezero.core.interfaces import AbstractPermission
|
|
9
|
+
from statezero.core.types import ActionType, ORMModel, RequestType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ASTValidator:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
model_graph: nx.DiGraph,
|
|
16
|
+
get_model_name: Callable[[Type], str],
|
|
17
|
+
registry: Registry,
|
|
18
|
+
request: Any,
|
|
19
|
+
get_model_by_name: Callable[[str], Type],
|
|
20
|
+
):
|
|
21
|
+
"""
|
|
22
|
+
:param model_graph: The model graph built by the ORM's graph builder.
|
|
23
|
+
:param get_model_name: A callable that returns a unique name for a given model.
|
|
24
|
+
:param registry: Global registry mapping models to their ModelConfig.
|
|
25
|
+
:param request: The current request (for permission checking).
|
|
26
|
+
:param get_model_by_name: Helper to resolve a model by its unique name.
|
|
27
|
+
"""
|
|
28
|
+
self.model_graph = model_graph
|
|
29
|
+
self.get_model_name = get_model_name
|
|
30
|
+
self.registry = registry
|
|
31
|
+
self.request = request
|
|
32
|
+
self.get_model_by_name = get_model_by_name
|
|
33
|
+
|
|
34
|
+
def _aggregate_permission_instances(self, model: Type) -> List[AbstractPermission]:
|
|
35
|
+
"""
|
|
36
|
+
Given a model, return a list of permission instances as specified in its ModelConfig.
|
|
37
|
+
(You might cache these or use a more sophisticated composition in a real app.)
|
|
38
|
+
"""
|
|
39
|
+
config = self.registry.get_config(model)
|
|
40
|
+
# Instantiate each permission class (assuming no extra init parameters).
|
|
41
|
+
return [perm_class() for perm_class in config.permissions]
|
|
42
|
+
|
|
43
|
+
def _allowed_fields_for_model(self, model: Type) -> Set[str]:
|
|
44
|
+
"""
|
|
45
|
+
Aggregates the visible fields from all permission instances for the given model.
|
|
46
|
+
"""
|
|
47
|
+
allowed_fields: Set[str] = set()
|
|
48
|
+
for perm in self._aggregate_permission_instances(model):
|
|
49
|
+
fields = perm.visible_fields(self.request, model)
|
|
50
|
+
if fields == "__all__":
|
|
51
|
+
# If any permission allows all fields, return all available fields
|
|
52
|
+
from statezero.adaptors.django.config import config
|
|
53
|
+
|
|
54
|
+
return config.orm_provider.get_fields(model)
|
|
55
|
+
allowed_fields |= fields
|
|
56
|
+
return allowed_fields
|
|
57
|
+
|
|
58
|
+
def _has_read_permission(self, model: Type) -> bool:
|
|
59
|
+
"""
|
|
60
|
+
Checks if any of the permission instances for the model allow the READ action.
|
|
61
|
+
"""
|
|
62
|
+
for perm in self._aggregate_permission_instances(model):
|
|
63
|
+
if ActionType.READ in perm.allowed_actions(self.request, model):
|
|
64
|
+
return True
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
def _is_additional_field(self, model: Type, field_name: str) -> bool:
|
|
68
|
+
"""
|
|
69
|
+
Check if a field is an additional (computed) field that doesn't exist in the Django model.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
config = self.registry.get_config(model)
|
|
73
|
+
additional_field_names = {f.name for f in config.additional_fields}
|
|
74
|
+
return field_name in additional_field_names
|
|
75
|
+
except (ValueError, AttributeError):
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
def _field_exists_in_django_model(self, model: Type, field_path: str) -> bool:
|
|
79
|
+
"""
|
|
80
|
+
Check if a field path exists in the actual Django model (not just additional fields).
|
|
81
|
+
This validates that Django ORM can actually filter/query on this field.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
# Remove any lookup operators (e.g., 'name__icontains' -> 'name')
|
|
85
|
+
SUPPORTED_OPERATORS = {
|
|
86
|
+
"contains",
|
|
87
|
+
"icontains",
|
|
88
|
+
"startswith",
|
|
89
|
+
"istartswith",
|
|
90
|
+
"endswith",
|
|
91
|
+
"iendswith",
|
|
92
|
+
"lt",
|
|
93
|
+
"gt",
|
|
94
|
+
"lte",
|
|
95
|
+
"gte",
|
|
96
|
+
"in",
|
|
97
|
+
"eq",
|
|
98
|
+
"exact",
|
|
99
|
+
"isnull",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
field_parts = field_path.split("__")
|
|
103
|
+
# Find where the lookup operators start
|
|
104
|
+
base_field_parts = []
|
|
105
|
+
for part in field_parts:
|
|
106
|
+
if part in SUPPORTED_OPERATORS:
|
|
107
|
+
break
|
|
108
|
+
base_field_parts.append(part)
|
|
109
|
+
|
|
110
|
+
# Traverse the Django model fields
|
|
111
|
+
current_model = model
|
|
112
|
+
for field_name in base_field_parts:
|
|
113
|
+
try:
|
|
114
|
+
field = current_model._meta.get_field(field_name)
|
|
115
|
+
if field.is_relation and hasattr(field, "related_model"):
|
|
116
|
+
current_model = field.related_model
|
|
117
|
+
except:
|
|
118
|
+
# Field doesn't exist in Django model
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
return True
|
|
122
|
+
except:
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
def is_field_allowed(self, model: Type, field_path: str) -> bool:
|
|
126
|
+
"""
|
|
127
|
+
Validates a nested field path (e.g. "related__name" or "level2__level3__name")
|
|
128
|
+
by using the model registry to extract the permission settings for each model
|
|
129
|
+
encountered along the path. Uses "__" to separate nested fields, and "::" as
|
|
130
|
+
the delimiter within a field node key.
|
|
131
|
+
"""
|
|
132
|
+
parts = field_path.split("__")
|
|
133
|
+
current_model = model
|
|
134
|
+
current_model_name = self.get_model_name(current_model)
|
|
135
|
+
|
|
136
|
+
# Check that the user has READ permission on the root model.
|
|
137
|
+
if not self._has_read_permission(current_model):
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
# Get allowed fields from permission settings.
|
|
141
|
+
allowed = self._allowed_fields_for_model(current_model)
|
|
142
|
+
for part in parts:
|
|
143
|
+
# Check that the field is allowed on the current model.
|
|
144
|
+
if part not in allowed and "__all__" not in str(allowed):
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
# Construct the field node key.
|
|
148
|
+
field_node = f"{current_model_name}::{part}"
|
|
149
|
+
if self.model_graph.has_node(field_node):
|
|
150
|
+
node_data = self.model_graph.nodes[field_node].get("data")
|
|
151
|
+
if not node_data:
|
|
152
|
+
return False
|
|
153
|
+
if node_data.is_relation:
|
|
154
|
+
# If this is a relation, resolve the related model.
|
|
155
|
+
related_model_name = node_data.related_model
|
|
156
|
+
if related_model_name:
|
|
157
|
+
related_model = self.get_model_by_name(related_model_name)
|
|
158
|
+
# Check READ permission on the related model.
|
|
159
|
+
if not self._has_read_permission(related_model):
|
|
160
|
+
return False
|
|
161
|
+
# Move to the related model for the next part.
|
|
162
|
+
current_model = related_model
|
|
163
|
+
current_model_name = related_model_name
|
|
164
|
+
allowed = self._allowed_fields_for_model(current_model)
|
|
165
|
+
continue
|
|
166
|
+
else:
|
|
167
|
+
return False
|
|
168
|
+
else:
|
|
169
|
+
# Terminal (non-relation) field reached.
|
|
170
|
+
break
|
|
171
|
+
else:
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
def validate_filterable_field(self, model: Type, field_path: str) -> None:
|
|
177
|
+
"""
|
|
178
|
+
Validates that a field path can be used for filtering operations.
|
|
179
|
+
Checks both field existence and user permissions.
|
|
180
|
+
Raises ValidationError if the field is an additional field or doesn't exist in Django model.
|
|
181
|
+
Raises PermissionDenied if the user doesn't have permission to access the field.
|
|
182
|
+
"""
|
|
183
|
+
base_field = field_path.split("__")[0]
|
|
184
|
+
|
|
185
|
+
# Check if it's an additional field (these can't be filtered)
|
|
186
|
+
if self._is_additional_field(model, base_field):
|
|
187
|
+
raise ValidationError(
|
|
188
|
+
f"Cannot filter on computed field '{base_field}'. "
|
|
189
|
+
f"Computed fields are read-only and cannot be used in filters. "
|
|
190
|
+
f"Consider using Django's computed fields, database annotations, or filter on the underlying fields instead."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Check if the field actually exists in the Django model
|
|
194
|
+
if not self._field_exists_in_django_model(model, field_path):
|
|
195
|
+
raise ValidationError(
|
|
196
|
+
f"Field '{field_path}' does not exist on model {model.__name__}. "
|
|
197
|
+
f"Please check the field name and ensure it's a valid Django model field."
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Check if the user has permission to access this field
|
|
201
|
+
if not self.is_field_allowed(model, field_path):
|
|
202
|
+
raise PermissionDenied(
|
|
203
|
+
f"Permission denied: You do not have access to filter on field '{field_path}'"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def validate_filter_conditions(self, ast_node: Dict[str, Any], model: Type) -> None:
|
|
207
|
+
"""
|
|
208
|
+
Recursively validates filter conditions in an AST node to ensure all fields are filterable.
|
|
209
|
+
"""
|
|
210
|
+
if not isinstance(ast_node, dict):
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
node_type = ast_node.get("type")
|
|
214
|
+
|
|
215
|
+
# Handle filter nodes
|
|
216
|
+
if node_type == "filter":
|
|
217
|
+
conditions = ast_node.get("conditions", {})
|
|
218
|
+
for field_path in conditions.keys():
|
|
219
|
+
self.validate_filterable_field(model, field_path)
|
|
220
|
+
|
|
221
|
+
# Handle exclude nodes (they also filter, so same validation applies)
|
|
222
|
+
elif node_type == "exclude":
|
|
223
|
+
if "child" in ast_node:
|
|
224
|
+
self.validate_filter_conditions(ast_node["child"], model)
|
|
225
|
+
else:
|
|
226
|
+
# Direct exclude conditions
|
|
227
|
+
conditions = ast_node.get("conditions", {})
|
|
228
|
+
for field_path in conditions.keys():
|
|
229
|
+
self.validate_filterable_field(model, field_path)
|
|
230
|
+
|
|
231
|
+
# Recursively validate children
|
|
232
|
+
if "children" in ast_node:
|
|
233
|
+
for child in ast_node["children"]:
|
|
234
|
+
self.validate_filter_conditions(child, model)
|
|
235
|
+
|
|
236
|
+
if "child" in ast_node:
|
|
237
|
+
self.validate_filter_conditions(ast_node["child"], model)
|
|
238
|
+
|
|
239
|
+
def validate_fields(self, ast: Dict[str, Any], root_model: Type) -> None:
|
|
240
|
+
"""
|
|
241
|
+
Iterates over the requested fields in the AST's serializerOptions and verifies each
|
|
242
|
+
field is allowed according to the registry-based permission settings.
|
|
243
|
+
Raises PermissionDenied if any field is not permitted.
|
|
244
|
+
"""
|
|
245
|
+
serializer_options = ast.get("serializerOptions", {})
|
|
246
|
+
requested_fields = serializer_options.get("fields", [])
|
|
247
|
+
for field in requested_fields:
|
|
248
|
+
if not self.is_field_allowed(root_model, field):
|
|
249
|
+
raise PermissionDenied(f"Access to field '{field}' is not permitted.")
|
|
250
|
+
|
|
251
|
+
def validate_ast(self, ast: Dict[str, Any], root_model: Type) -> None:
|
|
252
|
+
"""
|
|
253
|
+
Complete AST validation including both field permissions and filter validation.
|
|
254
|
+
"""
|
|
255
|
+
# Validate field access permissions
|
|
256
|
+
self.validate_fields(ast, root_model)
|
|
257
|
+
|
|
258
|
+
# Validate filter conditions to ensure no additional fields are used
|
|
259
|
+
filter_node = ast.get("filter")
|
|
260
|
+
if filter_node:
|
|
261
|
+
self.validate_filter_conditions(filter_node, root_model)
|
|
262
|
+
|
|
263
|
+
# Also validate any exclude conditions
|
|
264
|
+
exclude_node = ast.get("exclude")
|
|
265
|
+
if exclude_node:
|
|
266
|
+
self.validate_filter_conditions(exclude_node, root_model)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Any, Dict, List, Optional, Set, Type, Union
|
|
4
|
+
|
|
5
|
+
import jsonschema
|
|
6
|
+
from fastapi.encoders import jsonable_encoder
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from statezero.core.types import ORMField
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ValidatorType(str, Enum):
|
|
13
|
+
"""OpenAPI-aligned validators"""
|
|
14
|
+
|
|
15
|
+
# Numeric validators
|
|
16
|
+
MINIMUM = "minimum"
|
|
17
|
+
MAXIMUM = "maximum"
|
|
18
|
+
EXCLUSIVE_MINIMUM = "exclusiveMinimum"
|
|
19
|
+
EXCLUSIVE_MAXIMUM = "exclusiveMaximum"
|
|
20
|
+
MULTIPLE_OF = "multipleOf"
|
|
21
|
+
|
|
22
|
+
# String validators
|
|
23
|
+
MIN_LENGTH = "minLength"
|
|
24
|
+
MAX_LENGTH = "maxLength"
|
|
25
|
+
PATTERN = "pattern"
|
|
26
|
+
|
|
27
|
+
# Array validators
|
|
28
|
+
MIN_ITEMS = "minItems"
|
|
29
|
+
MAX_ITEMS = "maxItems"
|
|
30
|
+
UNIQUE_ITEMS = "uniqueItems"
|
|
31
|
+
|
|
32
|
+
# Object validators
|
|
33
|
+
MIN_PROPERTIES = "minProperties"
|
|
34
|
+
MAX_PROPERTIES = "maxProperties"
|
|
35
|
+
REQUIRED = "required"
|
|
36
|
+
|
|
37
|
+
# Format validators
|
|
38
|
+
FORMAT = "format" # Handles email, url, date-time, etc.
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Validator:
|
|
43
|
+
type: ValidatorType
|
|
44
|
+
value: Any # The constraint value
|
|
45
|
+
message: str # Error message to display
|
|
46
|
+
|
|
47
|
+
def to_openapi(self) -> dict:
|
|
48
|
+
"""Convert validator to OpenAPI schema fragment"""
|
|
49
|
+
if self.type == ValidatorType.FORMAT:
|
|
50
|
+
return {"format": self.value}
|
|
51
|
+
return {self.type: self.value}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FieldType(str, Enum):
|
|
55
|
+
"""Basic field types from the implementation"""
|
|
56
|
+
|
|
57
|
+
STRING = "string"
|
|
58
|
+
INTEGER = "integer"
|
|
59
|
+
BOOLEAN = "boolean"
|
|
60
|
+
NUMBER = "number"
|
|
61
|
+
ARRAY = "array"
|
|
62
|
+
OBJECT = "object"
|
|
63
|
+
FILE = "file"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FieldFormat(str, Enum):
|
|
67
|
+
"""Field formats from the implementation"""
|
|
68
|
+
|
|
69
|
+
ID = "id"
|
|
70
|
+
UUID = "uuid"
|
|
71
|
+
TEXT = "text"
|
|
72
|
+
DATE = "date"
|
|
73
|
+
DATETIME = "date-time"
|
|
74
|
+
FOREIGN_KEY = "foreign-key"
|
|
75
|
+
ONE_TO_ONE = "one-to-one"
|
|
76
|
+
MANY_TO_MANY = "many-to-many"
|
|
77
|
+
DECIMAL = "decimal"
|
|
78
|
+
FILE_PATH = "file-path"
|
|
79
|
+
IMAGE_PATH = "image-path"
|
|
80
|
+
JSON = "json"
|
|
81
|
+
MONEY = "money"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class AdditionalField:
|
|
86
|
+
"""
|
|
87
|
+
Represents configuration for an additional computed field in the schema.
|
|
88
|
+
|
|
89
|
+
Attributes:
|
|
90
|
+
name: The name of the property/method on the model that provides the value
|
|
91
|
+
field: The Django model field instance that defines the serialization behavior
|
|
92
|
+
title: Optional override for the field's display title
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
name: str # The property/method name to pull from
|
|
96
|
+
field: Type[ORMField] # The instantiated serializer field (e.g. CharField(max_length=255)) #type:ignore
|
|
97
|
+
title: Optional[str] # Optional display name override
|
|
98
|
+
|
|
99
|
+
class SchemaFieldMetadata(BaseModel):
|
|
100
|
+
type: FieldType
|
|
101
|
+
title: str
|
|
102
|
+
required: bool
|
|
103
|
+
description: Optional[str] = None
|
|
104
|
+
nullable: bool = False
|
|
105
|
+
format: Optional[FieldFormat] = None
|
|
106
|
+
max_length: Optional[int] = None
|
|
107
|
+
choices: Optional[Dict[str, str]] = None
|
|
108
|
+
default: Optional[Any] = None
|
|
109
|
+
validators: List[Validator] = Field(default_factory=list)
|
|
110
|
+
max_digits: Optional[int] = None # For decimal fields
|
|
111
|
+
decimal_places: Optional[int] = None # For decimal fields
|
|
112
|
+
read_only: bool = False
|
|
113
|
+
ref: Optional[str] = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ModelSchemaMetadata(BaseModel):
|
|
117
|
+
"""Core model metadata needed for frontend operations"""
|
|
118
|
+
|
|
119
|
+
model_name: str # model name for queries
|
|
120
|
+
title: str # display name (verbose_name)
|
|
121
|
+
class_name: str # class name for generating ts/js classes
|
|
122
|
+
plural_title: str # verbose_name_plural
|
|
123
|
+
primary_key_field: str
|
|
124
|
+
|
|
125
|
+
# Query capabilities
|
|
126
|
+
filterable_fields: Set[str]
|
|
127
|
+
searchable_fields: Set[str]
|
|
128
|
+
ordering_fields: Set[str]
|
|
129
|
+
properties: Dict[str, SchemaFieldMetadata]
|
|
130
|
+
relationships: Dict[str, Dict[str, Any]]
|
|
131
|
+
default_ordering: Optional[List[str]] = None
|
|
132
|
+
# Extra definitions (for schemas referenced via $ref) are merged in if provided.
|
|
133
|
+
definitions: Dict[str, Any] = field(default_factory=dict)
|
|
134
|
+
|
|
135
|
+
# Date / time formatting templates
|
|
136
|
+
datetime_format: Optional[str] = None
|
|
137
|
+
date_format: Optional[str] = None
|
|
138
|
+
time_format: Optional[str] = None
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class ModelSummaryRepresentation:
|
|
142
|
+
pk: Any
|
|
143
|
+
repr: Dict[str, Optional[str]] = field(default_factory=dict)
|
|
144
|
+
model_name: Optional[str] = field(default=None)
|
|
145
|
+
pk_field: str = "id"
|
|
146
|
+
|
|
147
|
+
def to_dict(self) -> dict:
|
|
148
|
+
return {
|
|
149
|
+
self.pk_field: jsonable_encoder(self.pk),
|
|
150
|
+
"repr": self.repr,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class ModelNode:
|
|
156
|
+
model_name: str
|
|
157
|
+
model: Optional[Type] = None # The actual model class (if applicable)
|
|
158
|
+
type: str = "model"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class FieldNode:
|
|
163
|
+
model_name: str # The parent model's name
|
|
164
|
+
field_name: str # The name of the field
|
|
165
|
+
is_relation: bool
|
|
166
|
+
related_model: Optional[str] = None # The object name of the related model, if any
|
|
167
|
+
type: str = "field"
|
statezero/core/config.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Type, Union, Literal
|
|
5
|
+
import networkx as nx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
from statezero.core.classes import AdditionalField
|
|
9
|
+
from statezero.core.event_bus import EventBus
|
|
10
|
+
from statezero.core.interfaces import (AbstractCustomQueryset,
|
|
11
|
+
AbstractDataSerializer,
|
|
12
|
+
AbstractORMProvider, AbstractPermission,
|
|
13
|
+
AbstractSchemaGenerator, AbstractSearchProvider, AbstractQueryOptimizer)
|
|
14
|
+
from statezero.core.types import ORMField
|
|
15
|
+
|
|
16
|
+
class AppConfig(ABC):
|
|
17
|
+
"""
|
|
18
|
+
Global configuration for the system.
|
|
19
|
+
Developers configure:
|
|
20
|
+
- The global web engine (including serializer and schema generator)
|
|
21
|
+
- The ORM provider (e.g. SQLAlchemyORMProvider, etc.)
|
|
22
|
+
- The event emitter (e.g. FastAPIEventEmitter, etc.)
|
|
23
|
+
|
|
24
|
+
Global overrides for both serializer and schema generation are provided,
|
|
25
|
+
keyed by ORMField. These overrides apply to all models unless a per-model override
|
|
26
|
+
is provided in the model's configuration.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
serializer: Optional[AbstractDataSerializer] = None
|
|
30
|
+
schema_generator: Optional[AbstractSchemaGenerator] = None
|
|
31
|
+
|
|
32
|
+
# Global custom overrides for ALL models.
|
|
33
|
+
custom_serializers: Dict[ORMField, Callable] = {} # type:ignore
|
|
34
|
+
schema_overrides: Dict[ORMField, dict] = {} # type:ignore
|
|
35
|
+
|
|
36
|
+
event_bus: EventBus = None
|
|
37
|
+
default_limit: Optional[int] = 100
|
|
38
|
+
orm_provider: AbstractORMProvider = None
|
|
39
|
+
search_provider: AbstractSearchProvider = None
|
|
40
|
+
|
|
41
|
+
# Query optimizers
|
|
42
|
+
query_optimizer: Optional[AbstractQueryOptimizer] = None
|
|
43
|
+
file_upload_callbacks: Optional[List[str]] = None
|
|
44
|
+
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self._orm_provider: Optional[AbstractORMProvider] = None
|
|
47
|
+
|
|
48
|
+
def configure(self, **kwargs) -> None:
|
|
49
|
+
for key, value in kwargs.items():
|
|
50
|
+
if hasattr(self, key):
|
|
51
|
+
setattr(self, key, value)
|
|
52
|
+
else:
|
|
53
|
+
raise AttributeError(f"Invalid configuration key: {key}")
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def initialize(self) -> None:
|
|
57
|
+
"""
|
|
58
|
+
Initialize the global configuration for the system.
|
|
59
|
+
|
|
60
|
+
This method sets up all core components needed by the framework, including:
|
|
61
|
+
- The data serializer and schema generator.
|
|
62
|
+
- The ORM provider for database interactions.
|
|
63
|
+
- The event bus along with its associated event emitters.
|
|
64
|
+
- Caching and dependency tracking mechanisms.
|
|
65
|
+
|
|
66
|
+
It must be implemented by each subclass of AppConfig to ensure that all required
|
|
67
|
+
components are properly configured and wired together before the application starts
|
|
68
|
+
processing requests.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
NotImplementedError: If the method is not implemented in a subclass.
|
|
72
|
+
"""
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
def validate_exposed_models(self, registry: Registry) -> bool:
|
|
76
|
+
"""
|
|
77
|
+
Validate that all registered models only expose fields
|
|
78
|
+
that reference other registered models.
|
|
79
|
+
|
|
80
|
+
This implementation is ORM-agnostic, using the configured orm_provider
|
|
81
|
+
to access model relationships.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
registry: The global registry containing registered models
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
bool: True if validation passes
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
ValueError: If a registered model exposes an unregistered model
|
|
91
|
+
"""
|
|
92
|
+
if not self.orm_provider:
|
|
93
|
+
raise ValueError("ORM provider must be initialized before validation")
|
|
94
|
+
|
|
95
|
+
# Build complete model graph for all registered models
|
|
96
|
+
model_graph = nx.DiGraph()
|
|
97
|
+
for model in registry._models_config.keys():
|
|
98
|
+
model_graph = self.orm_provider.build_model_graph(model, model_graph)
|
|
99
|
+
|
|
100
|
+
# Check each registered model
|
|
101
|
+
for model, config in registry._models_config.items():
|
|
102
|
+
# Get model name for error messages and graph lookup
|
|
103
|
+
model_name = self.orm_provider.get_model_name(model)
|
|
104
|
+
|
|
105
|
+
# Get all field nodes from the graph for this model
|
|
106
|
+
all_model_fields = set()
|
|
107
|
+
for _, field_node in model_graph.out_edges(model_name):
|
|
108
|
+
if "::" in field_node:
|
|
109
|
+
field_name = field_node.split("::")[-1]
|
|
110
|
+
all_model_fields.add(field_name)
|
|
111
|
+
|
|
112
|
+
# Determine which fields to check based on config.fields
|
|
113
|
+
fields_to_check = config.fields if config.fields != "__all__" else all_model_fields
|
|
114
|
+
|
|
115
|
+
# Check each field to see if it's a relation to an unregistered model
|
|
116
|
+
for field_name in fields_to_check:
|
|
117
|
+
field_node = f"{model_name}::{field_name}"
|
|
118
|
+
|
|
119
|
+
if model_graph.has_node(field_node):
|
|
120
|
+
node_data = model_graph.nodes[field_node].get("data")
|
|
121
|
+
if node_data and node_data.is_relation:
|
|
122
|
+
related_model_name = node_data.related_model
|
|
123
|
+
if related_model_name:
|
|
124
|
+
# Get the related model from its name
|
|
125
|
+
related_model = self.orm_provider.get_model_by_name(related_model_name)
|
|
126
|
+
|
|
127
|
+
# Check if related model is registered
|
|
128
|
+
if related_model not in registry._models_config:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"Model '{model_name}' exposes relation '{field_name}' "
|
|
131
|
+
f"to unregistered model '{related_model_name}'. "
|
|
132
|
+
f"Please register '{related_model_name}' with StateZero "
|
|
133
|
+
f"or restrict access to this field by excluding it from the 'fields' parameter."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ModelConfig:
|
|
140
|
+
"""
|
|
141
|
+
Initialize model-specific configuration.
|
|
142
|
+
|
|
143
|
+
Parameters:
|
|
144
|
+
-----------
|
|
145
|
+
model: Type
|
|
146
|
+
The model class to register
|
|
147
|
+
custom_querysets: Dict[str, Type[AbstractCustomQueryset]], optional
|
|
148
|
+
Custom queryset methods for this model
|
|
149
|
+
permissions: List[Type[AbstractPermission]], optional
|
|
150
|
+
Permission classes that control access to this model
|
|
151
|
+
pre_hooks: List[Callable], optional
|
|
152
|
+
Functions to run before serialization/deserialization
|
|
153
|
+
post_hooks: List[Callable], optional
|
|
154
|
+
Functions to run after serialization/deserialization
|
|
155
|
+
additional_fields: List[AdditionalField], optional
|
|
156
|
+
Additional computed fields to add to the model schema
|
|
157
|
+
filterable_fields: Optional[Union[Set[str], Literal["__all__"]]], optional
|
|
158
|
+
Fields that can be used in filter queries
|
|
159
|
+
searchable_fields: Optional[Union[Set[str], Literal["__all__"]]], optional
|
|
160
|
+
Fields that can be used in search queries
|
|
161
|
+
ordering_fields: Optional[Union[Set[str], Literal["__all__"]]], optional
|
|
162
|
+
Fields that can be used for ordering
|
|
163
|
+
fields: Optional[Optional[Union[Set[str], Literal["__all__"]]]]
|
|
164
|
+
Expose just a subset of the model fields
|
|
165
|
+
DEBUG: bool, default=False
|
|
166
|
+
Enable debug mode for this model
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
model: Type,
|
|
172
|
+
custom_querysets: Optional[Dict[str, Type[AbstractCustomQueryset]]] = None,
|
|
173
|
+
custom_querysets_user_scoped: Optional[Dict[str, bool]] = None,
|
|
174
|
+
permissions: Optional[List[Type[AbstractPermission]]] = None,
|
|
175
|
+
pre_hooks: Optional[List] = None,
|
|
176
|
+
post_hooks: Optional[List] = None,
|
|
177
|
+
additional_fields: Optional[List[AdditionalField]] = None,
|
|
178
|
+
filterable_fields: Optional[Union[Set[str], Literal["__all__"]]] = None,
|
|
179
|
+
searchable_fields: Optional[Union[Set[str], Literal["__all__"]]] = None,
|
|
180
|
+
ordering_fields: Optional[Union[Set[str], Literal["__all__"]]] = None,
|
|
181
|
+
fields: Optional[Union[Set[str], Literal["__all__"]]] = None,
|
|
182
|
+
DEBUG: bool = False,
|
|
183
|
+
):
|
|
184
|
+
self.model = model
|
|
185
|
+
self._custom_querysets = custom_querysets or {}
|
|
186
|
+
self._custom_querysets_user_scoped = custom_querysets_user_scoped or {}
|
|
187
|
+
self._permissions = permissions or []
|
|
188
|
+
self.pre_hooks = pre_hooks or []
|
|
189
|
+
self.post_hooks = post_hooks or []
|
|
190
|
+
self.additional_fields = additional_fields or []
|
|
191
|
+
self.filterable_fields = filterable_fields or set()
|
|
192
|
+
self.searchable_fields = searchable_fields or set()
|
|
193
|
+
self.ordering_fields = ordering_fields or set()
|
|
194
|
+
self.fields = fields or "__all__"
|
|
195
|
+
self.DEBUG = DEBUG or False
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def permissions(self):
|
|
199
|
+
"""Resolve permission class strings to actual classes on each access"""
|
|
200
|
+
resolved = []
|
|
201
|
+
for perm in self._permissions:
|
|
202
|
+
if isinstance(perm, str):
|
|
203
|
+
from django.utils.module_loading import import_string
|
|
204
|
+
try:
|
|
205
|
+
perm_class = import_string(perm)
|
|
206
|
+
resolved.append(perm_class)
|
|
207
|
+
except ImportError:
|
|
208
|
+
raise ImportError(f"Could not import permission class: {perm}")
|
|
209
|
+
else:
|
|
210
|
+
resolved.append(perm)
|
|
211
|
+
return resolved
|
|
212
|
+
|
|
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
|
+
|
|
245
|
+
class Registry:
|
|
246
|
+
"""
|
|
247
|
+
Global registry mapping models to their ModelConfig.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
_models_config: Dict[Type, ModelConfig] = {}
|
|
251
|
+
|
|
252
|
+
@classmethod
|
|
253
|
+
def register(cls, model: Type, config: ModelConfig) -> None:
|
|
254
|
+
if model in cls._models_config:
|
|
255
|
+
raise ValueError(f"Model {model.__name__} is already registered.")
|
|
256
|
+
cls._models_config[model] = config
|
|
257
|
+
|
|
258
|
+
@classmethod
|
|
259
|
+
def get_config(cls, model: Type) -> ModelConfig:
|
|
260
|
+
config = cls._models_config.get(model)
|
|
261
|
+
if not config:
|
|
262
|
+
raise ValueError(f"Model {model.__name__} is not registered.")
|
|
263
|
+
return config
|