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
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
from typing import (
|
|
5
|
-
Type,
|
|
6
5
|
Any,
|
|
6
|
+
Type,
|
|
7
|
+
cast,
|
|
7
8
|
)
|
|
8
9
|
from django.db import models, transaction
|
|
9
10
|
from simple_history.utils import update_change_reason # type: ignore
|
|
@@ -14,6 +15,52 @@ from general_manager.interface.databaseBasedInterface import (
|
|
|
14
15
|
from django.db.models import NOT_PROVIDED
|
|
15
16
|
|
|
16
17
|
|
|
18
|
+
class InvalidFieldValueError(ValueError):
|
|
19
|
+
"""Raised when assigning a value incompatible with the model field."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, field_name: str, value: object) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Initialize an InvalidFieldValueError for a specific model field and value.
|
|
24
|
+
|
|
25
|
+
Parameters:
|
|
26
|
+
field_name (str): Name of the field that received an invalid value.
|
|
27
|
+
value (object): The invalid value provided; included in the exception message.
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
super().__init__(f"Invalid value for {field_name}: {value}.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InvalidFieldTypeError(TypeError):
|
|
34
|
+
"""Raised when assigning a value with an unexpected type."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, field_name: str, error: Exception) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Initialize the InvalidFieldTypeError with the field name and the originating exception.
|
|
39
|
+
|
|
40
|
+
Parameters:
|
|
41
|
+
field_name (str): Name of the model field that received an unexpected type.
|
|
42
|
+
error (Exception): The original exception or error encountered for the field.
|
|
43
|
+
|
|
44
|
+
Notes:
|
|
45
|
+
The exception's message is formatted as "Type error for {field_name}: {error}."
|
|
46
|
+
"""
|
|
47
|
+
super().__init__(f"Type error for {field_name}: {error}.")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class UnknownFieldError(ValueError):
|
|
51
|
+
"""Raised when keyword arguments reference fields not present on the model."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, field_name: str, model_name: str) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Initialize an UnknownFieldError indicating a field name is not present on a model.
|
|
56
|
+
|
|
57
|
+
Parameters:
|
|
58
|
+
field_name (str): The field name that was not found on the model.
|
|
59
|
+
model_name (str): The name of the model in which the field was expected.
|
|
60
|
+
"""
|
|
61
|
+
super().__init__(f"{field_name} does not exist in {model_name}.")
|
|
62
|
+
|
|
63
|
+
|
|
17
64
|
class DatabaseInterface(DBBasedInterface[GeneralManagerModel]):
|
|
18
65
|
"""CRUD-capable interface backed by a concrete Django model."""
|
|
19
66
|
|
|
@@ -24,23 +71,24 @@ class DatabaseInterface(DBBasedInterface[GeneralManagerModel]):
|
|
|
24
71
|
cls, creator_id: int | None, history_comment: str | None = None, **kwargs: Any
|
|
25
72
|
) -> int:
|
|
26
73
|
"""
|
|
27
|
-
Create a new model instance
|
|
74
|
+
Create a new model instance using the provided field values.
|
|
28
75
|
|
|
29
76
|
Parameters:
|
|
30
|
-
creator_id (int | None):
|
|
31
|
-
history_comment (str | None): Optional comment
|
|
32
|
-
**kwargs
|
|
77
|
+
creator_id (int | None): ID of the user to record as the change author, or None to leave unset.
|
|
78
|
+
history_comment (str | None): Optional comment to attach to the instance history.
|
|
79
|
+
**kwargs: Field values used to populate the model; many-to-many relations may be provided as `<field>_id_list`.
|
|
33
80
|
|
|
34
81
|
Returns:
|
|
35
82
|
int: Primary key of the newly created instance.
|
|
36
83
|
|
|
37
84
|
Raises:
|
|
38
|
-
|
|
39
|
-
ValidationError:
|
|
85
|
+
UnknownFieldError: If kwargs contain names that do not correspond to model fields.
|
|
86
|
+
ValidationError: If model validation fails during save.
|
|
40
87
|
"""
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
88
|
+
model_cls = cast(type[GeneralManagerModel], cls._model)
|
|
89
|
+
cls._checkForInvalidKwargs(model_cls, kwargs=kwargs)
|
|
90
|
+
kwargs, many_to_many_kwargs = cls._sortKwargs(model_cls, kwargs)
|
|
91
|
+
instance = cls.__setAttrForWrite(model_cls(), kwargs)
|
|
44
92
|
pk = cls._save_with_history(instance, creator_id, history_comment)
|
|
45
93
|
cls.__setManyToManyAttributes(instance, many_to_many_kwargs)
|
|
46
94
|
return pk
|
|
@@ -49,23 +97,24 @@ class DatabaseInterface(DBBasedInterface[GeneralManagerModel]):
|
|
|
49
97
|
self, creator_id: int | None, history_comment: str | None = None, **kwargs: Any
|
|
50
98
|
) -> int:
|
|
51
99
|
"""
|
|
52
|
-
Update
|
|
100
|
+
Update this instance with the provided field values.
|
|
53
101
|
|
|
54
102
|
Parameters:
|
|
55
|
-
creator_id (int | None):
|
|
56
|
-
history_comment (str | None): Optional comment
|
|
57
|
-
**kwargs (Any): Field
|
|
103
|
+
creator_id (int | None): ID of the user recording the change; used to set `changed_by_id`.
|
|
104
|
+
history_comment (str | None): Optional comment to attach to the instance's change history.
|
|
105
|
+
**kwargs (Any): Field names and values to apply to the instance; many-to-many updates may be supplied using the `<relation>_id_list` convention.
|
|
58
106
|
|
|
59
107
|
Returns:
|
|
60
108
|
int: Primary key of the updated instance.
|
|
61
109
|
|
|
62
110
|
Raises:
|
|
63
|
-
|
|
64
|
-
ValidationError:
|
|
111
|
+
UnknownFieldError: If any provided kwarg does not correspond to a model field.
|
|
112
|
+
ValidationError: If model validation fails during save.
|
|
65
113
|
"""
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
114
|
+
model_cls = cast(type[GeneralManagerModel], self._model)
|
|
115
|
+
self._checkForInvalidKwargs(model_cls, kwargs=kwargs)
|
|
116
|
+
kwargs, many_to_many_kwargs = self._sortKwargs(model_cls, kwargs)
|
|
117
|
+
instance = self.__setAttrForWrite(model_cls.objects.get(pk=self.pk), kwargs)
|
|
69
118
|
pk = self._save_with_history(instance, creator_id, history_comment)
|
|
70
119
|
self.__setManyToManyAttributes(instance, many_to_many_kwargs)
|
|
71
120
|
return pk
|
|
@@ -83,7 +132,8 @@ class DatabaseInterface(DBBasedInterface[GeneralManagerModel]):
|
|
|
83
132
|
Returns:
|
|
84
133
|
int: Primary key of the deactivated instance.
|
|
85
134
|
"""
|
|
86
|
-
|
|
135
|
+
model_cls = cast(type[GeneralManagerModel], self._model)
|
|
136
|
+
instance = model_cls.objects.get(pk=self.pk)
|
|
87
137
|
instance.is_active = False
|
|
88
138
|
if history_comment:
|
|
89
139
|
history_comment = f"{history_comment} (deactivated)"
|
|
@@ -128,14 +178,20 @@ class DatabaseInterface(DBBasedInterface[GeneralManagerModel]):
|
|
|
128
178
|
kwargs: dict[str, Any],
|
|
129
179
|
) -> GeneralManagerModel:
|
|
130
180
|
"""
|
|
131
|
-
Populate non-relational fields on
|
|
181
|
+
Populate non-relational fields on an instance and prepare values for writing.
|
|
182
|
+
|
|
183
|
+
Converts any GeneralManager value to its `id` and appends `_id` to the attribute name, skips values equal to `NOT_PROVIDED`, sets each attribute on the instance, and translates underlying `ValueError`/`TypeError` from attribute assignment into `InvalidFieldValueError` and `InvalidFieldTypeError` respectively.
|
|
132
184
|
|
|
133
185
|
Parameters:
|
|
134
|
-
instance (GeneralManagerModel):
|
|
135
|
-
kwargs (dict[str, Any]):
|
|
186
|
+
instance (GeneralManagerModel): The model instance to modify.
|
|
187
|
+
kwargs (dict[str, Any]): Mapping of attribute names to values to apply.
|
|
136
188
|
|
|
137
189
|
Returns:
|
|
138
|
-
GeneralManagerModel:
|
|
190
|
+
GeneralManagerModel: The same instance with attributes updated.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
InvalidFieldValueError: If setting an attribute raises a `ValueError`.
|
|
194
|
+
InvalidFieldTypeError: If setting an attribute raises a `TypeError`.
|
|
139
195
|
"""
|
|
140
196
|
from general_manager.manager.generalManager import GeneralManager
|
|
141
197
|
|
|
@@ -147,10 +203,10 @@ class DatabaseInterface(DBBasedInterface[GeneralManagerModel]):
|
|
|
147
203
|
continue
|
|
148
204
|
try:
|
|
149
205
|
setattr(instance, key, value)
|
|
150
|
-
except ValueError as
|
|
151
|
-
raise
|
|
152
|
-
except TypeError as
|
|
153
|
-
raise
|
|
206
|
+
except ValueError as error:
|
|
207
|
+
raise InvalidFieldValueError(key, value) from error
|
|
208
|
+
except TypeError as error:
|
|
209
|
+
raise InvalidFieldTypeError(key, error) from error
|
|
154
210
|
return instance
|
|
155
211
|
|
|
156
212
|
@staticmethod
|
|
@@ -158,39 +214,41 @@ class DatabaseInterface(DBBasedInterface[GeneralManagerModel]):
|
|
|
158
214
|
model: Type[models.Model], kwargs: dict[str, Any]
|
|
159
215
|
) -> None:
|
|
160
216
|
"""
|
|
161
|
-
|
|
217
|
+
Validate that each key in `kwargs` corresponds to an attribute or field on `model`.
|
|
162
218
|
|
|
163
219
|
Parameters:
|
|
164
|
-
model (type[models.Model]): Django model
|
|
165
|
-
kwargs (dict[str, Any]):
|
|
220
|
+
model (type[models.Model]): The Django model class to validate against.
|
|
221
|
+
kwargs (dict[str, Any]): Mapping of keyword names to values; keys ending with `_id_list` are validated after stripping that suffix.
|
|
166
222
|
|
|
167
223
|
Raises:
|
|
168
|
-
|
|
224
|
+
UnknownFieldError: If any provided key (after removing a trailing `_id_list`) does not match a model attribute or field name.
|
|
169
225
|
"""
|
|
170
226
|
attributes = vars(model)
|
|
171
227
|
field_names = {f.name for f in model._meta.get_fields()}
|
|
172
228
|
for key in kwargs:
|
|
173
229
|
temp_key = key.split("_id_list")[0] # Remove '_id_list' suffix
|
|
174
230
|
if temp_key not in attributes and temp_key not in field_names:
|
|
175
|
-
raise
|
|
231
|
+
raise UnknownFieldError(key, model.__name__)
|
|
176
232
|
|
|
177
233
|
@staticmethod
|
|
178
234
|
def _sortKwargs(
|
|
179
235
|
model: Type[models.Model], kwargs: dict[Any, Any]
|
|
180
236
|
) -> tuple[dict[str, Any], dict[str, list[Any]]]:
|
|
181
237
|
"""
|
|
182
|
-
|
|
238
|
+
Separate provided kwargs into simple model-field arguments and many-to-many relation arguments.
|
|
239
|
+
|
|
240
|
+
This function removes keys targeting many-to-many relations from the input kwargs and returns them separately. A many-to-many key is identified by the suffix "_id_list" whose base name matches a many-to-many field on the given model.
|
|
183
241
|
|
|
184
242
|
Parameters:
|
|
185
|
-
model (
|
|
186
|
-
kwargs (dict[Any, Any]):
|
|
243
|
+
model (Type[models.Model]): Django model whose many-to-many field names are inspected.
|
|
244
|
+
kwargs (dict[Any, Any]): Mapping of keyword arguments to partition; keys matching many-to-many relations are removed in-place.
|
|
187
245
|
|
|
188
246
|
Returns:
|
|
189
|
-
tuple[dict[str, Any], dict[str, list[Any]]]:
|
|
247
|
+
tuple[dict[str, Any], dict[str, list[Any]]]: A tuple where the first element is the original kwargs dict with many-to-many keys removed, and the second element maps the removed many-to-many keys to their values.
|
|
190
248
|
"""
|
|
191
249
|
many_to_many_fields = [field.name for field in model._meta.many_to_many]
|
|
192
250
|
many_to_many_kwargs: dict[Any, Any] = {}
|
|
193
|
-
for key,
|
|
251
|
+
for key, _value in list(kwargs.items()):
|
|
194
252
|
many_to_many_key = key.split("_id_list")[0]
|
|
195
253
|
if many_to_many_key in many_to_many_fields:
|
|
196
254
|
many_to_many_kwargs[key] = kwargs.pop(key)
|
|
@@ -58,7 +58,7 @@ class GeneralManagerBasisModel(models.Model):
|
|
|
58
58
|
"""Abstract base model providing shared fields for GeneralManager storage."""
|
|
59
59
|
|
|
60
60
|
_general_manager_class: ClassVar[Type[GeneralManager]]
|
|
61
|
-
is_active = models.BooleanField(default=True)
|
|
61
|
+
is_active = models.BooleanField(default=True) # type: ignore[var-annotated]
|
|
62
62
|
history = HistoricalRecords(inherit=True)
|
|
63
63
|
|
|
64
64
|
class Meta:
|
|
@@ -70,7 +70,7 @@ class GeneralManagerModel(GeneralManagerBasisModel):
|
|
|
70
70
|
|
|
71
71
|
changed_by = models.ForeignKey(
|
|
72
72
|
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True
|
|
73
|
-
)
|
|
73
|
+
) # type: ignore[var-annotated]
|
|
74
74
|
changed_by_id: int | None
|
|
75
75
|
|
|
76
76
|
@property
|
|
@@ -83,12 +83,12 @@ class GeneralManagerModel(GeneralManagerBasisModel):
|
|
|
83
83
|
@_history_user.setter
|
|
84
84
|
def _history_user(self, value: AbstractUser | None) -> None:
|
|
85
85
|
"""
|
|
86
|
-
|
|
86
|
+
Assign the given user as the author of the most recent change recorded for this model instance.
|
|
87
87
|
|
|
88
88
|
Parameters:
|
|
89
|
-
value (AbstractUser): The user to associate with the latest modification.
|
|
89
|
+
value (AbstractUser | None): The user to associate with the latest modification, or `None` to clear the recorded user.
|
|
90
90
|
"""
|
|
91
|
-
|
|
91
|
+
self.changed_by = value
|
|
92
92
|
|
|
93
93
|
class Meta: # type: ignore
|
|
94
94
|
abstract = True
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
import json
|
|
5
5
|
|
|
6
|
-
from typing import Type, Any, Callable, TYPE_CHECKING, cast
|
|
6
|
+
from typing import Type, Any, Callable, TYPE_CHECKING, cast, ClassVar
|
|
7
7
|
from django.db import models, transaction
|
|
8
8
|
from general_manager.interface.databaseBasedInterface import (
|
|
9
9
|
DBBasedInterface,
|
|
@@ -15,7 +15,6 @@ from general_manager.interface.databaseBasedInterface import (
|
|
|
15
15
|
interfaceBaseClass,
|
|
16
16
|
)
|
|
17
17
|
from django.db import connection
|
|
18
|
-
from typing import ClassVar
|
|
19
18
|
from django.core.checks import Warning
|
|
20
19
|
import logging
|
|
21
20
|
|
|
@@ -26,22 +25,78 @@ if TYPE_CHECKING:
|
|
|
26
25
|
logger = logging.getLogger(__name__)
|
|
27
26
|
|
|
28
27
|
|
|
28
|
+
class MissingReadOnlyDataError(ValueError):
|
|
29
|
+
"""Raised when a ReadOnlyInterface lacks the required `_data` attribute."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, interface_name: str) -> None:
|
|
32
|
+
"""
|
|
33
|
+
Exception raised when a ReadOnlyInterface is missing the required `_data` attribute.
|
|
34
|
+
|
|
35
|
+
Parameters:
|
|
36
|
+
interface_name (str): Name of the interface class; used to construct the exception message.
|
|
37
|
+
"""
|
|
38
|
+
super().__init__(
|
|
39
|
+
f"ReadOnlyInterface '{interface_name}' must define a '_data' attribute."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MissingUniqueFieldError(ValueError):
|
|
44
|
+
"""Raised when a ReadOnlyInterface has no unique fields defined."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, interface_name: str) -> None:
|
|
47
|
+
"""
|
|
48
|
+
Initialize an error for a read-only interface that defines no unique fields.
|
|
49
|
+
|
|
50
|
+
Parameters:
|
|
51
|
+
interface_name (str): Name of the interface class missing at least one unique field; this name is included in the exception message.
|
|
52
|
+
"""
|
|
53
|
+
super().__init__(
|
|
54
|
+
f"ReadOnlyInterface '{interface_name}' must declare at least one unique field."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class InvalidReadOnlyDataFormatError(TypeError):
|
|
59
|
+
"""Raised when the `_data` JSON does not decode to a list of dictionaries."""
|
|
60
|
+
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
"""
|
|
63
|
+
Exception raised when the `_data` JSON does not decode to a list of dictionaries.
|
|
64
|
+
|
|
65
|
+
Initializes the exception with the message "_data JSON must decode to a list of dictionaries."
|
|
66
|
+
"""
|
|
67
|
+
super().__init__("_data JSON must decode to a list of dictionaries.")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class InvalidReadOnlyDataTypeError(TypeError):
|
|
71
|
+
"""Raised when the `_data` attribute is neither JSON string nor list."""
|
|
72
|
+
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
"""
|
|
75
|
+
Initialize the InvalidReadOnlyDataTypeError with a standard error message.
|
|
76
|
+
|
|
77
|
+
Raises a TypeError indicating that the `_data` attribute must be either a JSON string or a list of dictionaries.
|
|
78
|
+
"""
|
|
79
|
+
super().__init__("_data must be a JSON string or a list of dictionaries.")
|
|
80
|
+
|
|
81
|
+
|
|
29
82
|
class ReadOnlyInterface(DBBasedInterface[GeneralManagerBasisModel]):
|
|
30
83
|
"""Interface that reads static JSON data into a managed read-only model."""
|
|
31
84
|
|
|
32
|
-
_interface_type = "readonly"
|
|
33
|
-
_parent_class: Type[GeneralManager]
|
|
85
|
+
_interface_type: ClassVar[str] = "readonly"
|
|
86
|
+
_parent_class: ClassVar[Type["GeneralManager"]]
|
|
34
87
|
|
|
35
88
|
@staticmethod
|
|
36
89
|
def getUniqueFields(model: Type[models.Model]) -> set[str]:
|
|
37
90
|
"""
|
|
38
|
-
|
|
91
|
+
Determine which fields on the given Django model uniquely identify its instances.
|
|
92
|
+
|
|
93
|
+
The result includes fields declared with `unique=True` (excluding a primary key named "id"), any fields in `unique_together` tuples, and fields referenced by `UniqueConstraint` objects.
|
|
39
94
|
|
|
40
95
|
Parameters:
|
|
41
|
-
model (type[models.Model]): Django model
|
|
96
|
+
model (type[models.Model]): Django model to inspect.
|
|
42
97
|
|
|
43
98
|
Returns:
|
|
44
|
-
set[str]:
|
|
99
|
+
set[str]: Names of fields that participate in unique constraints for the model.
|
|
45
100
|
"""
|
|
46
101
|
opts = model._meta
|
|
47
102
|
unique_fields: set[str] = set()
|
|
@@ -63,7 +118,17 @@ class ReadOnlyInterface(DBBasedInterface[GeneralManagerBasisModel]):
|
|
|
63
118
|
|
|
64
119
|
@classmethod
|
|
65
120
|
def syncData(cls) -> None:
|
|
66
|
-
"""
|
|
121
|
+
"""
|
|
122
|
+
Synchronize the Django model with the parent manager's class-level `_data` JSON.
|
|
123
|
+
|
|
124
|
+
Parses the parent class's `_data` (JSON string or list of dicts), ensures the model schema is up to date, and within a single transaction creates, updates, or deactivates model instances to match the parsed data. Newly created or updated instances are marked `is_active = True`; existing active instances absent from the data are marked `is_active = False`. Logs a summary when any changes occur.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
MissingReadOnlyDataError: If the parent manager class does not define `_data`.
|
|
128
|
+
InvalidReadOnlyDataFormatError: If `_data` is a JSON string that does not decode to a list of dictionaries.
|
|
129
|
+
InvalidReadOnlyDataTypeError: If `_data` is neither a string nor a list.
|
|
130
|
+
MissingUniqueFieldError: If the model exposes no unique fields to identify records.
|
|
131
|
+
"""
|
|
67
132
|
if cls.ensureSchemaIsUpToDate(cls._parent_class, cls._model):
|
|
68
133
|
logger.warning(
|
|
69
134
|
f"Schema for ReadOnlyInterface '{cls._parent_class.__name__}' is not up to date."
|
|
@@ -74,27 +139,23 @@ class ReadOnlyInterface(DBBasedInterface[GeneralManagerBasisModel]):
|
|
|
74
139
|
parent_class = cls._parent_class
|
|
75
140
|
json_data = getattr(parent_class, "_data", None)
|
|
76
141
|
if json_data is None:
|
|
77
|
-
raise
|
|
78
|
-
f"For ReadOnlyInterface '{parent_class.__name__}' must set '_data'"
|
|
79
|
-
)
|
|
142
|
+
raise MissingReadOnlyDataError(parent_class.__name__)
|
|
80
143
|
|
|
81
144
|
# Parse JSON into Python structures
|
|
82
145
|
if isinstance(json_data, str):
|
|
83
146
|
parsed_data = json.loads(json_data)
|
|
84
147
|
if not isinstance(parsed_data, list):
|
|
85
|
-
raise
|
|
148
|
+
raise InvalidReadOnlyDataFormatError()
|
|
86
149
|
elif isinstance(json_data, list):
|
|
87
150
|
parsed_data = json_data
|
|
88
151
|
else:
|
|
89
|
-
raise
|
|
152
|
+
raise InvalidReadOnlyDataTypeError()
|
|
90
153
|
|
|
91
154
|
data_list = cast(list[dict[str, Any]], parsed_data)
|
|
92
155
|
|
|
93
156
|
unique_fields = cls.getUniqueFields(model)
|
|
94
157
|
if not unique_fields:
|
|
95
|
-
raise
|
|
96
|
-
f"For ReadOnlyInterface '{parent_class.__name__}' must have at least one unique field."
|
|
97
|
-
)
|
|
158
|
+
raise MissingUniqueFieldError(parent_class.__name__)
|
|
98
159
|
|
|
99
160
|
changes: dict[str, list[models.Model]] = {
|
|
100
161
|
"created": [],
|
|
@@ -106,14 +167,27 @@ class ReadOnlyInterface(DBBasedInterface[GeneralManagerBasisModel]):
|
|
|
106
167
|
json_unique_values: set[Any] = set()
|
|
107
168
|
|
|
108
169
|
# data synchronization
|
|
109
|
-
for data in data_list:
|
|
110
|
-
|
|
170
|
+
for idx, data in enumerate(data_list):
|
|
171
|
+
try:
|
|
172
|
+
lookup = {field: data[field] for field in unique_fields}
|
|
173
|
+
except KeyError as e:
|
|
174
|
+
missing = e.args[0]
|
|
175
|
+
raise InvalidReadOnlyDataFormatError() from KeyError(
|
|
176
|
+
f"Item {idx} missing unique field '{missing}'."
|
|
177
|
+
)
|
|
111
178
|
unique_identifier = tuple(lookup[field] for field in unique_fields)
|
|
112
179
|
json_unique_values.add(unique_identifier)
|
|
113
180
|
|
|
114
181
|
instance, is_created = model.objects.get_or_create(**lookup)
|
|
115
182
|
updated = False
|
|
116
|
-
|
|
183
|
+
editable_fields = {
|
|
184
|
+
f.name
|
|
185
|
+
for f in model._meta.local_fields
|
|
186
|
+
if getattr(f, "editable", True)
|
|
187
|
+
and not getattr(f, "primary_key", False)
|
|
188
|
+
} - {"is_active"}
|
|
189
|
+
for field_name in editable_fields.intersection(data.keys()):
|
|
190
|
+
value = data[field_name]
|
|
117
191
|
if getattr(instance, field_name, None) != value:
|
|
118
192
|
setattr(instance, field_name, value)
|
|
119
193
|
updated = True
|
|
@@ -192,7 +266,7 @@ class ReadOnlyInterface(DBBasedInterface[GeneralManagerBasisModel]):
|
|
|
192
266
|
if not table_exists(table):
|
|
193
267
|
return [
|
|
194
268
|
Warning(
|
|
195
|
-
|
|
269
|
+
"Database table does not exist!",
|
|
196
270
|
hint=f"ReadOnlyInterface '{new_manager_class.__name__}' (Table '{table}') does not exist in the database.",
|
|
197
271
|
obj=model,
|
|
198
272
|
)
|
|
@@ -12,10 +12,19 @@ __all__ = list(MANAGER_EXPORTS)
|
|
|
12
12
|
_MODULE_MAP = MANAGER_EXPORTS
|
|
13
13
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
15
|
-
from general_manager._types.manager import * # noqa:
|
|
15
|
+
from general_manager._types.manager import * # noqa: F403
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def __getattr__(name: str) -> Any:
|
|
19
|
+
"""
|
|
20
|
+
Resolve and return a public export by name for dynamic attribute access.
|
|
21
|
+
|
|
22
|
+
Parameters:
|
|
23
|
+
name (str): The attribute name to resolve.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Any: The object bound to the requested public name.
|
|
27
|
+
"""
|
|
19
28
|
return resolve_export(
|
|
20
29
|
name,
|
|
21
30
|
module_all=__all__,
|
|
@@ -7,6 +7,20 @@ from general_manager.cache.cacheTracker import DependencyTracker
|
|
|
7
7
|
from general_manager.cache.signals import dataChange
|
|
8
8
|
from general_manager.bucket.baseBucket import Bucket
|
|
9
9
|
|
|
10
|
+
|
|
11
|
+
class UnsupportedUnionOperandError(TypeError):
|
|
12
|
+
"""Raised when attempting to union a manager with an incompatible operand."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, operand_type: type) -> None:
|
|
15
|
+
"""
|
|
16
|
+
Exception raised when attempting to perform a union with an unsupported operand type.
|
|
17
|
+
|
|
18
|
+
Parameters:
|
|
19
|
+
operand_type (type): The operand type that is not supported for the union; its representation is included in the exception message.
|
|
20
|
+
"""
|
|
21
|
+
super().__init__(f"Unsupported type for union: {operand_type}.")
|
|
22
|
+
|
|
23
|
+
|
|
10
24
|
if TYPE_CHECKING:
|
|
11
25
|
from general_manager.permission.basePermission import BasePermission
|
|
12
26
|
from general_manager.interface.baseInterface import InterfaceBase
|
|
@@ -16,17 +30,15 @@ class GeneralManager(metaclass=GeneralManagerMeta):
|
|
|
16
30
|
Permission: Type[BasePermission]
|
|
17
31
|
_attributes: dict[str, Any]
|
|
18
32
|
Interface: Type["InterfaceBase"]
|
|
33
|
+
_old_values: dict[str, Any]
|
|
19
34
|
|
|
20
35
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
21
36
|
"""
|
|
22
|
-
|
|
37
|
+
Create a manager by constructing its Interface and record the resulting identification.
|
|
23
38
|
|
|
24
39
|
Parameters:
|
|
25
|
-
*args: Positional arguments forwarded to the
|
|
26
|
-
**kwargs: Keyword arguments forwarded to the
|
|
27
|
-
|
|
28
|
-
Returns:
|
|
29
|
-
None
|
|
40
|
+
*args: Positional arguments forwarded to the Interface constructor.
|
|
41
|
+
**kwargs: Keyword arguments forwarded to the Interface constructor.
|
|
30
42
|
"""
|
|
31
43
|
self._interface = self.Interface(*args, **kwargs)
|
|
32
44
|
self.__id: dict[str, Any] = self._interface.identification
|
|
@@ -56,36 +68,33 @@ class GeneralManager(metaclass=GeneralManagerMeta):
|
|
|
56
68
|
other: Self | Bucket[Self],
|
|
57
69
|
) -> Bucket[Self]:
|
|
58
70
|
"""
|
|
59
|
-
|
|
71
|
+
Combine this manager with another manager or a Bucket into a Bucket representing their union.
|
|
60
72
|
|
|
61
73
|
Parameters:
|
|
62
|
-
other (Self | Bucket[Self]):
|
|
74
|
+
other (Self | Bucket[Self]): A manager of the same class or a Bucket to union with.
|
|
63
75
|
|
|
64
76
|
Returns:
|
|
65
|
-
Bucket[Self]: Bucket containing the union of
|
|
77
|
+
Bucket[Self]: A Bucket containing the union of the managed objects represented by this manager and `other`.
|
|
66
78
|
|
|
67
79
|
Raises:
|
|
68
|
-
|
|
80
|
+
UnsupportedUnionOperandError: If `other` is not a Bucket and not a GeneralManager instance of the same class.
|
|
69
81
|
"""
|
|
70
82
|
if isinstance(other, Bucket):
|
|
71
83
|
return other | self
|
|
72
84
|
elif isinstance(other, GeneralManager) and other.__class__ == self.__class__:
|
|
73
85
|
return self.filter(id__in=[self.__id, other.__id])
|
|
74
86
|
else:
|
|
75
|
-
raise
|
|
87
|
+
raise UnsupportedUnionOperandError(type(other))
|
|
76
88
|
|
|
77
89
|
def __eq__(
|
|
78
90
|
self,
|
|
79
91
|
other: object,
|
|
80
92
|
) -> bool:
|
|
81
93
|
"""
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
Parameters:
|
|
85
|
-
other (object): Object to compare against this manager.
|
|
94
|
+
Determine whether another object represents the same managed entity.
|
|
86
95
|
|
|
87
96
|
Returns:
|
|
88
|
-
|
|
97
|
+
`true` if `other` is a `GeneralManager` whose identification equals this manager's, `false` otherwise.
|
|
89
98
|
"""
|
|
90
99
|
if not isinstance(other, GeneralManager):
|
|
91
100
|
return False
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
from typing import Any, Generic, Iterator, Type, cast, get_args
|
|
5
5
|
from datetime import datetime, date, time
|
|
6
|
-
from general_manager.api.
|
|
6
|
+
from general_manager.api.property import GraphQLProperty
|
|
7
7
|
from general_manager.measurement import Measurement
|
|
8
8
|
from general_manager.manager.generalManager import GeneralManager
|
|
9
9
|
from general_manager.bucket.baseBucket import (
|
|
@@ -12,6 +12,20 @@ from general_manager.bucket.baseBucket import (
|
|
|
12
12
|
)
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
class MissingGroupAttributeError(AttributeError):
|
|
16
|
+
"""Raised when a GroupManager access attempts to use an undefined attribute."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, manager_name: str, attribute: str) -> None:
|
|
19
|
+
"""
|
|
20
|
+
Initialize the exception indicating that a GroupManager attempted to access an undefined attribute.
|
|
21
|
+
|
|
22
|
+
Parameters:
|
|
23
|
+
manager_name (str): Name of the manager where the attribute access occurred.
|
|
24
|
+
attribute (str): The missing attribute name that was accessed.
|
|
25
|
+
"""
|
|
26
|
+
super().__init__(f"{manager_name} has no attribute {attribute}.")
|
|
27
|
+
|
|
28
|
+
|
|
15
29
|
class GroupManager(Generic[GeneralManagerType]):
|
|
16
30
|
"""Represent aggregated results for grouped GeneralManager records."""
|
|
17
31
|
|
|
@@ -112,16 +126,16 @@ class GroupManager(Generic[GeneralManagerType]):
|
|
|
112
126
|
|
|
113
127
|
def combineValue(self, item: str) -> Any:
|
|
114
128
|
"""
|
|
115
|
-
Aggregate
|
|
129
|
+
Aggregate the values of a named attribute across all records in the group.
|
|
116
130
|
|
|
117
131
|
Parameters:
|
|
118
|
-
item (str):
|
|
132
|
+
item (str): Attribute name to aggregate from each grouped record.
|
|
119
133
|
|
|
120
134
|
Returns:
|
|
121
|
-
Any:
|
|
135
|
+
Any: The aggregated value for `item` according to its type (e.g., merged Bucket/GeneralManager, concatenated list, merged dict, deduplicated comma-separated string, boolean OR, numeric sum, or latest datetime). Returns `None` if all values are `None` or if `item` is `"id"`.
|
|
122
136
|
|
|
123
137
|
Raises:
|
|
124
|
-
|
|
138
|
+
MissingGroupAttributeError: If the attribute does not exist or its type cannot be determined on the manager.
|
|
125
139
|
"""
|
|
126
140
|
if item == "id":
|
|
127
141
|
return None
|
|
@@ -139,13 +153,13 @@ class GroupManager(Generic[GeneralManagerType]):
|
|
|
139
153
|
else cast(type, attr_value.graphql_type_hint)
|
|
140
154
|
)
|
|
141
155
|
if data_type is None or not isinstance(data_type, type):
|
|
142
|
-
raise
|
|
156
|
+
raise MissingGroupAttributeError(self.__class__.__name__, item)
|
|
143
157
|
|
|
144
158
|
total_data = []
|
|
145
159
|
for entry in self._data:
|
|
146
160
|
total_data.append(getattr(entry, item))
|
|
147
161
|
|
|
148
|
-
new_data = None
|
|
162
|
+
new_data: Any = None
|
|
149
163
|
if all([i is None for i in total_data]):
|
|
150
164
|
return new_data
|
|
151
165
|
total_data = [i for i in total_data if i is not None]
|