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.
Files changed (45) hide show
  1. statezero/__init__.py +0 -0
  2. statezero/adaptors/__init__.py +0 -0
  3. statezero/adaptors/django/__init__.py +0 -0
  4. statezero/adaptors/django/apps.py +97 -0
  5. statezero/adaptors/django/config.py +99 -0
  6. statezero/adaptors/django/context_manager.py +12 -0
  7. statezero/adaptors/django/event_emitters.py +78 -0
  8. statezero/adaptors/django/exception_handler.py +98 -0
  9. statezero/adaptors/django/extensions/__init__.py +0 -0
  10. statezero/adaptors/django/extensions/custom_field_serializers/__init__.py +0 -0
  11. statezero/adaptors/django/extensions/custom_field_serializers/file_fields.py +141 -0
  12. statezero/adaptors/django/extensions/custom_field_serializers/money_field.py +75 -0
  13. statezero/adaptors/django/f_handler.py +312 -0
  14. statezero/adaptors/django/helpers.py +153 -0
  15. statezero/adaptors/django/middleware.py +10 -0
  16. statezero/adaptors/django/migrations/0001_initial.py +33 -0
  17. statezero/adaptors/django/migrations/0002_delete_modelviewsubscription.py +16 -0
  18. statezero/adaptors/django/migrations/__init__.py +0 -0
  19. statezero/adaptors/django/orm.py +915 -0
  20. statezero/adaptors/django/permissions.py +252 -0
  21. statezero/adaptors/django/query_optimizer.py +772 -0
  22. statezero/adaptors/django/schemas.py +324 -0
  23. statezero/adaptors/django/search_providers/__init__.py +0 -0
  24. statezero/adaptors/django/search_providers/basic_search.py +24 -0
  25. statezero/adaptors/django/search_providers/postgres_search.py +51 -0
  26. statezero/adaptors/django/serializers.py +554 -0
  27. statezero/adaptors/django/urls.py +14 -0
  28. statezero/adaptors/django/views.py +336 -0
  29. statezero/core/__init__.py +34 -0
  30. statezero/core/ast_parser.py +821 -0
  31. statezero/core/ast_validator.py +266 -0
  32. statezero/core/classes.py +167 -0
  33. statezero/core/config.py +263 -0
  34. statezero/core/context_storage.py +4 -0
  35. statezero/core/event_bus.py +175 -0
  36. statezero/core/event_emitters.py +60 -0
  37. statezero/core/exceptions.py +106 -0
  38. statezero/core/interfaces.py +492 -0
  39. statezero/core/process_request.py +184 -0
  40. statezero/core/types.py +63 -0
  41. statezero-0.1.0b1.dist-info/METADATA +252 -0
  42. statezero-0.1.0b1.dist-info/RECORD +45 -0
  43. statezero-0.1.0b1.dist-info/WHEEL +5 -0
  44. statezero-0.1.0b1.dist-info/licenses/license.md +117 -0
  45. 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"
@@ -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