GeneralManager 0.17.0__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.

Files changed (67) hide show
  1. general_manager/__init__.py +11 -1
  2. general_manager/_types/api.py +0 -1
  3. general_manager/_types/bucket.py +0 -1
  4. general_manager/_types/cache.py +0 -1
  5. general_manager/_types/factory.py +0 -1
  6. general_manager/_types/general_manager.py +0 -1
  7. general_manager/_types/interface.py +0 -1
  8. general_manager/_types/manager.py +0 -1
  9. general_manager/_types/measurement.py +0 -1
  10. general_manager/_types/permission.py +0 -1
  11. general_manager/_types/rule.py +0 -1
  12. general_manager/_types/utils.py +0 -1
  13. general_manager/api/__init__.py +13 -1
  14. general_manager/api/graphql.py +356 -221
  15. general_manager/api/graphql_subscription_consumer.py +81 -78
  16. general_manager/api/mutation.py +85 -23
  17. general_manager/api/property.py +39 -13
  18. general_manager/apps.py +188 -47
  19. general_manager/bucket/__init__.py +10 -1
  20. general_manager/bucket/calculationBucket.py +155 -53
  21. general_manager/bucket/databaseBucket.py +157 -45
  22. general_manager/bucket/groupBucket.py +133 -44
  23. general_manager/cache/__init__.py +10 -1
  24. general_manager/cache/dependencyIndex.py +143 -45
  25. general_manager/cache/signals.py +9 -2
  26. general_manager/factory/__init__.py +10 -1
  27. general_manager/factory/autoFactory.py +55 -13
  28. general_manager/factory/factories.py +110 -40
  29. general_manager/factory/factoryMethods.py +122 -34
  30. general_manager/interface/__init__.py +10 -1
  31. general_manager/interface/baseInterface.py +129 -36
  32. general_manager/interface/calculationInterface.py +35 -18
  33. general_manager/interface/databaseBasedInterface.py +71 -45
  34. general_manager/interface/databaseInterface.py +96 -38
  35. general_manager/interface/models.py +5 -5
  36. general_manager/interface/readOnlyInterface.py +94 -20
  37. general_manager/manager/__init__.py +10 -1
  38. general_manager/manager/generalManager.py +25 -16
  39. general_manager/manager/groupManager.py +20 -6
  40. general_manager/manager/meta.py +84 -16
  41. general_manager/measurement/__init__.py +10 -1
  42. general_manager/measurement/measurement.py +289 -95
  43. general_manager/measurement/measurementField.py +42 -31
  44. general_manager/permission/__init__.py +10 -1
  45. general_manager/permission/basePermission.py +120 -38
  46. general_manager/permission/managerBasedPermission.py +72 -21
  47. general_manager/permission/mutationPermission.py +14 -9
  48. general_manager/permission/permissionChecks.py +14 -12
  49. general_manager/permission/permissionDataManager.py +24 -11
  50. general_manager/permission/utils.py +34 -6
  51. general_manager/public_api_registry.py +36 -10
  52. general_manager/rule/__init__.py +10 -1
  53. general_manager/rule/handler.py +133 -44
  54. general_manager/rule/rule.py +178 -39
  55. general_manager/utils/__init__.py +10 -1
  56. general_manager/utils/argsToKwargs.py +34 -9
  57. general_manager/utils/filterParser.py +22 -7
  58. general_manager/utils/formatString.py +1 -0
  59. general_manager/utils/pathMapping.py +23 -15
  60. general_manager/utils/public_api.py +33 -2
  61. general_manager/utils/testing.py +31 -33
  62. {generalmanager-0.17.0.dist-info → generalmanager-0.18.0.dist-info}/METADATA +2 -1
  63. generalmanager-0.18.0.dist-info/RECORD +77 -0
  64. {generalmanager-0.17.0.dist-info → generalmanager-0.18.0.dist-info}/licenses/LICENSE +1 -1
  65. generalmanager-0.17.0.dist-info/RECORD +0 -77
  66. {generalmanager-0.17.0.dist-info → generalmanager-0.18.0.dist-info}/WHEEL +0 -0
  67. {generalmanager-0.17.0.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 and return its primary key.
74
+ Create a new model instance using the provided field values.
28
75
 
29
76
  Parameters:
30
- creator_id (int | None): Identifier of the user creating the instance.
31
- history_comment (str | None): Optional comment stored in the history log.
32
- **kwargs (Any): Field values used to populate the model.
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
- ValueError: If unknown fields are supplied.
39
- ValidationError: Propagated when model validation fails.
85
+ UnknownFieldError: If kwargs contain names that do not correspond to model fields.
86
+ ValidationError: If model validation fails during save.
40
87
  """
41
- cls._checkForInvalidKwargs(cls._model, kwargs=kwargs)
42
- kwargs, many_to_many_kwargs = cls._sortKwargs(cls._model, kwargs)
43
- instance = cls.__setAttrForWrite(cls._model(), kwargs)
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 the current model instance and return its primary key.
100
+ Update this instance with the provided field values.
53
101
 
54
102
  Parameters:
55
- creator_id (int | None): Identifier of the user performing the update.
56
- history_comment (str | None): Optional comment stored in the history log.
57
- **kwargs (Any): Field updates applied to the model.
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
- ValueError: If unknown fields are supplied.
64
- ValidationError: Propagated when model validation fails.
111
+ UnknownFieldError: If any provided kwarg does not correspond to a model field.
112
+ ValidationError: If model validation fails during save.
65
113
  """
66
- self._checkForInvalidKwargs(self._model, kwargs=kwargs)
67
- kwargs, many_to_many_kwargs = self._sortKwargs(self._model, kwargs)
68
- instance = self.__setAttrForWrite(self._model.objects.get(pk=self.pk), kwargs)
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
- instance = self._model.objects.get(pk=self.pk)
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 the instance before saving.
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): Model instance that will receive the values.
135
- kwargs (dict[str, Any]): Key-value pairs to assign to the instance.
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: Instance with updated attributes.
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 e:
151
- raise ValueError(f"Invalid value for {key}: {value}") from e
152
- except TypeError as e:
153
- raise TypeError(f"Type error for {key}: {e}") from e
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
- Ensure provided keyword arguments map to known fields or attributes.
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 being validated.
165
- kwargs (dict[str, Any]): Keyword arguments supplied by the caller.
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
- ValueError: If an unknown field name is encountered.
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 ValueError(f"{key} does not exist in {model.__name__}")
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
- Split keyword arguments into simple fields and many-to-many relations.
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 (type[models.Model]): Model whose relation metadata is inspected.
186
- kwargs (dict[Any, Any]): Keyword arguments supplied by the caller.
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]]]: Tuple containing simple-field kwargs and many-to-many kwargs.
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, value in list(kwargs.items()):
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
- Set the user responsible for the most recent change to the model instance.
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
- setattr(self, "changed_by", value)
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
- Return names of fields that uniquely identify instances of ``model``.
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 inspected for uniqueness metadata.
96
+ model (type[models.Model]): Django model to inspect.
42
97
 
