GeneralManager 0.16.1__py3-none-any.whl → 0.18.0__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.
Potentially problematic release.
This version of GeneralManager might be problematic. Click here for more details.
- general_manager/__init__.py +11 -1
- general_manager/_types/api.py +0 -1
- general_manager/_types/bucket.py +0 -1
- general_manager/_types/cache.py +0 -1
- general_manager/_types/factory.py +0 -1
- general_manager/_types/general_manager.py +0 -1
- general_manager/_types/interface.py +0 -1
- general_manager/_types/manager.py +0 -1
- general_manager/_types/measurement.py +0 -1
- general_manager/_types/permission.py +0 -1
- general_manager/_types/rule.py +0 -1
- general_manager/_types/utils.py +0 -1
- general_manager/api/__init__.py +13 -1
- general_manager/api/graphql.py +897 -147
- general_manager/api/graphql_subscription_consumer.py +432 -0
- general_manager/api/mutation.py +85 -23
- general_manager/api/property.py +39 -13
- general_manager/apps.py +336 -40
- general_manager/bucket/__init__.py +10 -1
- general_manager/bucket/calculationBucket.py +155 -53
- general_manager/bucket/databaseBucket.py +157 -45
- general_manager/bucket/groupBucket.py +133 -44
- general_manager/cache/__init__.py +10 -1
- general_manager/cache/dependencyIndex.py +303 -53
- general_manager/cache/signals.py +9 -2
- general_manager/factory/__init__.py +10 -1
- general_manager/factory/autoFactory.py +55 -13
- general_manager/factory/factories.py +110 -40
- general_manager/factory/factoryMethods.py +122 -34
- general_manager/interface/__init__.py +10 -1
- general_manager/interface/baseInterface.py +129 -36
- general_manager/interface/calculationInterface.py +35 -18
- general_manager/interface/databaseBasedInterface.py +71 -45
- general_manager/interface/databaseInterface.py +96 -38
- general_manager/interface/models.py +5 -5
- general_manager/interface/readOnlyInterface.py +94 -20
- general_manager/manager/__init__.py +10 -1
- general_manager/manager/generalManager.py +25 -16
- general_manager/manager/groupManager.py +21 -7
- general_manager/manager/meta.py +84 -16
- general_manager/measurement/__init__.py +10 -1
- general_manager/measurement/measurement.py +289 -95
- general_manager/measurement/measurementField.py +42 -31
- general_manager/permission/__init__.py +10 -1
- general_manager/permission/basePermission.py +120 -38
- general_manager/permission/managerBasedPermission.py +72 -21
- general_manager/permission/mutationPermission.py +14 -9
- general_manager/permission/permissionChecks.py +14 -12
- general_manager/permission/permissionDataManager.py +24 -11
- general_manager/permission/utils.py +34 -6
- general_manager/public_api_registry.py +36 -10
- general_manager/rule/__init__.py +10 -1
- general_manager/rule/handler.py +133 -44
- general_manager/rule/rule.py +178 -39
- general_manager/utils/__init__.py +10 -1
- general_manager/utils/argsToKwargs.py +34 -9
- general_manager/utils/filterParser.py +22 -7
- general_manager/utils/formatString.py +1 -0
- general_manager/utils/pathMapping.py +23 -15
- general_manager/utils/public_api.py +33 -2
- general_manager/utils/testing.py +49 -42
- {generalmanager-0.16.1.dist-info → generalmanager-0.18.0.dist-info}/METADATA +3 -1
- generalmanager-0.18.0.dist-info/RECORD +77 -0
- {generalmanager-0.16.1.dist-info → generalmanager-0.18.0.dist-info}/licenses/LICENSE +1 -1
- generalmanager-0.16.1.dist-info/RECORD +0 -76
- {generalmanager-0.16.1.dist-info → generalmanager-0.18.0.dist-info}/WHEEL +0 -0
- {generalmanager-0.16.1.dist-info → generalmanager-0.18.0.dist-info}/top_level.txt +0 -0
general_manager/api/property.py
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
"""GraphQL-aware property descriptor used by GeneralManager classes."""
|
|
2
2
|
|
|
3
|
-
from typing import Any, Callable, get_type_hints, overload, TypeVar
|
|
4
3
|
import sys
|
|
4
|
+
from typing import Any, Callable, TypeVar, get_type_hints, overload
|
|
5
5
|
|
|
6
6
|
T = TypeVar("T", bound=Callable[..., Any])
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
class GraphQLPropertyReturnAnnotationError(TypeError):
|
|
10
|
+
"""Raised when a GraphQLProperty is defined without a return type annotation."""
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
"""
|
|
14
|
+
Indicates a GraphQLProperty-decorated function is missing a return type annotation.
|
|
15
|
+
|
|
16
|
+
This exception is raised to signal that a property resolver intended for use with GraphQLProperty must have an explicit return type hint. The exception message is: "GraphQLProperty requires a return type hint for the property function."
|
|
17
|
+
"""
|
|
18
|
+
super().__init__(
|
|
19
|
+
"GraphQLProperty requires a return type hint for the property function."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
9
23
|
class GraphQLProperty(property):
|
|
10
24
|
"""Descriptor that exposes a property with GraphQL metadata and type hints."""
|
|
25
|
+
|
|
11
26
|
sortable: bool
|
|
12
27
|
filterable: bool
|
|
13
28
|
query_annotation: Any | None
|
|
@@ -22,14 +37,17 @@ class GraphQLProperty(property):
|
|
|
22
37
|
query_annotation: Any | None = None,
|
|
23
38
|
) -> None:
|
|
24
39
|
"""
|
|
25
|
-
|
|
40
|
+
Initialize the GraphQLProperty descriptor with GraphQL-specific metadata.
|
|
26
41
|
|
|
27
42
|
Parameters:
|
|
28
|
-
fget (Callable):
|
|
29
|
-
doc (str | None): Optional documentation string.
|
|
30
|
-
sortable (bool): Whether the property
|
|
31
|
-
filterable (bool): Whether the property
|
|
32
|
-
query_annotation (Any | None): Optional annotation
|
|
43
|
+
fget (Callable[..., Any]): The resolver function to wrap; its unwrapped form must include a return type annotation.
|
|
44
|
+
doc (str | None): Optional documentation string exposed on the descriptor.
|
|
45
|
+
sortable (bool): Whether the property should be considered for sorting.
|
|
46
|
+
filterable (bool): Whether the property should be considered for filtering.
|
|
47
|
+
query_annotation (Any | None): Optional annotation to apply when querying/queryset construction.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
GraphQLPropertyReturnAnnotationError: If the underlying resolver function does not declare a return type annotation.
|
|
33
51
|
"""
|
|
34
52
|
super().__init__(fget, doc=doc)
|
|
35
53
|
self.is_graphql_resolver = True
|
|
@@ -46,17 +64,25 @@ class GraphQLProperty(property):
|
|
|
46
64
|
) # falls decorator Annotations durchreicht
|
|
47
65
|
ann = getattr(orig, "__annotations__", {}) or {}
|
|
48
66
|
if "return" not in ann:
|
|
49
|
-
raise
|
|
50
|
-
"GraphQLProperty requires a return type hint for the property function."
|
|
51
|
-
)
|
|
67
|
+
raise GraphQLPropertyReturnAnnotationError()
|
|
52
68
|
|
|
53
69
|
def __set_name__(self, owner: type, name: str) -> None:
|
|
54
|
-
"""
|
|
70
|
+
"""
|
|
71
|
+
Record the owner class and attribute name for the descriptor to support later introspection.
|
|
72
|
+
|
|
73
|
+
Parameters:
|
|
74
|
+
owner (type): The class that owns this descriptor.
|
|
75
|
+
name (str): The attribute name under which this descriptor is assigned.
|
|
76
|
+
"""
|
|
55
77
|
self._owner = owner
|
|
56
78
|
self._name = name
|
|
57
79
|
|
|
58
80
|
def _try_resolve_type_hint(self) -> None:
|
|
59
|
-
"""
|
|
81
|
+
"""
|
|
82
|
+
Resolve and cache the wrapped resolver's return type hint.
|
|
83
|
+
|
|
84
|
+
When successful, stores the resolved return annotation on self._graphql_type_hint; if resolution fails or cannot be determined, sets self._graphql_type_hint to None.
|
|
85
|
+
"""
|
|
60
86
|
if self._graphql_type_hint is not None:
|
|
61
87
|
return
|
|
62
88
|
|
|
@@ -71,7 +97,7 @@ class GraphQLProperty(property):
|
|
|
71
97
|
|
|
72
98
|
hints = get_type_hints(self.fget, globalns=globalns, localns=localns)
|
|
73
99
|
self._graphql_type_hint = hints.get("return", None)
|
|
74
|
-
except
|
|
100
|
+
except (AttributeError, KeyError, NameError, TypeError, ValueError):
|
|
75
101
|
self._graphql_type_hint = None
|
|
76
102
|
|
|
77
103
|
@property
|
general_manager/apps.py
CHANGED
|
@@ -3,20 +3,45 @@ from django.apps import AppConfig
|
|
|
3
3
|
import graphene # type: ignore[import]
|
|
4
4
|
import os
|
|
5
5
|
from django.conf import settings
|
|
6
|
-
from django.urls import path
|
|
6
|
+
from django.urls import path, re_path
|
|
7
7
|
from graphene_django.views import GraphQLView # type: ignore[import]
|
|
8
|
-
from importlib import import_module
|
|
8
|
+
from importlib import import_module, util
|
|
9
|
+
import importlib.abc
|
|
10
|
+
import sys
|
|
9
11
|
from general_manager.manager.generalManager import GeneralManager
|
|
10
12
|
from general_manager.manager.meta import GeneralManagerMeta
|
|
11
13
|
from general_manager.manager.input import Input
|
|
12
14
|
from general_manager.api.property import graphQlProperty
|
|
13
15
|
from general_manager.api.graphql import GraphQL
|
|
14
|
-
from typing import TYPE_CHECKING, Type, cast
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Callable, Type, cast
|
|
15
17
|
from django.core.checks import register
|
|
16
18
|
import logging
|
|
17
19
|
from django.core.management.base import BaseCommand
|
|
18
20
|
|
|
19
21
|
|
|
22
|
+
class MissingRootUrlconfError(RuntimeError):
|
|
23
|
+
"""Raised when Django settings do not define ROOT_URLCONF."""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
"""
|
|
27
|
+
Initialize the MissingRootUrlconfError with a default message indicating ROOT_URLCONF is missing from Django settings.
|
|
28
|
+
"""
|
|
29
|
+
super().__init__("ROOT_URLCONF not found in settings.")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InvalidPermissionClassError(TypeError):
|
|
33
|
+
"""Raised when a GeneralManager Permission attribute is not a BasePermission subclass."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, permission_name: str) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Create an InvalidPermissionClassError indicating a permission is not a subclass of BasePermission.
|
|
38
|
+
|
|
39
|
+
Parameters:
|
|
40
|
+
permission_name (str): The name of the permission (typically the class or attribute name) that failed validation. The exception message will be "`{permission_name} must be a subclass of BasePermission.`"
|
|
41
|
+
"""
|
|
42
|
+
super().__init__(f"{permission_name} must be a subclass of BasePermission.")
|
|
43
|
+
|
|
44
|
+
|
|
20
45
|
if TYPE_CHECKING:
|
|
21
46
|
from general_manager.interface.readOnlyInterface import ReadOnlyInterface
|
|
22
47
|
|
|
@@ -46,24 +71,45 @@ class GeneralmanagerConfig(AppConfig):
|
|
|
46
71
|
read_only_classes: list[Type[GeneralManager]],
|
|
47
72
|
) -> None:
|
|
48
73
|
"""
|
|
49
|
-
|
|
74
|
+
Configure synchronization and register schema checks for read-only GeneralManager classes.
|
|
50
75
|
|
|
51
|
-
|
|
76
|
+
Parameters:
|
|
77
|
+
read_only_classes (list[Type[GeneralManager]]): GeneralManager subclasses whose Interface implements a ReadOnlyInterface; each class will have its read-only data synchronized before management commands and a Django system check registered to verify the Interface schema is up to date.
|
|
52
78
|
"""
|
|
53
79
|
GeneralmanagerConfig.patchReadOnlyInterfaceSync(read_only_classes)
|
|
54
80
|
from general_manager.interface.readOnlyInterface import ReadOnlyInterface
|
|
55
81
|
|
|
56
82
|
logger.debug("starting to register ReadOnlyInterface schema warnings...")
|
|
83
|
+
|
|
84
|
+
def _build_schema_check(
|
|
85
|
+
manager_cls: Type[GeneralManager], model: Any
|
|
86
|
+
) -> Callable[..., list[Any]]:
|
|
87
|
+
"""
|
|
88
|
+
Builds a Django system check callable that verifies the read-only interface schema for a manager against a model is current.
|
|
89
|
+
|
|
90
|
+
Parameters:
|
|
91
|
+
manager_cls (Type[GeneralManager]): The GeneralManager class whose ReadOnlyInterface schema will be validated.
|
|
92
|
+
model (Any): The model (or model-like descriptor) to check the schema against.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Callable[..., list[Any]]: A callable suitable for Django's system checks framework that returns a list of check messages.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def schema_check(*_: Any, **__: Any) -> list[Any]:
|
|
99
|
+
return ReadOnlyInterface.ensureSchemaIsUpToDate(manager_cls, model)
|
|
100
|
+
|
|
101
|
+
return schema_check
|
|
102
|
+
|
|
57
103
|
for general_manager_class in read_only_classes:
|
|
58
104
|
read_only_interface = cast(
|
|
59
105
|
Type[ReadOnlyInterface], general_manager_class.Interface
|
|
60
106
|
)
|
|
61
107
|
|
|
62
|
-
register(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
108
|
+
register("general_manager")(
|
|
109
|
+
_build_schema_check(
|
|
110
|
+
general_manager_class,
|
|
111
|
+
read_only_interface._model,
|
|
112
|
+
)
|
|
67
113
|
)
|
|
68
114
|
|
|
69
115
|
@staticmethod
|
|
@@ -71,9 +117,21 @@ class GeneralmanagerConfig(AppConfig):
|
|
|
71
117
|
general_manager_classes: list[Type[GeneralManager]],
|
|
72
118
|
) -> None:
|
|
73
119
|
"""
|
|
74
|
-
|
|
120
|
+
Ensure the provided GeneralManager classes' ReadOnlyInterfaces synchronize their data before any Django management command is executed.
|
|
121
|
+
|
|
122
|
+
For each class in `general_manager_classes`, calls the class's `Interface.syncData()` to keep read-only data consistent. Skips synchronization when running the autoreload subprocess of `runserver`.
|
|
123
|
+
Parameters:
|
|
124
|
+
general_manager_classes (list[Type[GeneralManager]]): GeneralManager subclasses whose `Interface` implements `syncData`.
|
|
125
|
+
"""
|
|
126
|
+
"""
|
|
127
|
+
Wrap BaseCommand.run_from_argv to call `syncData()` on registered ReadOnlyInterfaces before executing the original command.
|
|
75
128
|
|
|
76
|
-
|
|
129
|
+
Skips synchronization when the command is `runserver` and the process is the autoreload subprocess.
|
|
130
|
+
Parameters:
|
|
131
|
+
self (BaseCommand): The management command instance.
|
|
132
|
+
argv (list[str]): Command-line arguments for the management command.
|
|
133
|
+
Returns:
|
|
134
|
+
The result returned by the original `BaseCommand.run_from_argv` call.
|
|
77
135
|
"""
|
|
78
136
|
from general_manager.interface.readOnlyInterface import ReadOnlyInterface
|
|
79
137
|
|
|
@@ -85,13 +143,13 @@ class GeneralmanagerConfig(AppConfig):
|
|
|
85
143
|
) -> None:
|
|
86
144
|
# Ensure syncData is only called at real run of runserver
|
|
87
145
|
"""
|
|
88
|
-
|
|
146
|
+
Synchronizes all registered ReadOnlyInterface data before running a Django management command, except when running the autoreload subprocess of `runserver`.
|
|
89
147
|
|
|
90
148
|
Parameters:
|
|
91
|
-
argv (list):
|
|
149
|
+
argv (list[str]): The management command `argv`, including the program name and command.
|
|
92
150
|
|
|
93
151
|
Returns:
|
|
94
|
-
The
|
|
152
|
+
The value returned by the original `BaseCommand.run_from_argv` invocation.
|
|
95
153
|
"""
|
|
96
154
|
run_main = os.environ.get("RUN_MAIN") == "true"
|
|
97
155
|
command = argv[1] if len(argv) > 1 else None
|
|
@@ -108,7 +166,7 @@ class GeneralmanagerConfig(AppConfig):
|
|
|
108
166
|
result = original_run_from_argv(self, argv)
|
|
109
167
|
return result
|
|
110
168
|
|
|
111
|
-
|
|
169
|
+
BaseCommand.run_from_argv = run_from_argv_with_sync # type: ignore[assignment]
|
|
112
170
|
|
|
113
171
|
@staticmethod
|
|
114
172
|
def initializeGeneralManagerClasses(
|
|
@@ -116,16 +174,40 @@ class GeneralmanagerConfig(AppConfig):
|
|
|
116
174
|
all_classes: list[Type[GeneralManager]],
|
|
117
175
|
) -> None:
|
|
118
176
|
"""
|
|
119
|
-
|
|
177
|
+
Initialize GeneralManager classes' interface attributes, create attribute-based accessors, wire GraphQL connection properties between related managers, and validate each class's permission configuration.
|
|
120
178
|
|
|
121
|
-
For each class
|
|
179
|
+
For each class in `pending_attribute_initialization` this assigns the class's Interface attributes to its internal `_attributes` and creates property accessors for those attributes. For each class in `all_classes` this scans its Interface `input_fields` for inputs whose type is another GeneralManager subclass and adds a GraphQL property on the connected manager that resolves related objects filtered by the input attribute. Finally, validate and normalize the Permission attribute on every class via GeneralmanagerConfig.checkPermissionClass.
|
|
180
|
+
|
|
181
|
+
Parameters:
|
|
182
|
+
pending_attribute_initialization (list[type[GeneralManager]]): GeneralManager classes whose Interface attributes need to be initialized and whose attribute properties should be created.
|
|
183
|
+
all_classes (list[type[GeneralManager]]): All registered GeneralManager classes to inspect for input-field connections and to validate permissions.
|
|
122
184
|
"""
|
|
123
185
|
logger.debug("Initializing GeneralManager classes...")
|
|
124
186
|
|
|
187
|
+
def _build_connection_resolver(
|
|
188
|
+
attribute_key: str, manager_cls: Type[GeneralManager]
|
|
189
|
+
) -> Callable[[object], Any]:
|
|
190
|
+
"""
|
|
191
|
+
Create a resolver that queries the given GeneralManager class by matching an attribute to a provided value.
|
|
192
|
+
|
|
193
|
+
Parameters:
|
|
194
|
+
attribute_key (str): Name of the attribute to filter on.
|
|
195
|
+
manager_cls (Type[GeneralManager]): GeneralManager subclass to query.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Callable[[object], Any]: A callable that accepts a value and returns the result of filtering `manager_cls` where `attribute_key` equals that value.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
def resolver(value: object) -> Any:
|
|
202
|
+
return manager_cls.filter(**{attribute_key: value})
|
|
203
|
+
|
|
204
|
+
resolver.__annotations__ = {"return": manager_cls}
|
|
205
|
+
return resolver
|
|
206
|
+
|
|
125
207
|
logger.debug("starting to create attributes for GeneralManager classes...")
|
|
126
208
|
for general_manager_class in pending_attribute_initialization:
|
|
127
209
|
attributes = general_manager_class.Interface.getAttributes()
|
|
128
|
-
|
|
210
|
+
general_manager_class._attributes = attributes
|
|
129
211
|
GeneralManagerMeta.createAtPropertiesForAttributes(
|
|
130
212
|
attributes.keys(), general_manager_class
|
|
131
213
|
)
|
|
@@ -138,15 +220,13 @@ class GeneralmanagerConfig(AppConfig):
|
|
|
138
220
|
attribute.type, GeneralManager
|
|
139
221
|
):
|
|
140
222
|
connected_manager = attribute.type
|
|
141
|
-
|
|
142
|
-
|
|
223
|
+
resolver = _build_connection_resolver(
|
|
224
|
+
attribute_name, general_manager_class
|
|
143
225
|
)
|
|
144
|
-
|
|
145
|
-
func.__annotations__ = {"return": general_manager_class}
|
|
146
226
|
setattr(
|
|
147
227
|
connected_manager,
|
|
148
228
|
f"{general_manager_class.__name__.lower()}_list",
|
|
149
|
-
graphQlProperty(
|
|
229
|
+
graphQlProperty(resolver),
|
|
150
230
|
)
|
|
151
231
|
for general_manager_class in all_classes:
|
|
152
232
|
GeneralmanagerConfig.checkPermissionClass(general_manager_class)
|
|
@@ -156,7 +236,10 @@ class GeneralmanagerConfig(AppConfig):
|
|
|
156
236
|
pending_graphql_interfaces: list[Type[GeneralManager]],
|
|
157
237
|
) -> None:
|
|
158
238
|
"""
|
|
159
|
-
|
|
239
|
+
Generate GraphQL types, assemble the GraphQL schema for the given manager classes, and expose the GraphQL endpoint in the project's URL configuration.
|
|
240
|
+
|
|
241
|
+
Parameters:
|
|
242
|
+
pending_graphql_interfaces (list[Type[GeneralManager]]): GeneralManager classes for which GraphQL interfaces, mutations, and optional subscriptions should be created and included in the assembled schema.
|
|
160
243
|
"""
|
|
161
244
|
logger.debug("Starting to create GraphQL interfaces and mutations...")
|
|
162
245
|
for general_manager_class in pending_graphql_interfaces:
|
|
@@ -176,31 +259,44 @@ class GeneralmanagerConfig(AppConfig):
|
|
|
176
259
|
},
|
|
177
260
|
)
|
|
178
261
|
GraphQL._mutation_class = mutation_class
|
|
179
|
-
schema = graphene.Schema(
|
|
180
|
-
query=GraphQL._query_class,
|
|
181
|
-
mutation=mutation_class,
|
|
182
|
-
)
|
|
183
262
|
else:
|
|
184
263
|
GraphQL._mutation_class = None
|
|
185
|
-
|
|
264
|
+
|
|
265
|
+
if GraphQL._subscription_fields:
|
|
266
|
+
subscription_class = type(
|
|
267
|
+
"Subscription",
|
|
268
|
+
(graphene.ObjectType,),
|
|
269
|
+
GraphQL._subscription_fields,
|
|
270
|
+
)
|
|
271
|
+
GraphQL._subscription_class = subscription_class
|
|
272
|
+
else:
|
|
273
|
+
GraphQL._subscription_class = None
|
|
274
|
+
|
|
275
|
+
schema_kwargs: dict[str, Any] = {"query": GraphQL._query_class}
|
|
276
|
+
if GraphQL._mutation_class is not None:
|
|
277
|
+
schema_kwargs["mutation"] = GraphQL._mutation_class
|
|
278
|
+
if GraphQL._subscription_class is not None:
|
|
279
|
+
schema_kwargs["subscription"] = GraphQL._subscription_class
|
|
280
|
+
schema = graphene.Schema(**schema_kwargs)
|
|
281
|
+
GraphQL._schema = schema
|
|
186
282
|
GeneralmanagerConfig.addGraphqlUrl(schema)
|
|
187
283
|
|
|
188
284
|
@staticmethod
|
|
189
285
|
def addGraphqlUrl(schema: graphene.Schema) -> None:
|
|
190
286
|
"""
|
|
191
|
-
|
|
287
|
+
Add a GraphQL endpoint to the project's URL configuration and ensure the ASGI subscription route is configured.
|
|
192
288
|
|
|
193
289
|
Parameters:
|
|
194
|
-
schema:
|
|
290
|
+
schema (graphene.Schema): GraphQL schema to serve at the configured GRAPHQL_URL.
|
|
195
291
|
|
|
196
292
|
Raises:
|
|
197
|
-
|
|
293
|
+
MissingRootUrlconfError: If ROOT_URLCONF is not defined in Django settings.
|
|
198
294
|
"""
|
|
199
295
|
logging.debug("Adding GraphQL URL to Django settings...")
|
|
200
296
|
root_url_conf_path = getattr(settings, "ROOT_URLCONF", None)
|
|
201
297
|
graph_ql_url = getattr(settings, "GRAPHQL_URL", "graphql")
|
|
202
298
|
if not root_url_conf_path:
|
|
203
|
-
raise
|
|
299
|
+
raise MissingRootUrlconfError()
|
|
204
300
|
urlconf = import_module(root_url_conf_path)
|
|
205
301
|
urlconf.urlpatterns.append(
|
|
206
302
|
path(
|
|
@@ -208,12 +304,211 @@ class GeneralmanagerConfig(AppConfig):
|
|
|
208
304
|
GraphQLView.as_view(graphiql=True, schema=schema),
|
|
209
305
|
)
|
|
210
306
|
)
|
|
307
|
+
GeneralmanagerConfig._ensure_asgi_subscription_route(graph_ql_url)
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _ensure_asgi_subscription_route(graphql_url: str) -> None:
|
|
311
|
+
"""
|
|
312
|
+
Ensure GraphQL websocket subscription route is integrated into the project's ASGI application when ASGI is configured.
|
|
313
|
+
|
|
314
|
+
If settings.ASGI_APPLICATION is absent or invalid, the function logs and returns without making changes. When the ASGI application module can be imported, delegates to GeneralmanagerConfig._finalize_asgi_module to add or wire WebSocket routes for GraphQL subscriptions at the given URL. If the ASGI module cannot be imported during Django's application population phase, the function arranges a deferred finalization so the ASGI module will be finalized once it is loaded; other import failures are logged and cause a no-op return.
|
|
315
|
+
Parameters:
|
|
316
|
+
graphql_url (str): URL path at which the GraphQL websocket subscription endpoint should be exposed (e.g., "/graphql/").
|
|
317
|
+
"""
|
|
318
|
+
asgi_path = getattr(settings, "ASGI_APPLICATION", None)
|
|
319
|
+
if not asgi_path:
|
|
320
|
+
logger.debug("ASGI_APPLICATION not configured; skipping websocket setup.")
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
module_path, attr_name = asgi_path.rsplit(".", 1)
|
|
325
|
+
except ValueError:
|
|
326
|
+
logger.warning(
|
|
327
|
+
"ASGI_APPLICATION '%s' is not a valid module path; skipping websocket setup.",
|
|
328
|
+
asgi_path,
|
|
329
|
+
)
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
asgi_module = import_module(module_path)
|
|
334
|
+
except RuntimeError as exc:
|
|
335
|
+
if "populate() isn't reentrant" not in str(exc):
|
|
336
|
+
logger.warning(
|
|
337
|
+
"Unable to import ASGI module '%s': %s",
|
|
338
|
+
module_path,
|
|
339
|
+
exc,
|
|
340
|
+
exc_info=True,
|
|
341
|
+
)
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
spec = util.find_spec(module_path)
|
|
345
|
+
if spec is None or spec.loader is None:
|
|
346
|
+
logger.warning(
|
|
347
|
+
"Could not locate loader for ASGI module '%s'; skipping websocket setup.",
|
|
348
|
+
module_path,
|
|
349
|
+
)
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
def finalize(module: Any) -> None:
|
|
353
|
+
"""
|
|
354
|
+
Finalize integration of GraphQL subscription routing into the given ASGI module using the surrounding attribute name and GraphQL URL.
|
|
355
|
+
|
|
356
|
+
Ensure the ASGI module exposes websocket_urlpatterns and add a websocket route for the GraphQL subscription endpoint if missing; modify or wrap the module's application mapping or ASGI application as needed to include an authenticated websocket route for subscriptions.
|
|
357
|
+
|
|
358
|
+
Parameters:
|
|
359
|
+
module (Any): The imported ASGI module object to be modified in-place to support GraphQL websocket subscriptions.
|
|
360
|
+
"""
|
|
361
|
+
GeneralmanagerConfig._finalize_asgi_module(
|
|
362
|
+
module, attr_name, graphql_url
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
class _Loader(importlib.abc.Loader):
|
|
366
|
+
def __init__(self, original_loader: importlib.abc.Loader) -> None:
|
|
367
|
+
"""
|
|
368
|
+
Initialize the wrapper with the given original importlib loader.
|
|
369
|
+
|
|
370
|
+
Parameters:
|
|
371
|
+
original_loader (importlib.abc.Loader): The underlying loader that this wrapper will delegate to.
|
|
372
|
+
"""
|
|
373
|
+
self._original_loader = original_loader
|
|
374
|
+
|
|
375
|
+
def create_module(self, spec): # type: ignore[override]
|
|
376
|
+
if hasattr(self._original_loader, "create_module"):
|
|
377
|
+
return self._original_loader.create_module(spec) # type: ignore[attr-defined]
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
def exec_module(self, module): # type: ignore[override]
|
|
381
|
+
self._original_loader.exec_module(module)
|
|
382
|
+
finalize(module)
|
|
383
|
+
|
|
384
|
+
wrapped_loader = _Loader(spec.loader)
|
|
385
|
+
|
|
386
|
+
class _Finder(importlib.abc.MetaPathFinder):
|
|
387
|
+
def __init__(self) -> None:
|
|
388
|
+
self._processed = False
|
|
389
|
+
|
|
390
|
+
def find_spec(self, fullname, path, target=None): # type: ignore[override]
|
|
391
|
+
"""
|
|
392
|
+
Return a ModuleSpec for the wrapped loader the first time the specified module is requested.
|
|
393
|
+
|
|
394
|
+
If `fullname` matches the loader's target module name and this loader has not yet produced a spec, mark the loader as processed, create a new spec using the wrapped loader, copy `submodule_search_locations` from the original `spec` if present, and return the new spec; otherwise return `None`.
|
|
395
|
+
|
|
396
|
+
Parameters:
|
|
397
|
+
fullname (str): Fully-qualified name of the module being imported; matched against the loader's target module name.
|
|
398
|
+
path (Sequence[str] | None): Import path for package search (not used by this finder).
|
|
399
|
+
target (ModuleSpec | None): Optional existing spec suggested by the import machinery (ignored).
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
ModuleSpec | None: The created ModuleSpec when `fullname` matches and has not been processed, `None` otherwise.
|
|
403
|
+
"""
|
|
404
|
+
if fullname != module_path or self._processed:
|
|
405
|
+
return None
|
|
406
|
+
self._processed = True
|
|
407
|
+
new_spec = util.spec_from_loader(fullname, wrapped_loader)
|
|
408
|
+
if new_spec is not None:
|
|
409
|
+
new_spec.submodule_search_locations = (
|
|
410
|
+
spec.submodule_search_locations
|
|
411
|
+
)
|
|
412
|
+
return new_spec
|
|
413
|
+
|
|
414
|
+
finder = _Finder()
|
|
415
|
+
sys.meta_path.insert(0, finder)
|
|
416
|
+
return
|
|
417
|
+
except ImportError as exc: # pragma: no cover - defensive
|
|
418
|
+
logger.warning(
|
|
419
|
+
"Unable to import ASGI module '%s': %s", module_path, exc, exc_info=True
|
|
420
|
+
)
|
|
421
|
+
return
|
|
422
|
+
|
|
423
|
+
GeneralmanagerConfig._finalize_asgi_module(asgi_module, attr_name, graphql_url)
|
|
424
|
+
|
|
425
|
+
@staticmethod
|
|
426
|
+
def _finalize_asgi_module(
|
|
427
|
+
asgi_module: Any, attr_name: str, graphql_url: str
|
|
428
|
+
) -> None:
|
|
429
|
+
"""
|
|
430
|
+
Ensure the ASGI module exposes a websocket route for GraphQL subscriptions and integrate it into the ASGI application.
|
|
431
|
+
|
|
432
|
+
If Channels and the GraphQL subscription consumer are available, this function appends a websocket URL pattern for the GraphQL subscriptions to asgi_module.websocket_urlpatterns (creating the list if needed) and then wires that websocket route into the module's ASGI application. If the module exposes an application mapping (application.application_mapping is a dict) the websocket entry is added there; otherwise the existing application is wrapped in a ProtocolTypeRouter that routes websocket traffic through Channels' AuthMiddlewareStack. If optional dependencies are missing, or websocket_urlpatterns cannot be appended, the function returns without modifying the ASGI module.
|
|
433
|
+
|
|
434
|
+
Parameters:
|
|
435
|
+
asgi_module (module): The imported ASGI module object referenced by ASGI_APPLICATION in settings.
|
|
436
|
+
attr_name (str): Attribute name on asgi_module that holds the ASGI application (e.g., "application").
|
|
437
|
+
graphql_url (str): URL path (relative, may include leading/trailing slashes) at which the GraphQL HTTP endpoint is mounted; used to build the websocket route for subscriptions.
|
|
438
|
+
"""
|
|
439
|
+
try:
|
|
440
|
+
from channels.auth import AuthMiddlewareStack # type: ignore[import-untyped]
|
|
441
|
+
from channels.routing import ProtocolTypeRouter, URLRouter # type: ignore[import-untyped]
|
|
442
|
+
from general_manager.api.graphql_subscription_consumer import (
|
|
443
|
+
GraphQLSubscriptionConsumer,
|
|
444
|
+
)
|
|
445
|
+
except (
|
|
446
|
+
ImportError,
|
|
447
|
+
RuntimeError,
|
|
448
|
+
) as exc: # pragma: no cover - optional dependency
|
|
449
|
+
logger.debug(
|
|
450
|
+
"Channels or GraphQL subscription consumer unavailable (%s); skipping websocket setup.",
|
|
451
|
+
exc,
|
|
452
|
+
)
|
|
453
|
+
return
|
|
454
|
+
|
|
455
|
+
websocket_patterns = getattr(asgi_module, "websocket_urlpatterns", None)
|
|
456
|
+
if websocket_patterns is None:
|
|
457
|
+
websocket_patterns = []
|
|
458
|
+
asgi_module.websocket_urlpatterns = websocket_patterns
|
|
459
|
+
|
|
460
|
+
if not hasattr(websocket_patterns, "append"):
|
|
461
|
+
logger.warning(
|
|
462
|
+
"websocket_urlpatterns in '%s' does not support appending; skipping websocket setup.",
|
|
463
|
+
asgi_module.__name__,
|
|
464
|
+
)
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
normalized = graphql_url.strip("/")
|
|
468
|
+
pattern = rf"^{normalized}/?$" if normalized else r"^$"
|
|
469
|
+
|
|
470
|
+
route_exists = any(
|
|
471
|
+
getattr(route, "_general_manager_graphql_ws", False)
|
|
472
|
+
for route in websocket_patterns
|
|
473
|
+
)
|
|
474
|
+
if not route_exists:
|
|
475
|
+
websocket_route = re_path(pattern, GraphQLSubscriptionConsumer.as_asgi()) # type: ignore[arg-type]
|
|
476
|
+
websocket_route._general_manager_graphql_ws = True
|
|
477
|
+
websocket_patterns.append(websocket_route)
|
|
478
|
+
|
|
479
|
+
application = getattr(asgi_module, attr_name, None)
|
|
480
|
+
if application is None:
|
|
481
|
+
return
|
|
482
|
+
|
|
483
|
+
if hasattr(application, "application_mapping") and isinstance(
|
|
484
|
+
application.application_mapping, dict
|
|
485
|
+
):
|
|
486
|
+
application.application_mapping["websocket"] = AuthMiddlewareStack(
|
|
487
|
+
URLRouter(list(websocket_patterns))
|
|
488
|
+
)
|
|
489
|
+
else:
|
|
490
|
+
wrapped_application = ProtocolTypeRouter(
|
|
491
|
+
{
|
|
492
|
+
"http": application,
|
|
493
|
+
"websocket": AuthMiddlewareStack(
|
|
494
|
+
URLRouter(list(websocket_patterns))
|
|
495
|
+
),
|
|
496
|
+
}
|
|
497
|
+
)
|
|
498
|
+
setattr(asgi_module, attr_name, wrapped_application)
|
|
211
499
|
|
|
212
500
|
@staticmethod
|
|
213
501
|
def checkPermissionClass(general_manager_class: Type[GeneralManager]) -> None:
|
|
214
502
|
"""
|
|
215
|
-
|
|
216
|
-
|
|
503
|
+
Validate and normalize a GeneralManager class's Permission attribute.
|
|
504
|
+
|
|
505
|
+
If the class defines a Permission attribute, ensure it is a subclass of BasePermission and leave it assigned; if it is not a subclass, raise InvalidPermissionClassError. If the class does not define Permission, assign ManagerBasedPermission as the default.
|
|
506
|
+
|
|
507
|
+
Parameters:
|
|
508
|
+
general_manager_class (Type[GeneralManager]): GeneralManager subclass whose Permission attribute will be validated or initialized.
|
|
509
|
+
|
|
510
|
+
Raises:
|
|
511
|
+
InvalidPermissionClassError: If the existing Permission attribute is not a subclass of BasePermission.
|
|
217
512
|
"""
|
|
218
513
|
from general_manager.permission.basePermission import BasePermission
|
|
219
514
|
from general_manager.permission.managerBasedPermission import (
|
|
@@ -222,10 +517,11 @@ class GeneralmanagerConfig(AppConfig):
|
|
|
222
517
|
|
|
223
518
|
if hasattr(general_manager_class, "Permission"):
|
|
224
519
|
permission = general_manager_class.Permission
|
|
225
|
-
if not
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
)
|
|
520
|
+
if not (
|
|
521
|
+
isinstance(permission, type) and issubclass(permission, BasePermission)
|
|
522
|
+
):
|
|
523
|
+
permission_name = getattr(permission, "__name__", repr(permission))
|
|
524
|
+
raise InvalidPermissionClassError(permission_name)
|
|
229
525
|
general_manager_class.Permission = permission
|
|
230
526
|
else:
|
|
231
527
|
general_manager_class.Permission = ManagerBasedPermission
|
|
@@ -12,10 +12,19 @@ __all__ = list(BUCKET_EXPORTS)
|
|
|
12
12
|
_MODULE_MAP = BUCKET_EXPORTS
|
|
13
13
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
15
|
-
from general_manager._types.bucket import * # noqa:
|
|
15
|
+
from general_manager._types.bucket import * # noqa: F403
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def __getattr__(name: str) -> Any:
|
|
19
|
+
"""
|
|
20
|
+
Dynamically resolve and return a named bucket export from this module's public API.
|
|
21
|
+
|
|
22
|
+
Parameters:
|
|
23
|
+
name (str): The attribute name to resolve from the module's exports.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Any: The resolved export object for `name`.
|
|
27
|
+
"""
|
|
19
28
|
return resolve_export(
|
|
20
29
|
name,
|
|
21
30
|
module_all=__all__,
|