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/graphql.py
CHANGED
|
@@ -1,33 +1,53 @@
|
|
|
1
1
|
"""GraphQL schema utilities for exposing GeneralManager models via Graphene."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
import ast as py_ast
|
|
6
|
+
import asyncio
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from copy import deepcopy
|
|
11
|
+
from datetime import date, datetime
|
|
12
|
+
from decimal import Decimal
|
|
13
|
+
import hashlib
|
|
14
|
+
from types import UnionType
|
|
5
15
|
from typing import (
|
|
6
16
|
Any,
|
|
17
|
+
AsyncIterator,
|
|
7
18
|
Callable,
|
|
8
|
-
|
|
9
|
-
|
|
19
|
+
ClassVar,
|
|
20
|
+
Generator,
|
|
21
|
+
Iterable,
|
|
10
22
|
TYPE_CHECKING,
|
|
11
|
-
cast,
|
|
12
23
|
Type,
|
|
13
|
-
Generator,
|
|
14
24
|
Union,
|
|
25
|
+
cast,
|
|
26
|
+
get_args,
|
|
27
|
+
get_origin,
|
|
15
28
|
)
|
|
16
|
-
from types import UnionType
|
|
17
29
|
|
|
18
|
-
|
|
19
|
-
from datetime import date, datetime
|
|
20
|
-
import json
|
|
30
|
+
import graphene # type: ignore[import]
|
|
21
31
|
from graphql.language import ast
|
|
32
|
+
from graphql.language.ast import (
|
|
33
|
+
FieldNode,
|
|
34
|
+
FragmentSpreadNode,
|
|
35
|
+
InlineFragmentNode,
|
|
36
|
+
SelectionSetNode,
|
|
37
|
+
)
|
|
38
|
+
from asgiref.sync import async_to_sync
|
|
39
|
+
from channels.layers import BaseChannelLayer, get_channel_layer
|
|
22
40
|
|
|
23
|
-
from general_manager.
|
|
24
|
-
from general_manager.
|
|
25
|
-
from general_manager.
|
|
41
|
+
from general_manager.cache.cacheTracker import DependencyTracker
|
|
42
|
+
from general_manager.cache.dependencyIndex import Dependency
|
|
43
|
+
from general_manager.cache.signals import post_data_change
|
|
26
44
|
from general_manager.bucket.baseBucket import Bucket
|
|
27
45
|
from general_manager.interface.baseInterface import InterfaceBase
|
|
28
|
-
from
|
|
29
|
-
from
|
|
46
|
+
from general_manager.manager.generalManager import GeneralManager
|
|
47
|
+
from general_manager.measurement.measurement import Measurement
|
|
30
48
|
|
|
49
|
+
from django.core.exceptions import ValidationError
|
|
50
|
+
from django.db.models import NOT_PROVIDED
|
|
31
51
|
from graphql import GraphQLError
|
|
32
52
|
|
|
33
53
|
|
|
@@ -36,6 +56,95 @@ if TYPE_CHECKING:
|
|
|
36
56
|
from graphene import ResolveInfo as GraphQLResolveInfo
|
|
37
57
|
|
|
38
58
|
|
|
59
|
+
@dataclass(slots=True)
|
|
60
|
+
class SubscriptionEvent:
|
|
61
|
+
"""Payload delivered to GraphQL subscription resolvers."""
|
|
62
|
+
|
|
63
|
+
item: Any | None
|
|
64
|
+
action: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class InvalidMeasurementValueError(TypeError):
|
|
68
|
+
"""Raised when a scalar receives a value that is not a Measurement instance."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, value: object) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Initialize the error raised when a scalar value is not a Measurement instance.
|
|
73
|
+
|
|
74
|
+
Parameters:
|
|
75
|
+
value (object): The value that failed validation; its type name is included in the exception message.
|
|
76
|
+
"""
|
|
77
|
+
super().__init__(f"Expected Measurement, got {type(value).__name__}.")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class MissingChannelLayerError(RuntimeError):
|
|
81
|
+
"""Raised when GraphQL subscriptions run without a configured channel layer."""
|
|
82
|
+
|
|
83
|
+
def __init__(self) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Indicates that Django Channels channel layer is not configured for GraphQL subscriptions.
|
|
86
|
+
|
|
87
|
+
Raised when subscription functionality requires a configured channel layer (e.g., CHANNEL_LAYERS) but none is present.
|
|
88
|
+
"""
|
|
89
|
+
super().__init__(
|
|
90
|
+
"No channel layer configured. Configure CHANNEL_LAYERS to enable GraphQL subscriptions."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class UnsupportedGraphQLFieldTypeError(TypeError):
|
|
95
|
+
"""Raised when attempting to map an unsupported Python type to GraphQL."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, field_type: type) -> None:
|
|
98
|
+
"""
|
|
99
|
+
Exception raised when a Python `dict` type is encountered while mapping a field to GraphQL, which is not supported.
|
|
100
|
+
|
|
101
|
+
Parameters:
|
|
102
|
+
field_type (type): The offending Python type that was provided for the field. The exception message includes this type's `__name__`.
|
|
103
|
+
"""
|
|
104
|
+
super().__init__(
|
|
105
|
+
f"GraphQL does not support dict fields (received {field_type.__name__})."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class InvalidGeneralManagerClassError(TypeError):
|
|
110
|
+
"""Raised when a non-GeneralManager subclass is used in the GraphQL registry."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, received_class: type) -> None:
|
|
113
|
+
"""
|
|
114
|
+
Initialize the exception indicating the provided class is not a GeneralManager subclass.
|
|
115
|
+
|
|
116
|
+
Parameters:
|
|
117
|
+
received_class (type): The class that was supplied but is not a subclass of GeneralManager.
|
|
118
|
+
"""
|
|
119
|
+
super().__init__(
|
|
120
|
+
f"{received_class.__name__} must be a subclass of GeneralManager to create a GraphQL interface."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class MissingManagerIdentifierError(ValueError):
|
|
125
|
+
"""Raised when a GraphQL mutation is missing the required manager identifier."""
|
|
126
|
+
|
|
127
|
+
def __init__(self) -> None:
|
|
128
|
+
"""
|
|
129
|
+
Initialize the exception indicating a required manager identifier is missing.
|
|
130
|
+
|
|
131
|
+
This exception instance carries the default message "id is required.".
|
|
132
|
+
"""
|
|
133
|
+
super().__init__("id is required.")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
HANDLED_MANAGER_ERRORS: tuple[type[Exception], ...] = (
|
|
137
|
+
PermissionError,
|
|
138
|
+
ValidationError,
|
|
139
|
+
ValueError,
|
|
140
|
+
LookupError,
|
|
141
|
+
TypeError,
|
|
142
|
+
AttributeError,
|
|
143
|
+
GraphQLError,
|
|
144
|
+
RuntimeError,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
39
148
|
class MeasurementType(graphene.ObjectType):
|
|
40
149
|
value = graphene.Float()
|
|
41
150
|
unit = graphene.String()
|
|
@@ -48,8 +157,20 @@ class MeasurementScalar(graphene.Scalar):
|
|
|
48
157
|
|
|
49
158
|
@staticmethod
|
|
50
159
|
def serialize(value: Measurement) -> str:
|
|
160
|
+
"""
|
|
161
|
+
Convert a Measurement to its string representation.
|
|
162
|
+
|
|
163
|
+
Parameters:
|
|
164
|
+
value (Measurement): Measurement to serialize.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
str: String representation of the given Measurement.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
InvalidMeasurementValueError: If `value` is not a Measurement instance.
|
|
171
|
+
"""
|
|
51
172
|
if not isinstance(value, Measurement):
|
|
52
|
-
raise
|
|
173
|
+
raise InvalidMeasurementValueError(value)
|
|
53
174
|
return str(value)
|
|
54
175
|
|
|
55
176
|
@staticmethod
|
|
@@ -71,18 +192,18 @@ class PageInfo(graphene.ObjectType):
|
|
|
71
192
|
|
|
72
193
|
|
|
73
194
|
def getReadPermissionFilter(
|
|
74
|
-
generalManagerClass:
|
|
195
|
+
generalManagerClass: Type[GeneralManager],
|
|
75
196
|
info: GraphQLResolveInfo,
|
|
76
197
|
) -> list[tuple[dict[str, Any], dict[str, Any]]]:
|
|
77
198
|
"""
|
|
78
|
-
|
|
199
|
+
Produce a list of permission-derived filter and exclude mappings for queries against a manager class.
|
|
79
200
|
|
|
80
201
|
Parameters:
|
|
81
|
-
generalManagerClass (
|
|
82
|
-
info (GraphQLResolveInfo): GraphQL resolver info
|
|
202
|
+
generalManagerClass (Type[GeneralManager]): Manager class to derive permission filters for.
|
|
203
|
+
info (GraphQLResolveInfo): GraphQL resolver info whose context provides the current user.
|
|
83
204
|
|
|
84
205
|
Returns:
|
|
85
|
-
list[tuple[dict[str, Any], dict[str, Any]]]:
|
|
206
|
+
list[tuple[dict[str, Any], dict[str, Any]]]: A list of `(filter, exclude)` tuples where each `filter` and `exclude` is a mapping of query constraints produced by the manager's Permission class.
|
|
86
207
|
"""
|
|
87
208
|
filters = []
|
|
88
209
|
PermissionClass: type[BasePermission] | None = getattr(
|
|
@@ -102,21 +223,108 @@ def getReadPermissionFilter(
|
|
|
102
223
|
class GraphQL:
|
|
103
224
|
"""Static helper that builds GraphQL types, queries, and mutations for managers."""
|
|
104
225
|
|
|
105
|
-
_query_class: type[graphene.ObjectType] | None = None
|
|
106
|
-
_mutation_class: type[graphene.ObjectType] | None = None
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
226
|
+
_query_class: ClassVar[type[graphene.ObjectType] | None] = None
|
|
227
|
+
_mutation_class: ClassVar[type[graphene.ObjectType] | None] = None
|
|
228
|
+
_subscription_class: ClassVar[type[graphene.ObjectType] | None] = None
|
|
229
|
+
_mutations: ClassVar[dict[str, Any]] = {}
|
|
230
|
+
_query_fields: ClassVar[dict[str, Any]] = {}
|
|
231
|
+
_subscription_fields: ClassVar[dict[str, Any]] = {}
|
|
232
|
+
_page_type_registry: ClassVar[dict[str, type[graphene.ObjectType]]] = {}
|
|
233
|
+
_subscription_payload_registry: ClassVar[dict[str, type[graphene.ObjectType]]] = {}
|
|
234
|
+
graphql_type_registry: ClassVar[dict[str, type]] = {}
|
|
235
|
+
graphql_filter_type_registry: ClassVar[dict[str, type]] = {}
|
|
236
|
+
manager_registry: ClassVar[dict[str, type[GeneralManager]]] = {}
|
|
237
|
+
_schema: ClassVar[graphene.Schema | None] = None
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def _get_channel_layer(strict: bool = False) -> BaseChannelLayer | None:
|
|
241
|
+
"""
|
|
242
|
+
Retrieve the configured channel layer for GraphQL subscriptions.
|
|
243
|
+
|
|
244
|
+
Parameters:
|
|
245
|
+
strict (bool): When True, raise MissingChannelLayerError if no channel layer is configured.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
BaseChannelLayer | None: The configured channel layer instance if available, otherwise None.
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
MissingChannelLayerError: If `strict` is True and no channel layer is configured.
|
|
252
|
+
"""
|
|
253
|
+
layer = cast(BaseChannelLayer | None, get_channel_layer())
|
|
254
|
+
if layer is None and strict:
|
|
255
|
+
raise MissingChannelLayerError()
|
|
256
|
+
return layer
|
|
257
|
+
|
|
258
|
+
@classmethod
|
|
259
|
+
def get_schema(cls) -> graphene.Schema | None:
|
|
260
|
+
"""
|
|
261
|
+
Get the currently configured Graphene schema for the GraphQL registry.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
The active `graphene.Schema` instance, or `None` if no schema has been created.
|
|
265
|
+
"""
|
|
266
|
+
return cls._schema
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def _group_name(
|
|
270
|
+
manager_class: type[GeneralManager], identification: dict[str, Any]
|
|
271
|
+
) -> str:
|
|
272
|
+
"""
|
|
273
|
+
Builds a deterministic channel group name for subscription events for a specific manager instance.
|
|
274
|
+
|
|
275
|
+
Parameters:
|
|
276
|
+
manager_class (type[GeneralManager]): GeneralManager subclass used to namespace the group.
|
|
277
|
+
identification (dict[str, Any]): Identifying fields for the manager instance; the mapping is JSON-normalized (sorted keys) before being incorporated.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
group_name (str): A deterministic channel group identifier derived from the manager class and normalized identification.
|
|
281
|
+
"""
|
|
282
|
+
normalized = json.dumps(identification, sort_keys=True, default=str)
|
|
283
|
+
digest = hashlib.sha256(
|
|
284
|
+
f"{manager_class.__module__}.{manager_class.__name__}:{normalized}".encode(
|
|
285
|
+
"utf-8"
|
|
286
|
+
)
|
|
287
|
+
).hexdigest()[:32]
|
|
288
|
+
return f"gm_subscriptions.{manager_class.__name__}.{digest}"
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
async def _channel_listener(
|
|
292
|
+
channel_layer: BaseChannelLayer,
|
|
293
|
+
channel_name: str,
|
|
294
|
+
queue: asyncio.Queue[str],
|
|
295
|
+
) -> None:
|
|
296
|
+
"""
|
|
297
|
+
Listen to a channel layer for "gm.subscription.event" messages and enqueue their `action` values.
|
|
298
|
+
|
|
299
|
+
Continuously receives messages from the given channel_name on the channel_layer, and when a message of type "gm.subscription.event" contains an `action`, that action string is put into the provided asyncio queue. Ignores messages of other types. The loop exits silently when the task is cancelled.
|
|
300
|
+
|
|
301
|
+
Parameters:
|
|
302
|
+
channel_layer (BaseChannelLayer): Channel layer to receive messages from.
|
|
303
|
+
channel_name (str): Name of the channel to listen on.
|
|
304
|
+
queue (asyncio.Queue[str]): Async queue to which received action strings will be enqueued.
|
|
305
|
+
"""
|
|
306
|
+
try:
|
|
307
|
+
while True:
|
|
308
|
+
message = cast(
|
|
309
|
+
dict[str, Any], await channel_layer.receive(channel_name)
|
|
310
|
+
)
|
|
311
|
+
if message.get("type") != "gm.subscription.event":
|
|
312
|
+
continue
|
|
313
|
+
action = cast(str | None, message.get("action"))
|
|
314
|
+
if action is not None:
|
|
315
|
+
await queue.put(action)
|
|
316
|
+
except asyncio.CancelledError:
|
|
317
|
+
pass
|
|
112
318
|
|
|
113
319
|
@classmethod
|
|
114
320
|
def createGraphqlMutation(cls, generalManagerClass: type[GeneralManager]) -> None:
|
|
115
321
|
"""
|
|
116
|
-
Register
|
|
322
|
+
Register GraphQL mutation classes for a GeneralManager based on its Interface.
|
|
323
|
+
|
|
324
|
+
If the manager's Interface overrides the default `create`, `update`, or `deactivate` methods, corresponding mutation classes are generated and stored in the class-level mutation registry under the names `create<ManagerName>`, `update<ManagerName>`, and `delete<ManagerName>`. Each generated mutation exposes a `success` flag and a field named for the manager class that returns the created/updated/deactivated object.
|
|
117
325
|
|
|
118
326
|
Parameters:
|
|
119
|
-
generalManagerClass (type[GeneralManager]): Manager class whose
|
|
327
|
+
generalManagerClass (type[GeneralManager]): Manager class whose `Interface` determines which mutations are generated and registered.
|
|
120
328
|
"""
|
|
121
329
|
|
|
122
330
|
interface_cls: InterfaceBase | None = getattr(
|
|
@@ -150,12 +358,14 @@ class GraphQL:
|
|
|
150
358
|
)
|
|
151
359
|
|
|
152
360
|
@classmethod
|
|
153
|
-
def createGraphqlInterface(cls, generalManagerClass:
|
|
361
|
+
def createGraphqlInterface(cls, generalManagerClass: Type[GeneralManager]) -> None:
|
|
154
362
|
"""
|
|
155
|
-
|
|
363
|
+
Create and register a Graphene ObjectType for a GeneralManager class and expose its queries and subscription.
|
|
364
|
+
|
|
365
|
+
Builds a Graphene type by mapping the manager's Interface attributes and GraphQLProperties to Graphene fields and resolvers, registers the resulting type and manager in the GraphQL registries, and adds corresponding query and subscription fields to the schema.
|
|
156
366
|
|
|
157
367
|
Parameters:
|
|
158
|
-
generalManagerClass (
|
|
368
|
+
generalManagerClass (Type[GeneralManager]): The manager class whose Interface and GraphQLProperties are used to generate Graphene fields and resolvers.
|
|
159
369
|
"""
|
|
160
370
|
interface_cls: InterfaceBase | None = getattr(
|
|
161
371
|
generalManagerClass, "Interface", None
|
|
@@ -216,20 +426,19 @@ class GraphQL:
|
|
|
216
426
|
|
|
217
427
|
graphene_type = type(graphene_type_name, (graphene.ObjectType,), fields)
|
|
218
428
|
cls.graphql_type_registry[generalManagerClass.__name__] = graphene_type
|
|
429
|
+
cls.manager_registry[generalManagerClass.__name__] = generalManagerClass
|
|
219
430
|
cls._addQueriesToSchema(graphene_type, generalManagerClass)
|
|
431
|
+
cls._addSubscriptionField(graphene_type, generalManagerClass)
|
|
220
432
|
|
|
221
433
|
@staticmethod
|
|
222
434
|
def _sortByOptions(
|
|
223
|
-
generalManagerClass:
|
|
435
|
+
generalManagerClass: Type[GeneralManager],
|
|
224
436
|
) -> type[graphene.Enum] | None:
|
|
225
437
|
"""
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
Parameters:
|
|
229
|
-
generalManagerClass (GeneralManagerMeta): Manager class being inspected.
|
|
438
|
+
Builds an enum of sortable field names for the given manager class.
|
|
230
439
|
|
|
231
440
|
Returns:
|
|
232
|
-
|
|
441
|
+
An Enum type whose members are the sortable field names for the manager, or `None` if no sortable fields exist.
|
|
233
442
|
"""
|
|
234
443
|
sort_options = []
|
|
235
444
|
for (
|
|
@@ -266,7 +475,9 @@ class GraphQL:
|
|
|
266
475
|
)
|
|
267
476
|
|
|
268
477
|
@staticmethod
|
|
269
|
-
def _getFilterOptions(
|
|
478
|
+
def _getFilterOptions(
|
|
479
|
+
attribute_type: type, attribute_name: str
|
|
480
|
+
) -> Generator[
|
|
270
481
|
tuple[
|
|
271
482
|
str, type[graphene.ObjectType] | MeasurementScalar | graphene.List | None
|
|
272
483
|
],
|
|
@@ -274,14 +485,18 @@ class GraphQL:
|
|
|
274
485
|
None,
|
|
275
486
|
]:
|
|
276
487
|
"""
|
|
277
|
-
|
|
488
|
+
Produce filter field names and corresponding Graphene input types for a given attribute.
|
|
278
489
|
|
|
279
490
|
Parameters:
|
|
280
|
-
attribute_type (type): Python type declared for the attribute.
|
|
281
|
-
attribute_name (str):
|
|
491
|
+
attribute_type (type): The Python type declared for the attribute.
|
|
492
|
+
attribute_name (str): The attribute's name used as the base for generated filter field names.
|
|
282
493
|
|
|
283
494
|
Yields:
|
|
284
|
-
tuple[str,
|
|
495
|
+
tuple[str, type | MeasurementScalar | graphene.List | None]:
|
|
496
|
+
A pair where the first element is a filter field name (e.g., "age", "price__gte",
|
|
497
|
+
"name__icontains") and the second element is the Graphene input type for that filter
|
|
498
|
+
or `None` when no Graphene input type should be exposed (for example, nested
|
|
499
|
+
GeneralManager references).
|
|
285
500
|
"""
|
|
286
501
|
number_options = ["exact", "gt", "gte", "lt", "lte"]
|
|
287
502
|
string_options = [
|
|
@@ -300,13 +515,19 @@ class GraphQL:
|
|
|
300
515
|
for option in number_options:
|
|
301
516
|
yield f"{attribute_name}__{option}", MeasurementScalar()
|
|
302
517
|
else:
|
|
303
|
-
yield
|
|
304
|
-
|
|
518
|
+
yield (
|
|
519
|
+
attribute_name,
|
|
520
|
+
GraphQL._mapFieldToGrapheneRead(attribute_type, attribute_name),
|
|
305
521
|
)
|
|
306
522
|
if issubclass(attribute_type, (int, float, Decimal, date, datetime)):
|
|
307
523
|
for option in number_options:
|
|
308
|
-
yield
|
|
309
|
-
|
|
524
|
+
yield (
|
|
525
|
+
f"{attribute_name}__{option}",
|
|
526
|
+
(
|
|
527
|
+
GraphQL._mapFieldToGrapheneRead(
|
|
528
|
+
attribute_type, attribute_name
|
|
529
|
+
)
|
|
530
|
+
),
|
|
310
531
|
)
|
|
311
532
|
elif issubclass(attribute_type, str):
|
|
312
533
|
base_type = GraphQL._mapFieldToGrapheneBaseType(attribute_type)
|
|
@@ -314,24 +535,29 @@ class GraphQL:
|
|
|
314
535
|
if option == "in":
|
|
315
536
|
yield f"{attribute_name}__in", graphene.List(base_type)
|
|
316
537
|
else:
|
|
317
|
-
yield
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
538
|
+
yield (
|
|
539
|
+
f"{attribute_name}__{option}",
|
|
540
|
+
(
|
|
541
|
+
GraphQL._mapFieldToGrapheneRead(
|
|
542
|
+
attribute_type, attribute_name
|
|
543
|
+
)
|
|
544
|
+
),
|
|
321
545
|
)
|
|
322
546
|
|
|
323
547
|
@staticmethod
|
|
324
548
|
def _createFilterOptions(
|
|
325
|
-
field_type:
|
|
549
|
+
field_type: Type[GeneralManager],
|
|
326
550
|
) -> type[graphene.InputObjectType] | None:
|
|
327
551
|
"""
|
|
328
|
-
Create a Graphene
|
|
552
|
+
Create a Graphene InputObjectType that exposes available filter fields for a GeneralManager subclass.
|
|
553
|
+
|
|
554
|
+
Builds filter fields from the manager's Interface attributes and any GraphQLProperty marked filterable. Returns None when no filterable fields are found.
|
|
329
555
|
|
|
330
556
|
Parameters:
|
|
331
|
-
field_type (
|
|
557
|
+
field_type (Type[GeneralManager]): Manager class whose Interface and GraphQLProperties determine the filter fields.
|
|
332
558
|
|
|
333
559
|
Returns:
|
|
334
|
-
type[graphene.InputObjectType] | None:
|
|
560
|
+
type[graphene.InputObjectType] | None: Generated InputObjectType class for filters, or `None` if no filter fields are applicable.
|
|
335
561
|
"""
|
|
336
562
|
|
|
337
563
|
graphene_filter_type_name = f"{field_type.__name__}FilterType"
|
|
@@ -390,7 +616,7 @@ class GraphQL:
|
|
|
390
616
|
return graphene.Field(MeasurementType, target_unit=graphene.String())
|
|
391
617
|
elif issubclass(field_type, GeneralManager):
|
|
392
618
|
if field_name.endswith("_list"):
|
|
393
|
-
attributes = {
|
|
619
|
+
attributes: dict[str, Any] = {
|
|
394
620
|
"reverse": graphene.Boolean(),
|
|
395
621
|
"page": graphene.Int(),
|
|
396
622
|
"page_size": graphene.Int(),
|
|
@@ -420,29 +646,37 @@ class GraphQL:
|
|
|
420
646
|
@staticmethod
|
|
421
647
|
def _mapFieldToGrapheneBaseType(field_type: type) -> Type[Any]:
|
|
422
648
|
"""
|
|
423
|
-
Map a Python type to the corresponding Graphene scalar
|
|
649
|
+
Map a Python interface type to the corresponding Graphene scalar or custom scalar.
|
|
424
650
|
|
|
425
651
|
Parameters:
|
|
426
|
-
field_type (type): Python type
|
|
652
|
+
field_type (type): Python type from the interface to map.
|
|
427
653
|
|
|
428
654
|
Returns:
|
|
429
|
-
Type[Any]: Graphene scalar or type
|
|
655
|
+
Type[Any]: The Graphene scalar or custom scalar type used to represent the input type (e.g., graphene.String, graphene.Int, MeasurementScalar).
|
|
656
|
+
|
|
657
|
+
Raises:
|
|
658
|
+
UnsupportedGraphQLFieldTypeError: If `field_type` is `dict`, which is not supported for GraphQL mapping.
|
|
430
659
|
"""
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
660
|
+
base_type = (
|
|
661
|
+
get_origin(field_type) or field_type
|
|
662
|
+
) # Handle typing generics safely.
|
|
663
|
+
if not isinstance(base_type, type):
|
|
434
664
|
return graphene.String
|
|
435
|
-
|
|
665
|
+
if issubclass(base_type, dict):
|
|
666
|
+
raise UnsupportedGraphQLFieldTypeError(field_type)
|
|
667
|
+
if issubclass(base_type, str):
|
|
668
|
+
return graphene.String
|
|
669
|
+
elif issubclass(base_type, bool):
|
|
436
670
|
return graphene.Boolean
|
|
437
|
-
elif issubclass(
|
|
671
|
+
elif issubclass(base_type, int):
|
|
438
672
|
return graphene.Int
|
|
439
|
-
elif issubclass(
|
|
673
|
+
elif issubclass(base_type, (float, Decimal)):
|
|
440
674
|
return graphene.Float
|
|
441
|
-
elif issubclass(
|
|
675
|
+
elif issubclass(base_type, datetime):
|
|
442
676
|
return graphene.DateTime
|
|
443
|
-
elif issubclass(
|
|
677
|
+
elif issubclass(base_type, date):
|
|
444
678
|
return graphene.Date
|
|
445
|
-
elif issubclass(
|
|
679
|
+
elif issubclass(base_type, Measurement):
|
|
446
680
|
return MeasurementScalar
|
|
447
681
|
else:
|
|
448
682
|
return graphene.String
|
|
@@ -450,20 +684,22 @@ class GraphQL:
|
|
|
450
684
|
@staticmethod
|
|
451
685
|
def _parseInput(input_val: dict[str, Any] | str | None) -> dict[str, Any]:
|
|
452
686
|
"""
|
|
453
|
-
|
|
687
|
+
Normalize a filter or exclude input into a dictionary.
|
|
688
|
+
|
|
689
|
+
Accepts a dict, a JSON-encoded string, or None. If given None or an invalid JSON string, returns an empty dict.
|
|
454
690
|
|
|
455
691
|
Parameters:
|
|
456
|
-
input_val
|
|
692
|
+
input_val: Filter or exclude input provided as a dict, a JSON-encoded string, or None.
|
|
457
693
|
|
|
458
694
|
Returns:
|
|
459
|
-
dict
|
|
695
|
+
dict: Mapping of filter keys to values; empty dict if input is None or invalid JSON.
|
|
460
696
|
"""
|
|
461
697
|
if input_val is None:
|
|
462
698
|
return {}
|
|
463
699
|
if isinstance(input_val, str):
|
|
464
700
|
try:
|
|
465
701
|
return json.loads(input_val)
|
|
466
|
-
except
|
|
702
|
+
except (json.JSONDecodeError, ValueError):
|
|
467
703
|
return {}
|
|
468
704
|
return input_val
|
|
469
705
|
|
|
@@ -585,6 +821,8 @@ class GraphQL:
|
|
|
585
821
|
A dictionary containing the paginated items under "items" and pagination metadata under "pageInfo".
|
|
586
822
|
"""
|
|
587
823
|
base_queryset = base_getter(self)
|
|
824
|
+
if base_queryset is None:
|
|
825
|
+
base_queryset = fallback_manager_class.all()
|
|
588
826
|
# use _manager_class from the attribute if available, otherwise fallback
|
|
589
827
|
manager_class = getattr(
|
|
590
828
|
base_queryset, "_manager_class", fallback_manager_class
|
|
@@ -713,9 +951,19 @@ class GraphQL:
|
|
|
713
951
|
item_type: type[graphene.ObjectType] | Callable[[], type[graphene.ObjectType]],
|
|
714
952
|
) -> type[graphene.ObjectType]:
|
|
715
953
|
"""
|
|
716
|
-
|
|
954
|
+
Provide or retrieve a GraphQL ObjectType that represents a paginated page for the given item type.
|
|
717
955
|
|
|
718
|
-
|
|
956
|
+
Creates and caches a GraphQL ObjectType with two fields:
|
|
957
|
+
- `items`: a required list of the provided item type.
|
|
958
|
+
- `pageInfo`: a required PageInfo object containing pagination metadata.
|
|
959
|
+
|
|
960
|
+
Parameters:
|
|
961
|
+
page_type_name (str): The name to use for the generated GraphQL ObjectType.
|
|
962
|
+
item_type (type[graphene.ObjectType] | Callable[[], type[graphene.ObjectType]]):
|
|
963
|
+
The Graphene ObjectType used for items, or a zero-argument callable that returns it (to support forward references).
|
|
964
|
+
|
|
965
|
+
Returns:
|
|
966
|
+
type[graphene.ObjectType]: A Graphene ObjectType with `items` and `pageInfo` fields.
|
|
719
967
|
"""
|
|
720
968
|
if page_type_name not in cls._page_type_registry:
|
|
721
969
|
cls._page_type_registry[page_type_name] = type(
|
|
@@ -728,28 +976,65 @@ class GraphQL:
|
|
|
728
976
|
)
|
|
729
977
|
return cls._page_type_registry[page_type_name]
|
|
730
978
|
|
|
979
|
+
@classmethod
|
|
980
|
+
def _buildIdentificationArguments(
|
|
981
|
+
cls, generalManagerClass: Type[GeneralManager]
|
|
982
|
+
) -> dict[str, Any]:
|
|
983
|
+
"""
|
|
984
|
+
Build the GraphQL arguments required to uniquely identify an instance of the given manager class.
|
|
985
|
+
|
|
986
|
+
For each input field defined on the manager's Interface: use "<name>_id" as a required ID argument for fields that reference another GeneralManager, use "id" as a required ID argument when present, and map other fields to their corresponding Graphene base type marked required.
|
|
987
|
+
|
|
988
|
+
Parameters:
|
|
989
|
+
generalManagerClass: GeneralManager subclass whose Interface.input_fields are used to derive identification arguments.
|
|
990
|
+
|
|
991
|
+
Returns:
|
|
992
|
+
dict[str, Any]: Mapping of argument name to a Graphene Argument suitable for identifying a single manager instance.
|
|
993
|
+
"""
|
|
994
|
+
identification_fields: dict[str, Any] = {}
|
|
995
|
+
for (
|
|
996
|
+
input_field_name,
|
|
997
|
+
input_field,
|
|
998
|
+
) in generalManagerClass.Interface.input_fields.items():
|
|
999
|
+
if issubclass(input_field.type, GeneralManager):
|
|
1000
|
+
key = f"{input_field_name}_id"
|
|
1001
|
+
identification_fields[key] = graphene.Argument(
|
|
1002
|
+
graphene.ID, required=True
|
|
1003
|
+
)
|
|
1004
|
+
elif input_field_name == "id":
|
|
1005
|
+
identification_fields[input_field_name] = graphene.Argument(
|
|
1006
|
+
graphene.ID, required=True
|
|
1007
|
+
)
|
|
1008
|
+
else:
|
|
1009
|
+
base_type = cls._mapFieldToGrapheneBaseType(input_field.type)
|
|
1010
|
+
identification_fields[input_field_name] = graphene.Argument(
|
|
1011
|
+
base_type, required=True
|
|
1012
|
+
)
|
|
1013
|
+
return identification_fields
|
|
1014
|
+
|
|
731
1015
|
@classmethod
|
|
732
1016
|
def _addQueriesToSchema(
|
|
733
|
-
cls, graphene_type: type, generalManagerClass:
|
|
1017
|
+
cls, graphene_type: type, generalManagerClass: Type[GeneralManager]
|
|
734
1018
|
) -> None:
|
|
735
1019
|
"""
|
|
736
|
-
|
|
1020
|
+
Registers list and detail GraphQL query fields for the given manager type into the class query registry.
|
|
737
1021
|
|
|
738
1022
|
Parameters:
|
|
739
|
-
graphene_type (type): Graphene
|
|
740
|
-
generalManagerClass (
|
|
1023
|
+
graphene_type (type): The Graphene ObjectType that represents the manager's GraphQL type.
|
|
1024
|
+
generalManagerClass (Type[GeneralManager]): The GeneralManager subclass to expose via queries.
|
|
1025
|
+
|
|
1026
|
+
Raises:
|
|
1027
|
+
TypeError: If `generalManagerClass` is not a subclass of GeneralManager.
|
|
741
1028
|
"""
|
|
742
1029
|
if not issubclass(generalManagerClass, GeneralManager):
|
|
743
|
-
raise
|
|
744
|
-
"generalManagerClass must be a subclass of GeneralManager to create a GraphQL interface"
|
|
745
|
-
)
|
|
1030
|
+
raise InvalidGeneralManagerClassError(generalManagerClass)
|
|
746
1031
|
|
|
747
1032
|
if not hasattr(cls, "_query_fields"):
|
|
748
1033
|
cls._query_fields = cast(dict[str, Any], {})
|
|
749
1034
|
|
|
750
1035
|
# resolver and field for the list query
|
|
751
1036
|
list_field_name = f"{generalManagerClass.__name__.lower()}_list"
|
|
752
|
-
attributes = {
|
|
1037
|
+
attributes: dict[str, Any] = {
|
|
753
1038
|
"reverse": graphene.Boolean(),
|
|
754
1039
|
"page": graphene.Int(),
|
|
755
1040
|
"page_size": graphene.Int(),
|
|
@@ -768,50 +1053,465 @@ class GraphQL:
|
|
|
768
1053
|
)
|
|
769
1054
|
list_field = graphene.Field(page_type, **attributes)
|
|
770
1055
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
1056
|
+
def _all_items(_: Any) -> Any:
|
|
1057
|
+
"""
|
|
1058
|
+
Return all instances for the associated GeneralManager class.
|
|
1059
|
+
|
|
1060
|
+
Returns:
|
|
1061
|
+
All instances for the associated GeneralManager class, typically provided as a Bucket/QuerySet-like iterable.
|
|
1062
|
+
"""
|
|
1063
|
+
return generalManagerClass.all()
|
|
1064
|
+
|
|
1065
|
+
list_resolver = cls._createListResolver(_all_items, generalManagerClass)
|
|
774
1066
|
cls._query_fields[list_field_name] = list_field
|
|
775
1067
|
cls._query_fields[f"resolve_{list_field_name}"] = list_resolver
|
|
776
1068
|
|
|
777
1069
|
# resolver and field for the single item query
|
|
778
1070
|
item_field_name = generalManagerClass.__name__.lower()
|
|
779
|
-
identification_fields =
|
|
780
|
-
for (
|
|
781
|
-
input_field_name,
|
|
782
|
-
input_field,
|
|
783
|
-
) in generalManagerClass.Interface.input_fields.items():
|
|
784
|
-
if issubclass(input_field.type, GeneralManager):
|
|
785
|
-
key = f"{input_field_name}_id"
|
|
786
|
-
identification_fields[key] = graphene.Int(required=True)
|
|
787
|
-
elif input_field_name == "id":
|
|
788
|
-
identification_fields[input_field_name] = graphene.ID(required=True)
|
|
789
|
-
else:
|
|
790
|
-
identification_fields[input_field_name] = cls._mapFieldToGrapheneRead(
|
|
791
|
-
input_field.type, input_field_name
|
|
792
|
-
)
|
|
793
|
-
identification_fields[input_field_name].required = True
|
|
794
|
-
|
|
1071
|
+
identification_fields = cls._buildIdentificationArguments(generalManagerClass)
|
|
795
1072
|
item_field = graphene.Field(graphene_type, **identification_fields)
|
|
796
1073
|
|
|
797
1074
|
def resolver(
|
|
798
1075
|
self: GeneralManager, info: GraphQLResolveInfo, **identification: dict
|
|
799
1076
|
) -> GeneralManager:
|
|
1077
|
+
"""
|
|
1078
|
+
Instantiate and return a GeneralManager for the provided identification arguments.
|
|
1079
|
+
|
|
1080
|
+
Parameters:
|
|
1081
|
+
identification (dict): Mapping of identification argument names to values passed to the manager constructor.
|
|
1082
|
+
|
|
1083
|
+
Returns:
|
|
1084
|
+
GeneralManager: The manager instance identified by the provided arguments.
|
|
1085
|
+
"""
|
|
800
1086
|
return generalManagerClass(**identification)
|
|
801
1087
|
|
|
802
1088
|
cls._query_fields[item_field_name] = item_field
|
|
803
1089
|
cls._query_fields[f"resolve_{item_field_name}"] = resolver
|
|
804
1090
|
|
|
1091
|
+
@staticmethod
|
|
1092
|
+
def _prime_graphql_properties(
|
|
1093
|
+
instance: GeneralManager, property_names: Iterable[str] | None = None
|
|
1094
|
+
) -> None:
|
|
1095
|
+
"""
|
|
1096
|
+
Eagerly evaluate GraphQLProperty attributes on a manager instance to capture dependency metadata.
|
|
1097
|
+
|
|
1098
|
+
When GraphQLProperty descriptors are accessed they may record dependency information on the instance; this function forces those properties to be read so their dependency tracking is populated. If `property_names` is provided, only those property names that exist on the Interface are evaluated; otherwise all GraphQLProperties from the Interface are evaluated.
|
|
1099
|
+
|
|
1100
|
+
Parameters:
|
|
1101
|
+
instance (GeneralManager): The manager instance whose GraphQLProperty attributes should be evaluated.
|
|
1102
|
+
property_names (Iterable[str] | None): Optional iterable of property names to evaluate. Names not present in the Interface's GraphQLProperties are ignored.
|
|
1103
|
+
"""
|
|
1104
|
+
interface_cls = getattr(instance.__class__, "Interface", None)
|
|
1105
|
+
if interface_cls is None:
|
|
1106
|
+
return
|
|
1107
|
+
available_properties = interface_cls.getGraphQLProperties()
|
|
1108
|
+
if property_names is None:
|
|
1109
|
+
names = available_properties.keys()
|
|
1110
|
+
else:
|
|
1111
|
+
names = [name for name in property_names if name in available_properties]
|
|
1112
|
+
for prop_name in names:
|
|
1113
|
+
getattr(instance, prop_name)
|
|
1114
|
+
|
|
1115
|
+
@classmethod
|
|
1116
|
+
def _dependencies_from_tracker(
|
|
1117
|
+
cls, dependency_records: Iterable[Dependency]
|
|
1118
|
+
) -> list[tuple[type[GeneralManager], dict[str, Any]]]:
|
|
1119
|
+
"""
|
|
1120
|
+
Convert dependency tracker records into resolved (manager class, identification dict) pairs.
|
|
1121
|
+
|
|
1122
|
+
Parses records whose operation is "identification", looks up the corresponding GeneralManager class by name in the registry, and parses the identifier into a dict. Records that do not meet these criteria are skipped.
|
|
1123
|
+
|
|
1124
|
+
Parameters:
|
|
1125
|
+
dependency_records (Iterable[Dependency]): Iterable of dependency records as (manager_name, operation, identifier).
|
|
1126
|
+
|
|
1127
|
+
Returns:
|
|
1128
|
+
list[tuple[type[GeneralManager], dict[str, Any]]]: List of (manager_class, identification_dict) tuples for each successfully resolved record.
|
|
1129
|
+
"""
|
|
1130
|
+
resolved: list[tuple[type[GeneralManager], dict[str, Any]]] = []
|
|
1131
|
+
for manager_name, operation, identifier in dependency_records:
|
|
1132
|
+
if operation != "identification":
|
|
1133
|
+
continue
|
|
1134
|
+
manager_class = cls.manager_registry.get(manager_name)
|
|
1135
|
+
if manager_class is None:
|
|
1136
|
+
continue
|
|
1137
|
+
try:
|
|
1138
|
+
parsed = py_ast.literal_eval(identifier)
|
|
1139
|
+
except (ValueError, SyntaxError):
|
|
1140
|
+
continue
|
|
1141
|
+
if not isinstance(parsed, dict):
|
|
1142
|
+
continue
|
|
1143
|
+
resolved.append((manager_class, parsed))
|
|
1144
|
+
return resolved
|
|
1145
|
+
|
|
1146
|
+
@classmethod
|
|
1147
|
+
def _subscription_property_names(
|
|
1148
|
+
cls,
|
|
1149
|
+
info: GraphQLResolveInfo,
|
|
1150
|
+
manager_class: type[GeneralManager],
|
|
1151
|
+
) -> set[str]:
|
|
1152
|
+
"""
|
|
1153
|
+
Determine which GraphQLProperty names are selected under the subscription payload's `item` field.
|
|
1154
|
+
|
|
1155
|
+
Parameters:
|
|
1156
|
+
info (GraphQLResolveInfo): Resolve info containing the parsed selection set and fragments.
|
|
1157
|
+
manager_class (type[GeneralManager]): GeneralManager subclass whose Interface defines available GraphQLProperty names.
|
|
1158
|
+
|
|
1159
|
+
Returns:
|
|
1160
|
+
property_names (set[str]): Set of GraphQLProperty names referenced under the `item` selection in the subscription request; empty set if none or if the manager has no Interface.
|
|
1161
|
+
"""
|
|
1162
|
+
interface_cls = getattr(manager_class, "Interface", None)
|
|
1163
|
+
if interface_cls is None:
|
|
1164
|
+
return set()
|
|
1165
|
+
available_properties = set(interface_cls.getGraphQLProperties().keys())
|
|
1166
|
+
if not available_properties:
|
|
1167
|
+
return set()
|
|
1168
|
+
|
|
1169
|
+
property_names: set[str] = set()
|
|
1170
|
+
|
|
1171
|
+
def collect_from_selection(selection_set: SelectionSetNode | None) -> None:
|
|
1172
|
+
"""
|
|
1173
|
+
Recursively collect selected GraphQL property names from a SelectionSetNode into the enclosing `property_names` set.
|
|
1174
|
+
|
|
1175
|
+
Processes each selection in the provided selection_set:
|
|
1176
|
+
- Adds a field's name to `property_names` when the name is present in the surrounding `available_properties` set.
|
|
1177
|
+
- For fragment spreads, resolves the fragment via `info.fragments` (from the enclosing scope) and recurses into its selection set.
|
|
1178
|
+
- For inline fragments, recurses into the fragment's selection set.
|
|
1179
|
+
|
|
1180
|
+
Parameters:
|
|
1181
|
+
selection_set (SelectionSetNode | None): GraphQL selection set to traverse; no action is taken if None.
|
|
1182
|
+
"""
|
|
1183
|
+
if selection_set is None:
|
|
1184
|
+
return
|
|
1185
|
+
for selection in selection_set.selections:
|
|
1186
|
+
if isinstance(selection, FieldNode):
|
|
1187
|
+
name = selection.name.value
|
|
1188
|
+
if name in available_properties:
|
|
1189
|
+
property_names.add(name)
|
|
1190
|
+
elif isinstance(selection, FragmentSpreadNode):
|
|
1191
|
+
fragment = info.fragments.get(selection.name.value)
|
|
1192
|
+
if fragment is not None:
|
|
1193
|
+
collect_from_selection(fragment.selection_set)
|
|
1194
|
+
elif isinstance(selection, InlineFragmentNode):
|
|
1195
|
+
collect_from_selection(selection.selection_set)
|
|
1196
|
+
|
|
1197
|
+
def inspect_selection_set(selection_set: SelectionSetNode | None) -> None:
|
|
1198
|
+
"""
|
|
1199
|
+
Traverse a GraphQL SelectionSet and collect subselections of any field named "item".
|
|
1200
|
+
|
|
1201
|
+
Parameters:
|
|
1202
|
+
selection_set (SelectionSetNode | None): The AST selection set to inspect. If None, the function returns without action.
|
|
1203
|
+
|
|
1204
|
+
Description:
|
|
1205
|
+
- Visits FieldNode, FragmentSpreadNode, and InlineFragmentNode entries.
|
|
1206
|
+
- For a FieldNode named "item", delegates its subselection to collect_from_selection.
|
|
1207
|
+
- For other FieldNode entries and InlineFragmentNode entries, recurses into their subselections.
|
|
1208
|
+
- For FragmentSpreadNode entries, attempts to resolve the referenced fragment (via the surrounding `info.fragments` if available) and inspects that fragment's subselection.
|
|
1209
|
+
"""
|
|
1210
|
+
if selection_set is None:
|
|
1211
|
+
return
|
|
1212
|
+
for selection in selection_set.selections:
|
|
1213
|
+
if isinstance(selection, FieldNode):
|
|
1214
|
+
if selection.name.value == "item":
|
|
1215
|
+
collect_from_selection(selection.selection_set)
|
|
1216
|
+
else:
|
|
1217
|
+
inspect_selection_set(selection.selection_set)
|
|
1218
|
+
elif isinstance(selection, FragmentSpreadNode):
|
|
1219
|
+
fragment = info.fragments.get(selection.name.value)
|
|
1220
|
+
if fragment is not None:
|
|
1221
|
+
inspect_selection_set(fragment.selection_set)
|
|
1222
|
+
elif isinstance(selection, InlineFragmentNode):
|
|
1223
|
+
inspect_selection_set(selection.selection_set)
|
|
1224
|
+
|
|
1225
|
+
for node in info.field_nodes:
|
|
1226
|
+
inspect_selection_set(node.selection_set)
|
|
1227
|
+
return property_names
|
|
1228
|
+
|
|
1229
|
+
@classmethod
|
|
1230
|
+
def _resolve_subscription_dependencies(
|
|
1231
|
+
cls,
|
|
1232
|
+
manager_class: type[GeneralManager],
|
|
1233
|
+
instance: GeneralManager,
|
|
1234
|
+
dependency_records: Iterable[Dependency] | None = None,
|
|
1235
|
+
) -> list[tuple[type[GeneralManager], dict[str, Any]]]:
|
|
1236
|
+
"""
|
|
1237
|
+
Builds a list of dependency pairs (manager class, identification) for subscription wiring from an instance and optional dependency records.
|
|
1238
|
+
|
|
1239
|
+
Given a manager class and its instantiated item, returns deduplicated dependency definitions derived from:
|
|
1240
|
+
- any Dependency records produced by a dependency tracker, and
|
|
1241
|
+
- the manager Interface's input fields that reference other GeneralManager types and are populated on the instance.
|
|
1242
|
+
|
|
1243
|
+
Parameters:
|
|
1244
|
+
manager_class (type[GeneralManager]): The manager type whose subscription dependencies are being resolved.
|
|
1245
|
+
instance (GeneralManager): The instantiated manager item whose inputs and identification are inspected.
|
|
1246
|
+
dependency_records (Iterable[Dependency] | None): Optional dependency-tracker records to include.
|
|
1247
|
+
|
|
1248
|
+
Returns:
|
|
1249
|
+
list[tuple[type[GeneralManager], dict[str, Any]]]: A list of (dependent_manager_class, identification) pairs.
|
|
1250
|
+
Each identification is a dict of identification fields. The list excludes the (manager_class, instance.identification) pair and contains no duplicates.
|
|
1251
|
+
"""
|
|
1252
|
+
dependencies: list[tuple[type[GeneralManager], dict[str, Any]]] = []
|
|
1253
|
+
seen: set[tuple[str, str]] = set()
|
|
1254
|
+
if dependency_records:
|
|
1255
|
+
for (
|
|
1256
|
+
dependency_class,
|
|
1257
|
+
dependency_identification,
|
|
1258
|
+
) in cls._dependencies_from_tracker(dependency_records):
|
|
1259
|
+
if (
|
|
1260
|
+
dependency_class is manager_class
|
|
1261
|
+
and dependency_identification == instance.identification
|
|
1262
|
+
):
|
|
1263
|
+
continue
|
|
1264
|
+
key = (dependency_class.__name__, repr(dependency_identification))
|
|
1265
|
+
if key in seen:
|
|
1266
|
+
continue
|
|
1267
|
+
seen.add(key)
|
|
1268
|
+
dependencies.append((dependency_class, dependency_identification))
|
|
1269
|
+
interface_cls = manager_class.Interface
|
|
1270
|
+
|
|
1271
|
+
for (
|
|
1272
|
+
input_name,
|
|
1273
|
+
input_field,
|
|
1274
|
+
) in interface_cls.input_fields.items():
|
|
1275
|
+
if not issubclass(input_field.type, GeneralManager):
|
|
1276
|
+
continue
|
|
1277
|
+
|
|
1278
|
+
raw_value = instance._interface.identification.get(input_name)
|
|
1279
|
+
if raw_value is None:
|
|
1280
|
+
continue
|
|
1281
|
+
|
|
1282
|
+
values = raw_value if isinstance(raw_value, list) else [raw_value]
|
|
1283
|
+
for value in values:
|
|
1284
|
+
if isinstance(value, GeneralManager):
|
|
1285
|
+
identification = deepcopy(value.identification)
|
|
1286
|
+
key = (input_field.type.__name__, repr(identification))
|
|
1287
|
+
if key in seen:
|
|
1288
|
+
continue
|
|
1289
|
+
seen.add(key)
|
|
1290
|
+
dependencies.append(
|
|
1291
|
+
(
|
|
1292
|
+
cast(type[GeneralManager], input_field.type),
|
|
1293
|
+
identification,
|
|
1294
|
+
)
|
|
1295
|
+
)
|
|
1296
|
+
elif isinstance(value, dict):
|
|
1297
|
+
identification_dict = deepcopy(cast(dict[str, Any], value))
|
|
1298
|
+
key = (input_field.type.__name__, repr(identification_dict))
|
|
1299
|
+
if key in seen:
|
|
1300
|
+
continue
|
|
1301
|
+
seen.add(key)
|
|
1302
|
+
dependencies.append(
|
|
1303
|
+
(
|
|
1304
|
+
cast(type[GeneralManager], input_field.type),
|
|
1305
|
+
identification_dict,
|
|
1306
|
+
)
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
return dependencies
|
|
1310
|
+
|
|
1311
|
+
@staticmethod
|
|
1312
|
+
def _instantiate_manager(
|
|
1313
|
+
manager_class: type[GeneralManager],
|
|
1314
|
+
identification: dict[str, Any],
|
|
1315
|
+
*,
|
|
1316
|
+
collect_dependencies: bool = False,
|
|
1317
|
+
property_names: Iterable[str] | None = None,
|
|
1318
|
+
) -> tuple[GeneralManager, set[Dependency]]:
|
|
1319
|
+
"""
|
|
1320
|
+
Create a GeneralManager instance for the given identification and optionally prime its GraphQL properties to capture dependency records.
|
|
1321
|
+
|
|
1322
|
+
Parameters:
|
|
1323
|
+
manager_class (type[GeneralManager]): Manager class to instantiate.
|
|
1324
|
+
identification (dict[str, Any]): Mapping of identification field names to values used to construct the instance.
|
|
1325
|
+
collect_dependencies (bool): If True, prime GraphQL properties while tracking and return the captured Dependency records.
|
|
1326
|
+
property_names (Iterable[str] | None): Specific GraphQLProperty names to prime; if None, all relevant properties are primed.
|
|
1327
|
+
|
|
1328
|
+
Returns:
|
|
1329
|
+
tuple[GeneralManager, set[Dependency]]: The instantiated manager and a set of captured Dependency objects (empty if collect_dependencies is False).
|
|
1330
|
+
"""
|
|
1331
|
+
if collect_dependencies:
|
|
1332
|
+
with DependencyTracker() as captured_dependencies:
|
|
1333
|
+
instance = manager_class(**identification)
|
|
1334
|
+
GraphQL._prime_graphql_properties(instance, property_names)
|
|
1335
|
+
return instance, captured_dependencies
|
|
1336
|
+
|
|
1337
|
+
instance = manager_class(**identification)
|
|
1338
|
+
return instance, set()
|
|
1339
|
+
|
|
1340
|
+
@classmethod
|
|
1341
|
+
def _addSubscriptionField(
|
|
1342
|
+
cls,
|
|
1343
|
+
graphene_type: type[graphene.ObjectType],
|
|
1344
|
+
generalManagerClass: Type[GeneralManager],
|
|
1345
|
+
) -> None:
|
|
1346
|
+
"""
|
|
1347
|
+
Register a GraphQL subscription field that publishes change events for the given manager type.
|
|
1348
|
+
|
|
1349
|
+
Creates (or reuses) a SubscriptionEvent payload GraphQL type and adds three entries to the class subscription registry:
|
|
1350
|
+
- a field exposing the subscription with identification arguments,
|
|
1351
|
+
- an async subscribe function that yields an initial "snapshot" event and subsequent change events for the identified instance and its dependencies,
|
|
1352
|
+
- and a resolve function that returns the delivered payload.
|
|
1353
|
+
|
|
1354
|
+
Parameters:
|
|
1355
|
+
graphene_type (type[graphene.ObjectType]): GraphQL ObjectType representing the manager's item type used as the payload `item` field.
|
|
1356
|
+
generalManagerClass (Type[GeneralManager]): The GeneralManager subclass whose changes the subscription will publish.
|
|
1357
|
+
|
|
1358
|
+
Notes:
|
|
1359
|
+
- The subscribe function requires an available channel layer and subscribes the caller to channel groups derived from the instance identification and its resolved dependencies.
|
|
1360
|
+
- The subscribe coroutine yields SubscriptionEvent objects with fields `item` (the current instance or None if it cannot be instantiated) and `action` (a string such as `"snapshot"` or other change actions).
|
|
1361
|
+
- On termination the subscription cleans up listener tasks and unsubscribes from channel groups.
|
|
1362
|
+
"""
|
|
1363
|
+
field_name = f"on_{generalManagerClass.__name__.lower()}_change"
|
|
1364
|
+
if field_name in cls._subscription_fields:
|
|
1365
|
+
return
|
|
1366
|
+
|
|
1367
|
+
payload_type = cls._subscription_payload_registry.get(
|
|
1368
|
+
generalManagerClass.__name__
|
|
1369
|
+
)
|
|
1370
|
+
if payload_type is None:
|
|
1371
|
+
payload_type = type(
|
|
1372
|
+
f"{generalManagerClass.__name__}SubscriptionEvent",
|
|
1373
|
+
(graphene.ObjectType,),
|
|
1374
|
+
{
|
|
1375
|
+
"item": graphene.Field(graphene_type),
|
|
1376
|
+
"action": graphene.String(required=True),
|
|
1377
|
+
},
|
|
1378
|
+
)
|
|
1379
|
+
cls._subscription_payload_registry[generalManagerClass.__name__] = (
|
|
1380
|
+
payload_type
|
|
1381
|
+
)
|
|
1382
|
+
|
|
1383
|
+
identification_args = cls._buildIdentificationArguments(generalManagerClass)
|
|
1384
|
+
subscription_field = graphene.Field(payload_type, **identification_args)
|
|
1385
|
+
|
|
1386
|
+
async def subscribe(
|
|
1387
|
+
_root: Any,
|
|
1388
|
+
info: GraphQLResolveInfo,
|
|
1389
|
+
**identification: Any,
|
|
1390
|
+
) -> AsyncIterator[SubscriptionEvent]:
|
|
1391
|
+
"""
|
|
1392
|
+
Stream subscription events for a specific manager instance identified by the provided arguments.
|
|
1393
|
+
|
|
1394
|
+
Yields an initial `SubscriptionEvent` with `action` set to `"snapshot"` containing the current manager instance, then yields `SubscriptionEvent`s for each subsequent action. For update events, `item` will be the re-instantiated manager instance or `None` if instantiation fails. The subscriber is registered on the manager's channel groups (including dependent managers' groups) and the channel subscriptions and background listener are cleaned up when the iterator is closed or cancelled.
|
|
1395
|
+
|
|
1396
|
+
Parameters:
|
|
1397
|
+
identification: Identification fields required to locate the manager instance (maps to the manager's identification signature).
|
|
1398
|
+
|
|
1399
|
+
Returns:
|
|
1400
|
+
AsyncIterator[SubscriptionEvent]: An asynchronous iterator that first yields a snapshot event and then yields update events; each event's `item` is the manager instance or `None` if it could not be instantiated.
|
|
1401
|
+
"""
|
|
1402
|
+
identification_copy = deepcopy(identification)
|
|
1403
|
+
property_names = cls._subscription_property_names(
|
|
1404
|
+
info, cast(type[GeneralManager], generalManagerClass)
|
|
1405
|
+
)
|
|
1406
|
+
try:
|
|
1407
|
+
instance, dependency_records = await asyncio.to_thread(
|
|
1408
|
+
cls._instantiate_manager,
|
|
1409
|
+
cast(type[GeneralManager], generalManagerClass),
|
|
1410
|
+
identification_copy,
|
|
1411
|
+
collect_dependencies=True,
|
|
1412
|
+
property_names=property_names,
|
|
1413
|
+
)
|
|
1414
|
+
except Exception as exc: # pragma: no cover - bubbled to GraphQL
|
|
1415
|
+
raise GraphQLError(str(exc)) from exc
|
|
1416
|
+
|
|
1417
|
+
try:
|
|
1418
|
+
channel_layer = cast(
|
|
1419
|
+
BaseChannelLayer, cls._get_channel_layer(strict=True)
|
|
1420
|
+
)
|
|
1421
|
+
except RuntimeError as exc:
|
|
1422
|
+
raise GraphQLError(str(exc)) from exc
|
|
1423
|
+
channel_name = cast(str, await channel_layer.new_channel())
|
|
1424
|
+
queue: asyncio.Queue[str] = asyncio.Queue[str]()
|
|
1425
|
+
|
|
1426
|
+
group_names = {
|
|
1427
|
+
cls._group_name(
|
|
1428
|
+
cast(type[GeneralManager], generalManagerClass),
|
|
1429
|
+
instance.identification,
|
|
1430
|
+
)
|
|
1431
|
+
}
|
|
1432
|
+
dependencies = cls._resolve_subscription_dependencies(
|
|
1433
|
+
cast(type[GeneralManager], generalManagerClass),
|
|
1434
|
+
instance,
|
|
1435
|
+
dependency_records,
|
|
1436
|
+
)
|
|
1437
|
+
for dependency_class, dependency_identification in dependencies:
|
|
1438
|
+
group_names.add(
|
|
1439
|
+
cls._group_name(dependency_class, dependency_identification)
|
|
1440
|
+
)
|
|
1441
|
+
|
|
1442
|
+
for group in group_names:
|
|
1443
|
+
await channel_layer.group_add(group, channel_name)
|
|
1444
|
+
|
|
1445
|
+
listener_task = asyncio.create_task(
|
|
1446
|
+
cls._channel_listener(channel_layer, channel_name, queue)
|
|
1447
|
+
)
|
|
1448
|
+
|
|
1449
|
+
async def event_stream() -> AsyncIterator[SubscriptionEvent]:
|
|
1450
|
+
"""
|
|
1451
|
+
Yield subscription events for a manager instance, starting with an initial snapshot followed by subsequent updates.
|
|
1452
|
+
|
|
1453
|
+
Returns:
|
|
1454
|
+
AsyncIterator[SubscriptionEvent]: An asynchronous iterator that first yields a `SubscriptionEvent` with `action` set to `"snapshot"` and `item` containing the current manager instance (or `None` if instantiation failed). Subsequent yields provide `SubscriptionEvent` values for each received action, where `action` is the action string and `item` is the (re-)instantiated manager or `None` if instantiation failed.
|
|
1455
|
+
|
|
1456
|
+
Notes:
|
|
1457
|
+
When the iterator is closed or exits, the background listener task is cancelled and the subscription's channel group memberships are discarded.
|
|
1458
|
+
"""
|
|
1459
|
+
try:
|
|
1460
|
+
yield SubscriptionEvent(item=instance, action="snapshot")
|
|
1461
|
+
while True:
|
|
1462
|
+
action = await queue.get()
|
|
1463
|
+
try:
|
|
1464
|
+
item, _ = await asyncio.to_thread(
|
|
1465
|
+
cls._instantiate_manager,
|
|
1466
|
+
cast(type[GeneralManager], generalManagerClass),
|
|
1467
|
+
identification_copy,
|
|
1468
|
+
property_names=property_names,
|
|
1469
|
+
)
|
|
1470
|
+
except HANDLED_MANAGER_ERRORS:
|
|
1471
|
+
item = None
|
|
1472
|
+
yield SubscriptionEvent(item=item, action=action)
|
|
1473
|
+
finally:
|
|
1474
|
+
listener_task.cancel()
|
|
1475
|
+
with suppress(asyncio.CancelledError):
|
|
1476
|
+
await listener_task
|
|
1477
|
+
for group in group_names:
|
|
1478
|
+
await channel_layer.group_discard(group, channel_name)
|
|
1479
|
+
|
|
1480
|
+
return event_stream()
|
|
1481
|
+
|
|
1482
|
+
def resolve(
|
|
1483
|
+
payload: SubscriptionEvent,
|
|
1484
|
+
info: GraphQLResolveInfo,
|
|
1485
|
+
**_: Any,
|
|
1486
|
+
) -> SubscriptionEvent:
|
|
1487
|
+
"""
|
|
1488
|
+
Passes a subscription payload through unchanged.
|
|
1489
|
+
|
|
1490
|
+
Parameters:
|
|
1491
|
+
payload (SubscriptionEvent): The subscription event payload to deliver to the client.
|
|
1492
|
+
info (GraphQLResolveInfo): GraphQL resolver info (unused).
|
|
1493
|
+
|
|
1494
|
+
Returns:
|
|
1495
|
+
SubscriptionEvent: The same payload instance provided as input.
|
|
1496
|
+
"""
|
|
1497
|
+
return payload
|
|
1498
|
+
|
|
1499
|
+
cls._subscription_fields[field_name] = subscription_field
|
|
1500
|
+
cls._subscription_fields[f"subscribe_{field_name}"] = subscribe
|
|
1501
|
+
cls._subscription_fields[f"resolve_{field_name}"] = resolve
|
|
1502
|
+
|
|
805
1503
|
@classmethod
|
|
806
1504
|
def createWriteFields(cls, interface_cls: InterfaceBase) -> dict[str, Any]:
|
|
807
1505
|
"""
|
|
808
|
-
|
|
1506
|
+
Create Graphene input fields for writable attributes defined by an Interface.
|
|
1507
|
+
|
|
1508
|
+
Skips system fields ("changed_by", "created_at", "updated_at") and attributes marked as derived. For attributes whose type is a GeneralManager, produces an ID field or a list of ID fields for names ending with "_list". Each generated field is annotated with an `editable` attribute reflecting the interface metadata. Always includes an optional `history_comment` string field marked editable.
|
|
809
1509
|
|
|
810
1510
|
Parameters:
|
|
811
|
-
interface_cls (InterfaceBase): Interface
|
|
1511
|
+
interface_cls (InterfaceBase): Interface providing attribute metadata (type, required, default, editable, derived) used to build the input fields.
|
|
812
1512
|
|
|
813
1513
|
Returns:
|
|
814
|
-
dict[str, Any]: Mapping
|
|
1514
|
+
dict[str, Any]: Mapping from attribute name to a Graphene input field instance.
|
|
815
1515
|
"""
|
|
816
1516
|
fields: dict[str, Any] = {}
|
|
817
1517
|
|
|
@@ -825,6 +1525,7 @@ class GraphQL:
|
|
|
825
1525
|
req = info["is_required"]
|
|
826
1526
|
default = info["default"]
|
|
827
1527
|
|
|
1528
|
+
fld: Any
|
|
828
1529
|
if issubclass(typ, GeneralManager):
|
|
829
1530
|
if name.endswith("_list"):
|
|
830
1531
|
fld = graphene.List(
|
|
@@ -845,12 +1546,13 @@ class GraphQL:
|
|
|
845
1546
|
)
|
|
846
1547
|
|
|
847
1548
|
# mark for generate* code to know what is editable
|
|
848
|
-
|
|
1549
|
+
cast(Any, fld).editable = info["is_editable"]
|
|
849
1550
|
fields[name] = fld
|
|
850
1551
|
|
|
851
1552
|
# history_comment is always optional without a default value
|
|
852
|
-
|
|
853
|
-
|
|
1553
|
+
history_field = graphene.String()
|
|
1554
|
+
cast(Any, history_field).editable = True
|
|
1555
|
+
fields["history_comment"] = history_field
|
|
854
1556
|
|
|
855
1557
|
return fields
|
|
856
1558
|
|
|
@@ -861,12 +1563,14 @@ class GraphQL:
|
|
|
861
1563
|
default_return_values: dict[str, Any],
|
|
862
1564
|
) -> type[graphene.Mutation] | None:
|
|
863
1565
|
"""
|
|
864
|
-
|
|
1566
|
+
Generate a Graphene Mutation class that creates instances of the given GeneralManager subclass.
|
|
865
1567
|
|
|
866
|
-
|
|
1568
|
+
Parameters:
|
|
1569
|
+
generalManagerClass (type[GeneralManager]): The GeneralManager subclass to expose a create mutation for.
|
|
1570
|
+
default_return_values (dict[str, Any]): Base mutation return fields to include on the generated class.
|
|
867
1571
|
|
|
868
1572
|
Returns:
|
|
869
|
-
|
|
1573
|
+
type[graphene.Mutation] | None: A Mutation class named `Create<ManagerName>` that implements creation for the manager (exposes appropriate Arguments and a `mutate` implementation), or `None` if the manager class does not define an `Interface`.
|
|
870
1574
|
"""
|
|
871
1575
|
interface_cls: InterfaceBase | None = getattr(
|
|
872
1576
|
generalManagerClass, "Interface", None
|
|
@@ -880,9 +1584,14 @@ class GraphQL:
|
|
|
880
1584
|
**kwargs: Any,
|
|
881
1585
|
) -> dict:
|
|
882
1586
|
"""
|
|
883
|
-
|
|
1587
|
+
Create a new instance of the manager using the provided input fields.
|
|
884
1588
|
|
|
885
|
-
|
|
1589
|
+
Parameters:
|
|
1590
|
+
info (GraphQLResolveInfo): GraphQL resolve info whose context user ID is used as the creator_id.
|
|
1591
|
+
kwargs (Any): Input fields for the new instance; fields equal to `NOT_PROVIDED` are ignored.
|
|
1592
|
+
|
|
1593
|
+
Returns:
|
|
1594
|
+
result (dict): A dictionary containing `success` (`True` if creation succeeded, `False` otherwise). On success the dictionary also includes the created instance under a key matching the manager class name.
|
|
886
1595
|
"""
|
|
887
1596
|
try:
|
|
888
1597
|
kwargs = {
|
|
@@ -893,11 +1602,8 @@ class GraphQL:
|
|
|
893
1602
|
instance = generalManagerClass.create(
|
|
894
1603
|
**kwargs, creator_id=info.context.user.id
|
|
895
1604
|
)
|
|
896
|
-
except
|
|
897
|
-
GraphQL._handleGraphQLError(
|
|
898
|
-
return {
|
|
899
|
-
"success": False,
|
|
900
|
-
}
|
|
1605
|
+
except HANDLED_MANAGER_ERRORS as error:
|
|
1606
|
+
raise GraphQL._handleGraphQLError(error) from error
|
|
901
1607
|
|
|
902
1608
|
return {
|
|
903
1609
|
"success": True,
|
|
@@ -951,27 +1657,24 @@ class GraphQL:
|
|
|
951
1657
|
**kwargs: Any,
|
|
952
1658
|
) -> dict:
|
|
953
1659
|
"""
|
|
954
|
-
|
|
1660
|
+
Update a GeneralManager instance identified by its `id` with the provided field values.
|
|
955
1661
|
|
|
956
1662
|
Parameters:
|
|
957
|
-
info (GraphQLResolveInfo):
|
|
958
|
-
**kwargs: Field values to update
|
|
1663
|
+
info (GraphQLResolveInfo): GraphQL resolver context (contains the requesting user and request data).
|
|
1664
|
+
**kwargs: Field values to update. Must include `id` to identify the target instance; other keys are treated as fields to update.
|
|
959
1665
|
|
|
960
1666
|
Returns:
|
|
961
|
-
dict:
|
|
1667
|
+
dict: Contains `success` (`True` if the update succeeded, `False` otherwise`) and, on success, the updated instance under a key matching the manager class name.
|
|
962
1668
|
"""
|
|
1669
|
+
manager_id = kwargs.pop("id", None)
|
|
1670
|
+
if manager_id is None:
|
|
1671
|
+
raise GraphQL._handleGraphQLError(MissingManagerIdentifierError())
|
|
963
1672
|
try:
|
|
964
|
-
manager_id = kwargs.pop("id", None)
|
|
965
|
-
if manager_id is None:
|
|
966
|
-
raise ValueError("id is required")
|
|
967
1673
|
instance = generalManagerClass(id=manager_id).update(
|
|
968
1674
|
creator_id=info.context.user.id, **kwargs
|
|
969
1675
|
)
|
|
970
|
-
except
|
|
971
|
-
GraphQL._handleGraphQLError(
|
|
972
|
-
return {
|
|
973
|
-
"success": False,
|
|
974
|
-
}
|
|
1676
|
+
except HANDLED_MANAGER_ERRORS as error:
|
|
1677
|
+
raise GraphQL._handleGraphQLError(error) from error
|
|
975
1678
|
|
|
976
1679
|
return {
|
|
977
1680
|
"success": True,
|
|
@@ -1028,23 +1731,20 @@ class GraphQL:
|
|
|
1028
1731
|
**kwargs: Any,
|
|
1029
1732
|
) -> dict:
|
|
1030
1733
|
"""
|
|
1031
|
-
Deactivates
|
|
1734
|
+
Deactivates the identified GeneralManager instance.
|
|
1032
1735
|
|
|
1033
1736
|
Returns:
|
|
1034
|
-
dict:
|
|
1737
|
+
dict: `success` key with `True` if deactivation succeeded, `False` otherwise. On success, includes an additional key equal to the manager class name containing the deactivated instance.
|
|
1035
1738
|
"""
|
|
1739
|
+
manager_id = kwargs.pop("id", None)
|
|
1740
|
+
if manager_id is None:
|
|
1741
|
+
raise GraphQL._handleGraphQLError(MissingManagerIdentifierError())
|
|
1036
1742
|
try:
|
|
1037
|
-
manager_id = kwargs.pop("id", None)
|
|
1038
|
-
if manager_id is None:
|
|
1039
|
-
raise ValueError("id is required")
|
|
1040
1743
|
instance = generalManagerClass(id=manager_id).deactivate(
|
|
1041
1744
|
creator_id=info.context.user.id
|
|
1042
1745
|
)
|
|
1043
|
-
except
|
|
1044
|
-
GraphQL._handleGraphQLError(
|
|
1045
|
-
return {
|
|
1046
|
-
"success": False,
|
|
1047
|
-
}
|
|
1746
|
+
except HANDLED_MANAGER_ERRORS as error:
|
|
1747
|
+
raise GraphQL._handleGraphQLError(error) from error
|
|
1048
1748
|
|
|
1049
1749
|
return {
|
|
1050
1750
|
"success": True,
|
|
@@ -1073,34 +1773,84 @@ class GraphQL:
|
|
|
1073
1773
|
)
|
|
1074
1774
|
|
|
1075
1775
|
@staticmethod
|
|
1076
|
-
def _handleGraphQLError(error: Exception) ->
|
|
1776
|
+
def _handleGraphQLError(error: Exception) -> GraphQLError:
|
|
1077
1777
|
"""
|
|
1078
|
-
|
|
1778
|
+
Convert an exception into a GraphQL error with an appropriate extensions['code'].
|
|
1779
|
+
|
|
1780
|
+
Maps:
|
|
1781
|
+
PermissionError -> "PERMISSION_DENIED"
|
|
1782
|
+
ValueError, ValidationError, TypeError -> "BAD_USER_INPUT"
|
|
1783
|
+
other exceptions -> "INTERNAL_SERVER_ERROR"
|
|
1079
1784
|
|
|
1080
1785
|
Parameters:
|
|
1081
|
-
error (Exception):
|
|
1786
|
+
error (Exception): The original exception to convert.
|
|
1082
1787
|
|
|
1083
|
-
|
|
1084
|
-
GraphQLError:
|
|
1788
|
+
Returns:
|
|
1789
|
+
GraphQLError: GraphQL error containing the original message and an `extensions['code']` indicating the error category.
|
|
1085
1790
|
"""
|
|
1086
1791
|
if isinstance(error, PermissionError):
|
|
1087
|
-
|
|
1792
|
+
return GraphQLError(
|
|
1088
1793
|
str(error),
|
|
1089
1794
|
extensions={
|
|
1090
1795
|
"code": "PERMISSION_DENIED",
|
|
1091
1796
|
},
|
|
1092
1797
|
)
|
|
1093
1798
|
elif isinstance(error, (ValueError, ValidationError, TypeError)):
|
|
1094
|
-
|
|
1799
|
+
return GraphQLError(
|
|
1095
1800
|
str(error),
|
|
1096
1801
|
extensions={
|
|
1097
1802
|
"code": "BAD_USER_INPUT",
|
|
1098
1803
|
},
|
|
1099
1804
|
)
|
|
1100
1805
|
else:
|
|
1101
|
-
|
|
1806
|
+
return GraphQLError(
|
|
1102
1807
|
str(error),
|
|
1103
1808
|
extensions={
|
|
1104
1809
|
"code": "INTERNAL_SERVER_ERROR",
|
|
1105
1810
|
},
|
|
1106
1811
|
)
|
|
1812
|
+
|
|
1813
|
+
@classmethod
|
|
1814
|
+
def _handle_data_change(
|
|
1815
|
+
cls,
|
|
1816
|
+
sender: type[GeneralManager] | GeneralManager,
|
|
1817
|
+
instance: GeneralManager | None,
|
|
1818
|
+
action: str,
|
|
1819
|
+
**_: Any,
|
|
1820
|
+
) -> None:
|
|
1821
|
+
"""
|
|
1822
|
+
Send a "gm.subscription.event" message to the channel group corresponding to a changed GeneralManager instance.
|
|
1823
|
+
|
|
1824
|
+
If the provided instance is a registered GeneralManager and a channel layer is configured, publish a message containing the given action to the channel group derived from the manager class and the instance's identification. If the instance is None, the manager type is not registered, or no channel layer is available, the function returns without side effects.
|
|
1825
|
+
|
|
1826
|
+
Parameters:
|
|
1827
|
+
sender (type[GeneralManager] | GeneralManager): The signal sender; either a GeneralManager subclass or an instance.
|
|
1828
|
+
instance (GeneralManager | None): The GeneralManager instance that changed.
|
|
1829
|
+
action (str): A string describing the change action (e.g., "created", "updated", "deleted").
|
|
1830
|
+
"""
|
|
1831
|
+
if instance is None or not isinstance(instance, GeneralManager):
|
|
1832
|
+
return
|
|
1833
|
+
|
|
1834
|
+
if isinstance(sender, type) and issubclass(sender, GeneralManager):
|
|
1835
|
+
manager_class: type[GeneralManager] = sender
|
|
1836
|
+
else:
|
|
1837
|
+
manager_class = instance.__class__
|
|
1838
|
+
|
|
1839
|
+
if manager_class.__name__ not in cls.manager_registry:
|
|
1840
|
+
return
|
|
1841
|
+
|
|
1842
|
+
channel_layer = cls._get_channel_layer()
|
|
1843
|
+
if channel_layer is None:
|
|
1844
|
+
return
|
|
1845
|
+
|
|
1846
|
+
group_name = cls._group_name(manager_class, instance.identification)
|
|
1847
|
+
async_to_sync(channel_layer.group_send)(
|
|
1848
|
+
group_name,
|
|
1849
|
+
{
|
|
1850
|
+
"type": "gm.subscription.event",
|
|
1851
|
+
"action": action,
|
|
1852
|
+
},
|
|
1853
|
+
)
|
|
1854
|
+
|
|
1855
|
+
|
|
1856
|
+
post_data_change.connect(GraphQL._handle_data_change, weak=False)
|