43
98
  Returns:
44
- set[str]: Field names that participate in unique constraints.
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
- """Synchronise the backing model with the class-level JSON data."""
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 ValueError(
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 TypeError("_data JSON must decode to a list of dictionaries")
148
+ raise InvalidReadOnlyDataFormatError()
86
149
  elif isinstance(json_data, list):
87
150
  parsed_data = json_data
88
151
  else:
89
- raise TypeError("_data must be a JSON string or a list of dictionaries")
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 ValueError(
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
- lookup = {field: data[field] for field in unique_fields}
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
- for field_name, value in data.items():
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
- f"Database table does not exist!",
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: F401,F403
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
- Instantiate the manager by delegating to the interface and record its identification.
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 interface constructor.
26
- **kwargs: Keyword arguments forwarded to the interface constructor.
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
- Merge this manager with another manager or bucket.
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]): Manager instance or bucket to combine.
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 both inputs.
77
+ Bucket[Self]: A Bucket containing the union of the managed objects represented by this manager and `other`.
66
78
 
67
79
  Raises:
68
- TypeError: If `other` is neither a compatible bucket nor manager.
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 TypeError(f"Unsupported type for union: {type(other)}")
87
+ raise UnsupportedUnionOperandError(type(other))
76
88
 
77
89
  def __eq__(
78
90
  self,
79
91
  other: object,
80
92
  ) -> bool:
81
93
  """
82
- Compare managers based on their identification values.
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
- bool: True when `other` is a manager with the same identification.
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
@@ -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 attribute values across the grouped records.
129
+ Aggregate the values of a named attribute across all records in the group.
116
130
 
117
131
  Parameters:
118
- item (str): Name of the attribute to aggregate.
132
+ item (str): Attribute name to aggregate from each grouped record.
119
133
 
120
134
  Returns:
121
- Any: Aggregated value corresponding to `item`.
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
- AttributeError: If the attribute is not defined on the manager or property set.
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 AttributeError(f"{self.__class__.__name__} has no attribute {item}")
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]