GeneralManager 0.16.0__py3-none-any.whl → 0.17.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/api/graphql.py +689 -74
- general_manager/api/graphql_subscription_consumer.py +429 -0
- general_manager/apps.py +164 -9
- general_manager/cache/dependencyIndex.py +169 -17
- general_manager/manager/groupManager.py +1 -1
- general_manager/py.typed +0 -0
- general_manager/utils/testing.py +26 -17
- {generalmanager-0.16.0.dist-info → generalmanager-0.17.0.dist-info}/METADATA +10 -3
- {generalmanager-0.16.0.dist-info → generalmanager-0.17.0.dist-info}/RECORD +12 -10
- {generalmanager-0.16.0.dist-info → generalmanager-0.17.0.dist-info}/WHEEL +0 -0
- {generalmanager-0.16.0.dist-info → generalmanager-0.17.0.dist-info}/licenses/LICENSE +0 -0
- {generalmanager-0.16.0.dist-info → generalmanager-0.17.0.dist-info}/top_level.txt +0 -0
general_manager/api/graphql.py
CHANGED
|
@@ -1,33 +1,47 @@
|
|
|
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
|
+
Generator,
|
|
20
|
+
Iterable,
|
|
10
21
|
TYPE_CHECKING,
|
|
11
|
-
cast,
|
|
12
22
|
Type,
|
|
13
|
-
Generator,
|
|
14
23
|
Union,
|
|
24
|
+
cast,
|
|
25
|
+
get_args,
|
|
26
|
+
get_origin,
|
|
15
27
|
)
|
|
16
|
-
from types import UnionType
|
|
17
28
|
|
|
18
|
-
|
|
19
|
-
from datetime import date, datetime
|
|
20
|
-
import json
|
|
29
|
+
import graphene # type: ignore[import]
|
|
21
30
|
from graphql.language import ast
|
|
31
|
+
from graphql.language.ast import FieldNode, FragmentSpreadNode, InlineFragmentNode, SelectionSetNode
|
|
32
|
+
from asgiref.sync import async_to_sync
|
|
33
|
+
from channels.layers import BaseChannelLayer, get_channel_layer
|
|
22
34
|
|
|
23
|
-
from general_manager.
|
|
24
|
-
from general_manager.
|
|
25
|
-
from general_manager.
|
|
35
|
+
from general_manager.cache.cacheTracker import DependencyTracker
|
|
36
|
+
from general_manager.cache.dependencyIndex import Dependency
|
|
37
|
+
from general_manager.cache.signals import post_data_change
|
|
26
38
|
from general_manager.bucket.baseBucket import Bucket
|
|
27
39
|
from general_manager.interface.baseInterface import InterfaceBase
|
|
28
|
-
from
|
|
29
|
-
from
|
|
40
|
+
from general_manager.manager.generalManager import GeneralManager
|
|
41
|
+
from general_manager.measurement.measurement import Measurement
|
|
30
42
|
|
|
43
|
+
from django.core.exceptions import ValidationError
|
|
44
|
+
from django.db.models import NOT_PROVIDED
|
|
31
45
|
from graphql import GraphQLError
|
|
32
46
|
|
|
33
47
|
|
|
@@ -36,6 +50,14 @@ if TYPE_CHECKING:
|
|
|
36
50
|
from graphene import ResolveInfo as GraphQLResolveInfo
|
|
37
51
|
|
|
38
52
|
|
|
53
|
+
@dataclass(slots=True)
|
|
54
|
+
class SubscriptionEvent:
|
|
55
|
+
"""Payload delivered to GraphQL subscription resolvers."""
|
|
56
|
+
|
|
57
|
+
item: Any | None
|
|
58
|
+
action: str
|
|
59
|
+
|
|
60
|
+
|
|
39
61
|
class MeasurementType(graphene.ObjectType):
|
|
40
62
|
value = graphene.Float()
|
|
41
63
|
unit = graphene.String()
|
|
@@ -71,18 +93,18 @@ class PageInfo(graphene.ObjectType):
|
|
|
71
93
|
|
|
72
94
|
|
|
73
95
|
def getReadPermissionFilter(
|
|
74
|
-
generalManagerClass:
|
|
96
|
+
generalManagerClass: Type[GeneralManager],
|
|
75
97
|
info: GraphQLResolveInfo,
|
|
76
98
|
) -> list[tuple[dict[str, Any], dict[str, Any]]]:
|
|
77
99
|
"""
|
|
78
|
-
|
|
79
|
-
|
|
100
|
+
Produce a list of permission-derived filter and exclude mappings for queries against a manager class.
|
|
101
|
+
|
|
80
102
|
Parameters:
|
|
81
|
-
generalManagerClass (
|
|
82
|
-
info (GraphQLResolveInfo): GraphQL resolver info
|
|
83
|
-
|
|
103
|
+
generalManagerClass (Type[GeneralManager]): Manager class to derive permission filters for.
|
|
104
|
+
info (GraphQLResolveInfo): GraphQL resolver info whose context provides the current user.
|
|
105
|
+
|
|
84
106
|
Returns:
|
|
85
|
-
list[tuple[dict[str, Any], dict[str, Any]]]:
|
|
107
|
+
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
108
|
"""
|
|
87
109
|
filters = []
|
|
88
110
|
PermissionClass: type[BasePermission] | None = getattr(
|
|
@@ -104,19 +126,106 @@ class GraphQL:
|
|
|
104
126
|
|
|
105
127
|
_query_class: type[graphene.ObjectType] | None = None
|
|
106
128
|
_mutation_class: type[graphene.ObjectType] | None = None
|
|
129
|
+
_subscription_class: type[graphene.ObjectType] | None = None
|
|
107
130
|
_mutations: dict[str, Any] = {}
|
|
108
131
|
_query_fields: dict[str, Any] = {}
|
|
132
|
+
_subscription_fields: dict[str, Any] = {}
|
|
109
133
|
_page_type_registry: dict[str, type[graphene.ObjectType]] = {}
|
|
134
|
+
_subscription_payload_registry: dict[str, type[graphene.ObjectType]] = {}
|
|
110
135
|
graphql_type_registry: dict[str, type] = {}
|
|
111
136
|
graphql_filter_type_registry: dict[str, type] = {}
|
|
137
|
+
manager_registry: dict[str, type[GeneralManager]] = {}
|
|
138
|
+
_schema: graphene.Schema | None = None
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _get_channel_layer(strict: bool = False) -> BaseChannelLayer | None:
|
|
142
|
+
"""
|
|
143
|
+
Return the configured channel layer used for GraphQL subscriptions.
|
|
144
|
+
|
|
145
|
+
Parameters:
|
|
146
|
+
strict (bool): If True, raise an error when no channel layer is configured.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
BaseChannelLayer | None: The channel layer instance if configured, otherwise `None`.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
RuntimeError: If `strict` is True and no channel layer is configured.
|
|
153
|
+
"""
|
|
154
|
+
layer = cast(BaseChannelLayer | None, get_channel_layer())
|
|
155
|
+
if layer is None and strict:
|
|
156
|
+
raise RuntimeError(
|
|
157
|
+
"No channel layer configured. Configure CHANNEL_LAYERS to enable GraphQL subscriptions."
|
|
158
|
+
)
|
|
159
|
+
return layer
|
|
112
160
|
|
|
113
161
|
@classmethod
|
|
114
|
-
def
|
|
162
|
+
def get_schema(cls) -> graphene.Schema | None:
|
|
163
|
+
"""
|
|
164
|
+
Get the currently configured Graphene schema for the GraphQL registry.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The active `graphene.Schema` instance, or `None` if no schema has been created.
|
|
168
|
+
"""
|
|
169
|
+
return cls._schema
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _group_name(
|
|
173
|
+
manager_class: type[GeneralManager], identification: dict[str, Any]
|
|
174
|
+
) -> str:
|
|
175
|
+
"""
|
|
176
|
+
Build a consistent channel group name for subscriptions tied to a manager and its identification.
|
|
177
|
+
|
|
178
|
+
Parameters:
|
|
179
|
+
manager_class (type[GeneralManager]): Manager class used to namespace the group.
|
|
180
|
+
identification (dict[str, Any]): Mapping of identifying fields for a manager instance; will be JSON-normalized and included in the group name.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
group_name (str): A deterministic string usable as a channel group identifier for subscription events.
|
|
184
|
+
"""
|
|
185
|
+
normalized = json.dumps(identification, sort_keys=True, default=str)
|
|
186
|
+
digest = hashlib.sha256(
|
|
187
|
+
f"{manager_class.__module__}.{manager_class.__name__}:{normalized}".encode(
|
|
188
|
+
"utf-8"
|
|
189
|
+
)
|
|
190
|
+
).hexdigest()[:32]
|
|
191
|
+
return f"gm_subscriptions.{manager_class.__name__}.{digest}"
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
async def _channel_listener(
|
|
195
|
+
channel_layer: BaseChannelLayer,
|
|
196
|
+
channel_name: str,
|
|
197
|
+
queue: asyncio.Queue[str],
|
|
198
|
+
) -> None:
|
|
199
|
+
"""
|
|
200
|
+
Listen to a channel layer for "gm.subscription.event" messages and enqueue their `action` values.
|
|
201
|
+
|
|
202
|
+
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.
|
|
203
|
+
|
|
204
|
+
Parameters:
|
|
205
|
+
channel_layer (BaseChannelLayer): Channel layer to receive messages from.
|
|
206
|
+
channel_name (str): Name of the channel to listen on.
|
|
207
|
+
queue (asyncio.Queue[str]): Async queue to which received action strings will be enqueued.
|
|
115
208
|
"""
|
|
116
|
-
|
|
209
|
+
try:
|
|
210
|
+
while True:
|
|
211
|
+
message = cast(dict[str, Any], await channel_layer.receive(channel_name))
|
|
212
|
+
if message.get("type") != "gm.subscription.event":
|
|
213
|
+
continue
|
|
214
|
+
action = cast(str | None, message.get("action"))
|
|
215
|
+
if action is not None:
|
|
216
|
+
await queue.put(action)
|
|
217
|
+
except asyncio.CancelledError:
|
|
218
|
+
pass
|
|
117
219
|
|
|
220
|
+
@classmethod
|
|
221
|
+
def createGraphqlMutation(cls, generalManagerClass: type[GeneralManager]) -> None:
|
|
222
|
+
"""
|
|
223
|
+
Register create, update, and delete GraphQL mutations for a manager class when its Interface overrides the default CRUD methods.
|
|
224
|
+
|
|
225
|
+
For each of `create`, `update`, and `deactivate` that is implemented on the manager's `Interface` (i.e., differs from InterfaceBase), this method generates a corresponding mutation class and stores it on the class-level mutation registry (`cls._mutations`) under the names `create<ManagerName>`, `update<ManagerName>`, and `delete<ManagerName>`. The generated mutations return a `success` flag and the created/updated/deactivated object field keyed by the manager class name.
|
|
226
|
+
|
|
118
227
|
Parameters:
|
|
119
|
-
generalManagerClass (type[GeneralManager]): Manager class whose
|
|
228
|
+
generalManagerClass (type[GeneralManager]): Manager class whose `Interface` drives which mutations are generated and registered.
|
|
120
229
|
"""
|
|
121
230
|
|
|
122
231
|
interface_cls: InterfaceBase | None = getattr(
|
|
@@ -150,12 +259,14 @@ class GraphQL:
|
|
|
150
259
|
)
|
|
151
260
|
|
|
152
261
|
@classmethod
|
|
153
|
-
def createGraphqlInterface(cls, generalManagerClass:
|
|
262
|
+
def createGraphqlInterface(cls, generalManagerClass: Type[GeneralManager]) -> None:
|
|
154
263
|
"""
|
|
155
|
-
|
|
156
|
-
|
|
264
|
+
Create and register a Graphene ObjectType for a GeneralManager class and expose its queries and subscription.
|
|
265
|
+
|
|
266
|
+
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.
|
|
267
|
+
|
|
157
268
|
Parameters:
|
|
158
|
-
generalManagerClass (
|
|
269
|
+
generalManagerClass (Type[GeneralManager]): The manager class whose Interface and GraphQLProperties are used to generate Graphene fields and resolvers.
|
|
159
270
|
"""
|
|
160
271
|
interface_cls: InterfaceBase | None = getattr(
|
|
161
272
|
generalManagerClass, "Interface", None
|
|
@@ -216,20 +327,19 @@ class GraphQL:
|
|
|
216
327
|
|
|
217
328
|
graphene_type = type(graphene_type_name, (graphene.ObjectType,), fields)
|
|
218
329
|
cls.graphql_type_registry[generalManagerClass.__name__] = graphene_type
|
|
330
|
+
cls.manager_registry[generalManagerClass.__name__] = generalManagerClass
|
|
219
331
|
cls._addQueriesToSchema(graphene_type, generalManagerClass)
|
|
332
|
+
cls._addSubscriptionField(graphene_type, generalManagerClass)
|
|
220
333
|
|
|
221
334
|
@staticmethod
|
|
222
335
|
def _sortByOptions(
|
|
223
|
-
generalManagerClass:
|
|
336
|
+
generalManagerClass: Type[GeneralManager],
|
|
224
337
|
) -> type[graphene.Enum] | None:
|
|
225
338
|
"""
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
Parameters:
|
|
229
|
-
generalManagerClass (GeneralManagerMeta): Manager class being inspected.
|
|
230
|
-
|
|
339
|
+
Builds an enum of sortable field names for the given manager class.
|
|
340
|
+
|
|
231
341
|
Returns:
|
|
232
|
-
|
|
342
|
+
An Enum type whose members are the sortable field names for the manager, or `None` if no sortable fields exist.
|
|
233
343
|
"""
|
|
234
344
|
sort_options = []
|
|
235
345
|
for (
|
|
@@ -322,16 +432,18 @@ class GraphQL:
|
|
|
322
432
|
|
|
323
433
|
@staticmethod
|
|
324
434
|
def _createFilterOptions(
|
|
325
|
-
field_type:
|
|
435
|
+
field_type: Type[GeneralManager],
|
|
326
436
|
) -> type[graphene.InputObjectType] | None:
|
|
327
437
|
"""
|
|
328
|
-
Create a Graphene
|
|
329
|
-
|
|
438
|
+
Create a Graphene InputObjectType that exposes available filter fields for a GeneralManager subclass.
|
|
439
|
+
|
|
440
|
+
Builds filter fields from the manager's Interface attributes and any GraphQLProperty marked filterable. Returns None when no filterable fields are found.
|
|
441
|
+
|
|
330
442
|
Parameters:
|
|
331
|
-
field_type (
|
|
332
|
-
|
|
443
|
+
field_type (Type[GeneralManager]): Manager class whose Interface and GraphQLProperties determine the filter fields.
|
|
444
|
+
|
|
333
445
|
Returns:
|
|
334
|
-
type[graphene.InputObjectType] | None:
|
|
446
|
+
type[graphene.InputObjectType] | None: Generated InputObjectType class for filters, or `None` if no filter fields are applicable.
|
|
335
447
|
"""
|
|
336
448
|
|
|
337
449
|
graphene_filter_type_name = f"{field_type.__name__}FilterType"
|
|
@@ -713,9 +825,19 @@ class GraphQL:
|
|
|
713
825
|
item_type: type[graphene.ObjectType] | Callable[[], type[graphene.ObjectType]],
|
|
714
826
|
) -> type[graphene.ObjectType]:
|
|
715
827
|
"""
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
828
|
+
Provide or retrieve a GraphQL ObjectType that represents a paginated page for the given item type.
|
|
829
|
+
|
|
830
|
+
Creates and caches a GraphQL ObjectType with two fields:
|
|
831
|
+
- `items`: a required list of the provided item type.
|
|
832
|
+
- `pageInfo`: a required PageInfo object containing pagination metadata.
|
|
833
|
+
|
|
834
|
+
Parameters:
|
|
835
|
+
page_type_name (str): The name to use for the generated GraphQL ObjectType.
|
|
836
|
+
item_type (type[graphene.ObjectType] | Callable[[], type[graphene.ObjectType]]):
|
|
837
|
+
The Graphene ObjectType used for items, or a zero-argument callable that returns it (to support forward references).
|
|
838
|
+
|
|
839
|
+
Returns:
|
|
840
|
+
type[graphene.ObjectType]: A Graphene ObjectType with `items` and `pageInfo` fields.
|
|
719
841
|
"""
|
|
720
842
|
if page_type_name not in cls._page_type_registry:
|
|
721
843
|
cls._page_type_registry[page_type_name] = type(
|
|
@@ -728,16 +850,50 @@ class GraphQL:
|
|
|
728
850
|
)
|
|
729
851
|
return cls._page_type_registry[page_type_name]
|
|
730
852
|
|
|
853
|
+
@classmethod
|
|
854
|
+
def _buildIdentificationArguments(
|
|
855
|
+
cls, generalManagerClass: Type[GeneralManager]
|
|
856
|
+
) -> dict[str, Any]:
|
|
857
|
+
"""
|
|
858
|
+
Builds GraphQL arguments required to uniquely identify an instance of the given manager class.
|
|
859
|
+
|
|
860
|
+
Parameters:
|
|
861
|
+
generalManagerClass (Type[GeneralManager]): Manager class whose Interface.input_fields are used to derive identification arguments.
|
|
862
|
+
|
|
863
|
+
Returns:
|
|
864
|
+
dict[str, Any]: Mapping from argument name to a Graphene argument/field. Fields for related manager inputs use "<name>_id" as an ID, the "id" input uses an ID, and other inputs are mapped to appropriate Graphene read fields with `required=True`.
|
|
865
|
+
"""
|
|
866
|
+
identification_fields: dict[str, graphene.Argument] = {}
|
|
867
|
+
for input_field_name, input_field in generalManagerClass.Interface.input_fields.items():
|
|
868
|
+
if issubclass(input_field.type, GeneralManager):
|
|
869
|
+
key = f"{input_field_name}_id"
|
|
870
|
+
identification_fields[key] = graphene.Argument(
|
|
871
|
+
graphene.ID, required=True
|
|
872
|
+
)
|
|
873
|
+
elif input_field_name == "id":
|
|
874
|
+
identification_fields[input_field_name] = graphene.Argument(
|
|
875
|
+
graphene.ID, required=True
|
|
876
|
+
)
|
|
877
|
+
else:
|
|
878
|
+
base_type = cls._mapFieldToGrapheneBaseType(input_field.type)
|
|
879
|
+
identification_fields[input_field_name] = graphene.Argument(
|
|
880
|
+
base_type, required=True
|
|
881
|
+
)
|
|
882
|
+
return identification_fields
|
|
883
|
+
|
|
731
884
|
@classmethod
|
|
732
885
|
def _addQueriesToSchema(
|
|
733
|
-
cls, graphene_type: type, generalManagerClass:
|
|
886
|
+
cls, graphene_type: type, generalManagerClass: Type[GeneralManager]
|
|
734
887
|
) -> None:
|
|
735
888
|
"""
|
|
736
|
-
|
|
737
|
-
|
|
889
|
+
Registers list and detail GraphQL query fields for the given manager type into the class query registry.
|
|
890
|
+
|
|
738
891
|
Parameters:
|
|
739
|
-
graphene_type (type): Graphene
|
|
740
|
-
generalManagerClass (
|
|
892
|
+
graphene_type (type): The Graphene ObjectType that represents the manager's GraphQL type.
|
|
893
|
+
generalManagerClass (Type[GeneralManager]): The GeneralManager subclass to expose via queries.
|
|
894
|
+
|
|
895
|
+
Raises:
|
|
896
|
+
TypeError: If `generalManagerClass` is not a subclass of GeneralManager.
|
|
741
897
|
"""
|
|
742
898
|
if not issubclass(generalManagerClass, GeneralManager):
|
|
743
899
|
raise TypeError(
|
|
@@ -776,42 +932,448 @@ class GraphQL:
|
|
|
776
932
|
|
|
777
933
|
# resolver and field for the single item query
|
|
778
934
|
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
|
-
|
|
935
|
+
identification_fields = cls._buildIdentificationArguments(generalManagerClass)
|
|
795
936
|
item_field = graphene.Field(graphene_type, **identification_fields)
|
|
796
937
|
|
|
797
938
|
def resolver(
|
|
798
939
|
self: GeneralManager, info: GraphQLResolveInfo, **identification: dict
|
|
799
940
|
) -> GeneralManager:
|
|
941
|
+
"""
|
|
942
|
+
Resolve a single manager instance from provided identification arguments.
|
|
943
|
+
|
|
944
|
+
Parameters:
|
|
945
|
+
identification (dict): Mapping of identification argument names to their values; used as keyword arguments when instantiating the manager.
|
|
946
|
+
|
|
947
|
+
Returns:
|
|
948
|
+
GeneralManager: The instantiated manager corresponding to the given identification.
|
|
949
|
+
"""
|
|
800
950
|
return generalManagerClass(**identification)
|
|
801
951
|
|
|
802
952
|
cls._query_fields[item_field_name] = item_field
|
|
803
953
|
cls._query_fields[f"resolve_{item_field_name}"] = resolver
|
|
804
954
|
|
|
955
|
+
@staticmethod
|
|
956
|
+
def _prime_graphql_properties(
|
|
957
|
+
instance: GeneralManager, property_names: Iterable[str] | None = None
|
|
958
|
+
) -> None:
|
|
959
|
+
"""
|
|
960
|
+
Eagerly evaluate GraphQLProperty attributes on a manager instance to capture dependency metadata.
|
|
961
|
+
|
|
962
|
+
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.
|
|
963
|
+
|
|
964
|
+
Parameters:
|
|
965
|
+
instance (GeneralManager): The manager instance whose GraphQLProperty attributes should be evaluated.
|
|
966
|
+
property_names (Iterable[str] | None): Optional iterable of property names to evaluate. Names not present in the Interface's GraphQLProperties are ignored.
|
|
967
|
+
"""
|
|
968
|
+
interface_cls = getattr(instance.__class__, "Interface", None)
|
|
969
|
+
if interface_cls is None:
|
|
970
|
+
return
|
|
971
|
+
available_properties = interface_cls.getGraphQLProperties()
|
|
972
|
+
if property_names is None:
|
|
973
|
+
names = available_properties.keys()
|
|
974
|
+
else:
|
|
975
|
+
names = [name for name in property_names if name in available_properties]
|
|
976
|
+
for prop_name in names:
|
|
977
|
+
getattr(instance, prop_name)
|
|
978
|
+
|
|
805
979
|
@classmethod
|
|
806
|
-
def
|
|
980
|
+
def _dependencies_from_tracker(
|
|
981
|
+
cls, dependency_records: Iterable[Dependency]
|
|
982
|
+
) -> list[tuple[type[GeneralManager], dict[str, Any]]]:
|
|
983
|
+
"""
|
|
984
|
+
Convert DependencyTracker records into tuples of (manager class, identification dict).
|
|
985
|
+
|
|
986
|
+
Filters input records to those whose operation is "identification", whose manager name maps to a registered GeneralManager class, and whose identifier can be parsed to a dict; returns one tuple per successfully resolved record.
|
|
987
|
+
|
|
988
|
+
Parameters:
|
|
989
|
+
dependency_records (Iterable[Dependency]): Iterable of dependency records as (manager_name, operation, identifier).
|
|
990
|
+
|
|
991
|
+
Returns:
|
|
992
|
+
list[tuple[type[GeneralManager], dict[str, Any]]]: A list of (manager_class, identification_dict) tuples for each resolved record.
|
|
993
|
+
"""
|
|
994
|
+
resolved: list[tuple[type[GeneralManager], dict[str, Any]]] = []
|
|
995
|
+
for manager_name, operation, identifier in dependency_records:
|
|
996
|
+
if operation != "identification":
|
|
997
|
+
continue
|
|
998
|
+
manager_class = cls.manager_registry.get(manager_name)
|
|
999
|
+
if manager_class is None:
|
|
1000
|
+
continue
|
|
1001
|
+
try:
|
|
1002
|
+
parsed = py_ast.literal_eval(identifier)
|
|
1003
|
+
except (ValueError, SyntaxError):
|
|
1004
|
+
continue
|
|
1005
|
+
if not isinstance(parsed, dict):
|
|
1006
|
+
continue
|
|
1007
|
+
resolved.append((manager_class, parsed))
|
|
1008
|
+
return resolved
|
|
1009
|
+
|
|
1010
|
+
@classmethod
|
|
1011
|
+
def _subscription_property_names(
|
|
1012
|
+
cls,
|
|
1013
|
+
info: GraphQLResolveInfo,
|
|
1014
|
+
manager_class: type[GeneralManager],
|
|
1015
|
+
) -> set[str]:
|
|
1016
|
+
"""
|
|
1017
|
+
Identify GraphQLProperty names selected within the subscription payload's `item` field.
|
|
1018
|
+
|
|
1019
|
+
Parameters:
|
|
1020
|
+
info (GraphQLResolveInfo): Resolve info containing the parsed selection set and fragments.
|
|
1021
|
+
manager_class (type[GeneralManager]): Manager class whose Interface defines available GraphQLProperty names.
|
|
1022
|
+
|
|
1023
|
+
Returns:
|
|
1024
|
+
property_names (set[str]): Set of GraphQLProperty names referenced under the `item` selection in the subscription request; empty if none or if the manager has no Interface.
|
|
807
1025
|
"""
|
|
808
|
-
|
|
1026
|
+
interface_cls = getattr(manager_class, "Interface", None)
|
|
1027
|
+
if interface_cls is None:
|
|
1028
|
+
return set()
|
|
1029
|
+
available_properties = set(interface_cls.getGraphQLProperties().keys())
|
|
1030
|
+
if not available_properties:
|
|
1031
|
+
return set()
|
|
809
1032
|
|
|
1033
|
+
property_names: set[str] = set()
|
|
1034
|
+
|
|
1035
|
+
def collect_from_selection(selection_set: SelectionSetNode | None) -> None:
|
|
1036
|
+
"""
|
|
1037
|
+
Recursively collect selected GraphQL property names from a SelectionSetNode into the enclosing `property_names` set.
|
|
1038
|
+
|
|
1039
|
+
Processes each selection in the provided selection_set:
|
|
1040
|
+
- Adds a field's name to `property_names` when the name is present in the surrounding `available_properties` set.
|
|
1041
|
+
- For fragment spreads, resolves the fragment via `info.fragments` (from the enclosing scope) and recurses into its selection set.
|
|
1042
|
+
- For inline fragments, recurses into the fragment's selection set.
|
|
1043
|
+
|
|
1044
|
+
Parameters:
|
|
1045
|
+
selection_set (SelectionSetNode | None): GraphQL selection set to traverse; no action is taken if None.
|
|
1046
|
+
"""
|
|
1047
|
+
if selection_set is None:
|
|
1048
|
+
return
|
|
1049
|
+
for selection in selection_set.selections:
|
|
1050
|
+
if isinstance(selection, FieldNode):
|
|
1051
|
+
name = selection.name.value
|
|
1052
|
+
if name in available_properties:
|
|
1053
|
+
property_names.add(name)
|
|
1054
|
+
elif isinstance(selection, FragmentSpreadNode):
|
|
1055
|
+
fragment = info.fragments.get(selection.name.value)
|
|
1056
|
+
if fragment is not None:
|
|
1057
|
+
collect_from_selection(fragment.selection_set)
|
|
1058
|
+
elif isinstance(selection, InlineFragmentNode):
|
|
1059
|
+
collect_from_selection(selection.selection_set)
|
|
1060
|
+
|
|
1061
|
+
def inspect_selection_set(selection_set: SelectionSetNode | None) -> None:
|
|
1062
|
+
"""
|
|
1063
|
+
Recursively traverse a GraphQL SelectionSet and delegate the subselection of any field named "item" to collect_from_selection.
|
|
1064
|
+
|
|
1065
|
+
Parameters:
|
|
1066
|
+
selection_set (SelectionSetNode | None): The AST selection set to inspect. If None, the function does nothing.
|
|
1067
|
+
|
|
1068
|
+
Description:
|
|
1069
|
+
Visits FieldNode, FragmentSpreadNode, and InlineFragmentNode entries:
|
|
1070
|
+
- For FieldNode named "item", calls collect_from_selection with that field's subselection.
|
|
1071
|
+
- For other FieldNode entries and inline fragments, continues recursion into their subselections.
|
|
1072
|
+
- For fragment spreads, resolves the fragment from `info.fragments` (if present) and inspects its subselection.
|
|
1073
|
+
"""
|
|
1074
|
+
if selection_set is None:
|
|
1075
|
+
return
|
|
1076
|
+
for selection in selection_set.selections:
|
|
1077
|
+
if isinstance(selection, FieldNode):
|
|
1078
|
+
if selection.name.value == "item":
|
|
1079
|
+
collect_from_selection(selection.selection_set)
|
|
1080
|
+
else:
|
|
1081
|
+
inspect_selection_set(selection.selection_set)
|
|
1082
|
+
elif isinstance(selection, FragmentSpreadNode):
|
|
1083
|
+
fragment = info.fragments.get(selection.name.value)
|
|
1084
|
+
if fragment is not None:
|
|
1085
|
+
inspect_selection_set(fragment.selection_set)
|
|
1086
|
+
elif isinstance(selection, InlineFragmentNode):
|
|
1087
|
+
inspect_selection_set(selection.selection_set)
|
|
1088
|
+
|
|
1089
|
+
for node in info.field_nodes:
|
|
1090
|
+
inspect_selection_set(node.selection_set)
|
|
1091
|
+
return property_names
|
|
1092
|
+
|
|
1093
|
+
@classmethod
|
|
1094
|
+
def _resolve_subscription_dependencies(
|
|
1095
|
+
cls,
|
|
1096
|
+
manager_class: type[GeneralManager],
|
|
1097
|
+
instance: GeneralManager,
|
|
1098
|
+
dependency_records: Iterable[Dependency] | None = None,
|
|
1099
|
+
) -> list[tuple[type[GeneralManager], dict[str, Any]]]:
|
|
1100
|
+
"""
|
|
1101
|
+
Builds a list of dependency pairs (manager class, identification) for subscription wiring from an instance and optional dependency records.
|
|
1102
|
+
|
|
1103
|
+
Given a manager class and its instantiated item, returns deduplicated dependency definitions derived from:
|
|
1104
|
+
- any Dependency records produced by a dependency tracker, and
|
|
1105
|
+
- the manager Interface's input fields that reference other GeneralManager types and are populated on the instance.
|
|
1106
|
+
|
|
810
1107
|
Parameters:
|
|
811
|
-
|
|
1108
|
+
manager_class (type[GeneralManager]): The manager type whose subscription dependencies are being resolved.
|
|
1109
|
+
instance (GeneralManager): The instantiated manager item whose inputs and identification are inspected.
|
|
1110
|
+
dependency_records (Iterable[Dependency] | None): Optional dependency-tracker records to include.
|
|
1111
|
+
|
|
1112
|
+
Returns:
|
|
1113
|
+
list[tuple[type[GeneralManager], dict[str, Any]]]: A list of (dependent_manager_class, identification) pairs.
|
|
1114
|
+
Each identification is a dict of identification fields. The list excludes the (manager_class, instance.identification) pair and contains no duplicates.
|
|
1115
|
+
"""
|
|
1116
|
+
dependencies: list[tuple[type[GeneralManager], dict[str, Any]]] = []
|
|
1117
|
+
seen: set[tuple[str, str]] = set()
|
|
1118
|
+
if dependency_records:
|
|
1119
|
+
for dependency_class, dependency_identification in cls._dependencies_from_tracker(
|
|
1120
|
+
dependency_records
|
|
1121
|
+
):
|
|
1122
|
+
if (
|
|
1123
|
+
dependency_class is manager_class
|
|
1124
|
+
and dependency_identification == instance.identification
|
|
1125
|
+
):
|
|
1126
|
+
continue
|
|
1127
|
+
key = (dependency_class.__name__, repr(dependency_identification))
|
|
1128
|
+
if key in seen:
|
|
1129
|
+
continue
|
|
1130
|
+
seen.add(key)
|
|
1131
|
+
dependencies.append((dependency_class, dependency_identification))
|
|
1132
|
+
interface_cls = manager_class.Interface
|
|
1133
|
+
|
|
1134
|
+
for (
|
|
1135
|
+
input_name,
|
|
1136
|
+
input_field,
|
|
1137
|
+
) in interface_cls.input_fields.items():
|
|
1138
|
+
if not issubclass(input_field.type, GeneralManager):
|
|
1139
|
+
continue
|
|
1140
|
+
|
|
1141
|
+
raw_value = instance._interface.identification.get(input_name)
|
|
1142
|
+
if raw_value is None:
|
|
1143
|
+
continue
|
|
1144
|
+
|
|
1145
|
+
values = raw_value if isinstance(raw_value, list) else [raw_value]
|
|
1146
|
+
for value in values:
|
|
1147
|
+
if isinstance(value, GeneralManager):
|
|
1148
|
+
identification = deepcopy(value.identification)
|
|
1149
|
+
key = (input_field.type.__name__, repr(identification))
|
|
1150
|
+
if key in seen:
|
|
1151
|
+
continue
|
|
1152
|
+
seen.add(key)
|
|
1153
|
+
dependencies.append(
|
|
1154
|
+
(
|
|
1155
|
+
cast(type[GeneralManager], input_field.type),
|
|
1156
|
+
identification,
|
|
1157
|
+
)
|
|
1158
|
+
)
|
|
1159
|
+
elif isinstance(value, dict):
|
|
1160
|
+
identification_dict = deepcopy(cast(dict[str, Any], value))
|
|
1161
|
+
key = (input_field.type.__name__, repr(identification_dict))
|
|
1162
|
+
if key in seen:
|
|
1163
|
+
continue
|
|
1164
|
+
seen.add(key)
|
|
1165
|
+
dependencies.append(
|
|
1166
|
+
(
|
|
1167
|
+
cast(type[GeneralManager], input_field.type),
|
|
1168
|
+
identification_dict,
|
|
1169
|
+
)
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
return dependencies
|
|
1173
|
+
|
|
1174
|
+
@staticmethod
|
|
1175
|
+
def _instantiate_manager(
|
|
1176
|
+
manager_class: type[GeneralManager],
|
|
1177
|
+
identification: dict[str, Any],
|
|
1178
|
+
*,
|
|
1179
|
+
collect_dependencies: bool = False,
|
|
1180
|
+
property_names: Iterable[str] | None = None,
|
|
1181
|
+
) -> tuple[GeneralManager, set[Dependency]]:
|
|
1182
|
+
"""
|
|
1183
|
+
Create a GeneralManager instance for the given identification and optionally prime its GraphQL properties to capture dependency records.
|
|
1184
|
+
|
|
1185
|
+
Parameters:
|
|
1186
|
+
manager_class (type[GeneralManager]): Manager class to instantiate.
|
|
1187
|
+
identification (dict[str, Any]): Mapping of identification field names to values used to construct the instance.
|
|
1188
|
+
collect_dependencies (bool): If True, prime GraphQL properties while tracking and return the captured Dependency records.
|
|
1189
|
+
property_names (Iterable[str] | None): Specific GraphQLProperty names to prime; if None, all relevant properties are primed.
|
|
1190
|
+
|
|
1191
|
+
Returns:
|
|
1192
|
+
tuple[GeneralManager, set[Dependency]]: The instantiated manager and a set of captured Dependency objects (empty if collect_dependencies is False).
|
|
1193
|
+
"""
|
|
1194
|
+
if collect_dependencies:
|
|
1195
|
+
with DependencyTracker() as captured_dependencies:
|
|
1196
|
+
instance = manager_class(**identification)
|
|
1197
|
+
GraphQL._prime_graphql_properties(instance, property_names)
|
|
1198
|
+
return instance, captured_dependencies
|
|
1199
|
+
|
|
1200
|
+
instance = manager_class(**identification)
|
|
1201
|
+
return instance, set()
|
|
1202
|
+
|
|
1203
|
+
@classmethod
|
|
1204
|
+
def _addSubscriptionField(
|
|
1205
|
+
cls, graphene_type: type[graphene.ObjectType], generalManagerClass: Type[GeneralManager]
|
|
1206
|
+
) -> None:
|
|
1207
|
+
"""
|
|
1208
|
+
Register a GraphQL subscription field that publishes change events for the given manager type.
|
|
1209
|
+
|
|
1210
|
+
Creates (or reuses) a SubscriptionEvent payload GraphQL type and adds three entries to the class subscription registry:
|
|
1211
|
+
- a field exposing the subscription with identification arguments,
|
|
1212
|
+
- an async subscribe function that yields an initial "snapshot" event and subsequent change events for the identified instance and its dependencies,
|
|
1213
|
+
- and a resolve function that returns the delivered payload.
|
|
1214
|
+
|
|
1215
|
+
Parameters:
|
|
1216
|
+
graphene_type (type[graphene.ObjectType]): GraphQL ObjectType representing the manager's item type used as the payload `item` field.
|
|
1217
|
+
generalManagerClass (Type[GeneralManager]): The GeneralManager subclass whose changes the subscription will publish.
|
|
1218
|
+
|
|
1219
|
+
Notes:
|
|
1220
|
+
- The subscribe function requires an available channel layer and subscribes the caller to channel groups derived from the instance identification and its resolved dependencies.
|
|
1221
|
+
- 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).
|
|
1222
|
+
- On termination the subscription cleans up listener tasks and unsubscribes from channel groups.
|
|
1223
|
+
"""
|
|
1224
|
+
field_name = f"on_{generalManagerClass.__name__.lower()}_change"
|
|
1225
|
+
if field_name in cls._subscription_fields:
|
|
1226
|
+
return
|
|
1227
|
+
|
|
1228
|
+
payload_type = cls._subscription_payload_registry.get(
|
|
1229
|
+
generalManagerClass.__name__
|
|
1230
|
+
)
|
|
1231
|
+
if payload_type is None:
|
|
1232
|
+
payload_type = type(
|
|
1233
|
+
f"{generalManagerClass.__name__}SubscriptionEvent",
|
|
1234
|
+
(graphene.ObjectType,),
|
|
1235
|
+
{
|
|
1236
|
+
"item": graphene.Field(graphene_type),
|
|
1237
|
+
"action": graphene.String(required=True),
|
|
1238
|
+
},
|
|
1239
|
+
)
|
|
1240
|
+
cls._subscription_payload_registry[
|
|
1241
|
+
generalManagerClass.__name__
|
|
1242
|
+
] = payload_type
|
|
1243
|
+
|
|
1244
|
+
identification_args = cls._buildIdentificationArguments(generalManagerClass)
|
|
1245
|
+
subscription_field = graphene.Field(payload_type, **identification_args)
|
|
1246
|
+
|
|
1247
|
+
async def subscribe(
|
|
1248
|
+
_root: Any,
|
|
1249
|
+
info: GraphQLResolveInfo,
|
|
1250
|
+
**identification: Any,
|
|
1251
|
+
) -> AsyncIterator[SubscriptionEvent]:
|
|
1252
|
+
"""
|
|
1253
|
+
Open a subscription stream for a specific manager instance identified by the provided arguments.
|
|
1254
|
+
|
|
1255
|
+
The iterator first yields a `SubscriptionEvent` with action `"snapshot"` containing the current item, then yields subsequent `SubscriptionEvent`s for each received action. If an item cannot be re-instantiated while producing an update event, the yielded `SubscriptionEvent.item` will be `None`. The subscription registers the caller on the manager's channel groups and automatically unsubscribes and cancels background listeners when the iterator is closed or cancelled.
|
|
1256
|
+
|
|
1257
|
+
Parameters:
|
|
1258
|
+
identification: Identification fields required to locate the manager instance (maps to the manager's identification signature).
|
|
1259
|
+
|
|
1260
|
+
Returns:
|
|
1261
|
+
AsyncIterator[SubscriptionEvent]: An asynchronous iterator that produces subscription events (initial snapshot then updates).
|
|
1262
|
+
"""
|
|
1263
|
+
identification_copy = deepcopy(identification)
|
|
1264
|
+
property_names = cls._subscription_property_names(
|
|
1265
|
+
info, cast(type[GeneralManager], generalManagerClass)
|
|
1266
|
+
)
|
|
1267
|
+
try:
|
|
1268
|
+
instance, dependency_records = await asyncio.to_thread(
|
|
1269
|
+
cls._instantiate_manager,
|
|
1270
|
+
cast(type[GeneralManager], generalManagerClass),
|
|
1271
|
+
identification_copy,
|
|
1272
|
+
collect_dependencies=True,
|
|
1273
|
+
property_names=property_names,
|
|
1274
|
+
)
|
|
1275
|
+
except Exception as exc: # pragma: no cover - bubbled to GraphQL
|
|
1276
|
+
raise GraphQLError(str(exc)) from exc
|
|
812
1277
|
|
|
1278
|
+
try:
|
|
1279
|
+
channel_layer = cast(BaseChannelLayer, cls._get_channel_layer(strict=True))
|
|
1280
|
+
except RuntimeError as exc:
|
|
1281
|
+
raise GraphQLError(str(exc)) from exc
|
|
1282
|
+
channel_name = cast(str, await channel_layer.new_channel())
|
|
1283
|
+
queue: asyncio.Queue[str] = asyncio.Queue[str]()
|
|
1284
|
+
|
|
1285
|
+
group_names = {
|
|
1286
|
+
cls._group_name(
|
|
1287
|
+
cast(type[GeneralManager], generalManagerClass),
|
|
1288
|
+
instance.identification,
|
|
1289
|
+
)
|
|
1290
|
+
}
|
|
1291
|
+
dependencies = cls._resolve_subscription_dependencies(
|
|
1292
|
+
cast(type[GeneralManager], generalManagerClass),
|
|
1293
|
+
instance,
|
|
1294
|
+
dependency_records,
|
|
1295
|
+
)
|
|
1296
|
+
for dependency_class, dependency_identification in dependencies:
|
|
1297
|
+
group_names.add(
|
|
1298
|
+
cls._group_name(dependency_class, dependency_identification)
|
|
1299
|
+
)
|
|
1300
|
+
|
|
1301
|
+
for group in group_names:
|
|
1302
|
+
await channel_layer.group_add(group, channel_name)
|
|
1303
|
+
|
|
1304
|
+
listener_task = asyncio.create_task(
|
|
1305
|
+
cls._channel_listener(channel_layer, channel_name, queue)
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
async def event_stream() -> AsyncIterator[SubscriptionEvent]:
|
|
1309
|
+
"""
|
|
1310
|
+
Asynchronously yields subscription events for a manager instance: an initial "snapshot" event followed by events for subsequent actions.
|
|
1311
|
+
|
|
1312
|
+
Yields:
|
|
1313
|
+
SubscriptionEvent: First yields a `SubscriptionEvent` with `action` set to "snapshot" and `item` containing the current manager instance (or `None` if instantiation failed). Afterwards yields `SubscriptionEvent` values for each action received, where `action` is the action string and `item` is the (re-)instantiated manager or `None` if instantiation failed.
|
|
1314
|
+
|
|
1315
|
+
Notes:
|
|
1316
|
+
On exit the function cancels the background listener task and discards the channel subscriptions for all groups associated with the subscription.
|
|
1317
|
+
"""
|
|
1318
|
+
try:
|
|
1319
|
+
yield SubscriptionEvent(item=instance, action="snapshot")
|
|
1320
|
+
while True:
|
|
1321
|
+
action = await queue.get()
|
|
1322
|
+
try:
|
|
1323
|
+
item, _ = await asyncio.to_thread(
|
|
1324
|
+
cls._instantiate_manager,
|
|
1325
|
+
cast(type[GeneralManager], generalManagerClass),
|
|
1326
|
+
identification_copy,
|
|
1327
|
+
property_names=property_names,
|
|
1328
|
+
)
|
|
1329
|
+
except Exception:
|
|
1330
|
+
item = None
|
|
1331
|
+
yield SubscriptionEvent(item=item, action=action)
|
|
1332
|
+
finally:
|
|
1333
|
+
listener_task.cancel()
|
|
1334
|
+
with suppress(asyncio.CancelledError):
|
|
1335
|
+
await listener_task
|
|
1336
|
+
for group in group_names:
|
|
1337
|
+
await channel_layer.group_discard(group, channel_name)
|
|
1338
|
+
|
|
1339
|
+
return event_stream()
|
|
1340
|
+
|
|
1341
|
+
def resolve(
|
|
1342
|
+
payload: SubscriptionEvent,
|
|
1343
|
+
info: GraphQLResolveInfo,
|
|
1344
|
+
**_: Any,
|
|
1345
|
+
) -> SubscriptionEvent:
|
|
1346
|
+
"""
|
|
1347
|
+
Passes a subscription payload through unchanged.
|
|
1348
|
+
|
|
1349
|
+
Parameters:
|
|
1350
|
+
payload (SubscriptionEvent): The subscription event payload to deliver to the client.
|
|
1351
|
+
info (GraphQLResolveInfo): GraphQL resolver info (unused).
|
|
1352
|
+
|
|
1353
|
+
Returns:
|
|
1354
|
+
SubscriptionEvent: The same payload instance provided as input.
|
|
1355
|
+
"""
|
|
1356
|
+
return payload
|
|
1357
|
+
|
|
1358
|
+
cls._subscription_fields[field_name] = subscription_field
|
|
1359
|
+
cls._subscription_fields[f"subscribe_{field_name}"] = subscribe
|
|
1360
|
+
cls._subscription_fields[f"resolve_{field_name}"] = resolve
|
|
1361
|
+
|
|
1362
|
+
@classmethod
|
|
1363
|
+
def createWriteFields(cls, interface_cls: InterfaceBase) -> dict[str, Any]:
|
|
1364
|
+
"""
|
|
1365
|
+
Generate Graphene input fields for writable attributes defined by an Interface.
|
|
1366
|
+
|
|
1367
|
+
Skips system fields ("changed_by", "created_at", "updated_at") and derived attributes.
|
|
1368
|
+
For relation attributes referencing another GeneralManager, produces ID or list-of-ID fields.
|
|
1369
|
+
Each generated field is annotated with an `editable` attribute reflecting the interface metadata.
|
|
1370
|
+
Always includes an optional "history_comment" String field marked editable.
|
|
1371
|
+
|
|
1372
|
+
Parameters:
|
|
1373
|
+
interface_cls (InterfaceBase): Interface whose attribute metadata (types, required, default, editable, derived) is used to build input fields.
|
|
1374
|
+
|
|
813
1375
|
Returns:
|
|
814
|
-
dict[str, Any]: Mapping
|
|
1376
|
+
dict[str, Any]: Mapping from attribute name to a Graphene input field instance.
|
|
815
1377
|
"""
|
|
816
1378
|
fields: dict[str, Any] = {}
|
|
817
1379
|
|
|
@@ -1075,13 +1637,18 @@ class GraphQL:
|
|
|
1075
1637
|
@staticmethod
|
|
1076
1638
|
def _handleGraphQLError(error: Exception) -> None:
|
|
1077
1639
|
"""
|
|
1078
|
-
|
|
1079
|
-
|
|
1640
|
+
Convert an exception into a GraphQL error with an appropriate extensions['code'].
|
|
1641
|
+
|
|
1642
|
+
Maps:
|
|
1643
|
+
PermissionError -> "PERMISSION_DENIED"
|
|
1644
|
+
ValueError, ValidationError, TypeError -> "BAD_USER_INPUT"
|
|
1645
|
+
other exceptions -> "INTERNAL_SERVER_ERROR"
|
|
1646
|
+
|
|
1080
1647
|
Parameters:
|
|
1081
|
-
error (Exception):
|
|
1082
|
-
|
|
1648
|
+
error (Exception): The original exception to convert.
|
|
1649
|
+
|
|
1083
1650
|
Raises:
|
|
1084
|
-
GraphQLError:
|
|
1651
|
+
GraphQLError: GraphQL error containing the original message and an `extensions['code']` indicating the error category.
|
|
1085
1652
|
"""
|
|
1086
1653
|
if isinstance(error, PermissionError):
|
|
1087
1654
|
raise GraphQLError(
|
|
@@ -1104,3 +1671,51 @@ class GraphQL:
|
|
|
1104
1671
|
"code": "INTERNAL_SERVER_ERROR",
|
|
1105
1672
|
},
|
|
1106
1673
|
)
|
|
1674
|
+
|
|
1675
|
+
@classmethod
|
|
1676
|
+
def _handle_data_change(
|
|
1677
|
+
cls,
|
|
1678
|
+
sender: type[GeneralManager] | GeneralManager,
|
|
1679
|
+
instance: GeneralManager | None,
|
|
1680
|
+
action: str,
|
|
1681
|
+
**_: Any,
|
|
1682
|
+
) -> None:
|
|
1683
|
+
"""
|
|
1684
|
+
Send a subscription event to the channel group for a changed GeneralManager instance.
|
|
1685
|
+
|
|
1686
|
+
If `instance` is a GeneralManager of a registered manager type and a channel layer is available,
|
|
1687
|
+
publish a "gm.subscription.event" message containing the provided `action` to the group identified
|
|
1688
|
+
by the manager class and the instance's identification. If `instance` is missing, the manager is
|
|
1689
|
+
not registered, or no channel layer is available, the function returns without side effects.
|
|
1690
|
+
|
|
1691
|
+
Parameters:
|
|
1692
|
+
sender: The signal sender; either a GeneralManager subclass or an instance.
|
|
1693
|
+
instance: The GeneralManager instance that changed.
|
|
1694
|
+
action: A string describing the change action (e.g., "created", "updated", "deleted").
|
|
1695
|
+
"""
|
|
1696
|
+
if instance is None or not isinstance(instance, GeneralManager):
|
|
1697
|
+
return
|
|
1698
|
+
|
|
1699
|
+
if isinstance(sender, type) and issubclass(sender, GeneralManager):
|
|
1700
|
+
manager_class: type[GeneralManager] = sender
|
|
1701
|
+
else:
|
|
1702
|
+
manager_class = instance.__class__
|
|
1703
|
+
|
|
1704
|
+
if manager_class.__name__ not in cls.manager_registry:
|
|
1705
|
+
return
|
|
1706
|
+
|
|
1707
|
+
channel_layer = cls._get_channel_layer()
|
|
1708
|
+
if channel_layer is None:
|
|
1709
|
+
return
|
|
1710
|
+
|
|
1711
|
+
group_name = cls._group_name(manager_class, instance.identification)
|
|
1712
|
+
async_to_sync(channel_layer.group_send)(
|
|
1713
|
+
group_name,
|
|
1714
|
+
{
|
|
1715
|
+
"type": "gm.subscription.event",
|
|
1716
|
+
"action": action,
|
|
1717
|
+
},
|
|
1718
|
+
)
|
|
1719
|
+
|
|
1720
|
+
|
|
1721
|
+
post_data_change.connect(GraphQL._handle_data_change, weak=False)
|