GeneralManager 0.12.0__tar.gz → 0.12.1__tar.gz
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.
- {generalmanager-0.12.0 → generalmanager-0.12.1}/GeneralManager.egg-info/PKG-INFO +1 -1
- {generalmanager-0.12.0 → generalmanager-0.12.1}/PKG-INFO +1 -1
- {generalmanager-0.12.0 → generalmanager-0.12.1}/pyproject.toml +1 -1
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/api/graphql.py +124 -103
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/measurement/measurement.py +14 -12
- generalmanager-0.12.1/src/general_manager/measurement/measurementField.py +271 -0
- generalmanager-0.12.0/src/general_manager/measurement/measurementField.py +0 -168
- {generalmanager-0.12.0 → generalmanager-0.12.1}/GeneralManager.egg-info/SOURCES.txt +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/GeneralManager.egg-info/dependency_links.txt +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/GeneralManager.egg-info/requires.txt +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/GeneralManager.egg-info/top_level.txt +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/LICENSE +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/README.md +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/setup.cfg +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/__init__.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/api/mutation.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/api/property.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/apps.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/bucket/baseBucket.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/bucket/calculationBucket.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/bucket/databaseBucket.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/bucket/groupBucket.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/cache/cacheDecorator.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/cache/cacheTracker.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/cache/dependencyIndex.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/cache/modelDependencyCollector.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/cache/signals.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/factory/__init__.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/factory/autoFactory.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/factory/factories.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/factory/factoryMethods.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/interface/__init__.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/interface/baseInterface.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/interface/calculationInterface.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/interface/databaseBasedInterface.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/interface/databaseInterface.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/interface/models.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/interface/readOnlyInterface.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/manager/__init__.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/manager/generalManager.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/manager/groupManager.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/manager/input.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/manager/meta.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/measurement/__init__.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/permission/__init__.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/permission/basePermission.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/permission/fileBasedPermission.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/permission/managerBasedPermission.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/permission/mutationPermission.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/permission/permissionChecks.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/permission/permissionDataManager.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/permission/utils.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/rule/__init__.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/rule/handler.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/rule/rule.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/utils/__init__.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/utils/argsToKwargs.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/utils/filterParser.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/utils/formatString.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/utils/jsonEncoder.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/utils/makeCacheKey.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/utils/noneToZero.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/utils/pathMapping.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/utils/testing.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/tests/test_settings.py +0 -0
- {generalmanager-0.12.0 → generalmanager-0.12.1}/tests/test_urls.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: GeneralManager
|
3
|
-
Version: 0.12.
|
3
|
+
Version: 0.12.1
|
4
4
|
Summary: Modular Django-based data management framework with ORM, GraphQL, fine-grained permissions, rule validation, calculations and caching.
|
5
5
|
Author-email: Tim Kleindick <tkleindick@yahoo.de>
|
6
6
|
License-Expression: MIT
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: GeneralManager
|
3
|
-
Version: 0.12.
|
3
|
+
Version: 0.12.1
|
4
4
|
Summary: Modular Django-based data management framework with ORM, GraphQL, fine-grained permissions, rule validation, calculations and caching.
|
5
5
|
Author-email: Tim Kleindick <tkleindick@yahoo.de>
|
6
6
|
License-Expression: MIT
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
4
4
|
|
5
5
|
[project]
|
6
6
|
name = "GeneralManager"
|
7
|
-
version = "0.12.
|
7
|
+
version = "0.12.1"
|
8
8
|
description = "Modular Django-based data management framework with ORM, GraphQL, fine-grained permissions, rule validation, calculations and caching."
|
9
9
|
readme = "README.md"
|
10
10
|
authors = [{ name = "Tim Kleindick", email = "tkleindick@yahoo.de" }]
|
@@ -4,6 +4,7 @@ from typing import Any, Callable, get_args, TYPE_CHECKING, cast, Type
|
|
4
4
|
from decimal import Decimal
|
5
5
|
from datetime import date, datetime
|
6
6
|
import json
|
7
|
+
from graphql.language import ast
|
7
8
|
|
8
9
|
from general_manager.measurement.measurement import Measurement
|
9
10
|
from general_manager.manager.generalManager import GeneralManagerMeta, GeneralManager
|
@@ -26,6 +27,28 @@ class MeasurementType(graphene.ObjectType):
|
|
26
27
|
unit = graphene.String()
|
27
28
|
|
28
29
|
|
30
|
+
class MeasurementScalar(graphene.Scalar):
|
31
|
+
"""
|
32
|
+
A measurement in format "value unit", e.g. "12.5 m/s".
|
33
|
+
"""
|
34
|
+
|
35
|
+
@staticmethod
|
36
|
+
def serialize(value: Measurement) -> str:
|
37
|
+
if not isinstance(value, Measurement):
|
38
|
+
raise TypeError(f"Expected Measurement, got {type(value)}")
|
39
|
+
return str(value)
|
40
|
+
|
41
|
+
@staticmethod
|
42
|
+
def parse_value(value: str) -> Measurement:
|
43
|
+
return Measurement.from_string(value)
|
44
|
+
|
45
|
+
@staticmethod
|
46
|
+
def parse_literal(node: Any) -> Measurement | None:
|
47
|
+
if isinstance(node, ast.StringValueNode):
|
48
|
+
return Measurement.from_string(node.value)
|
49
|
+
return None
|
50
|
+
|
51
|
+
|
29
52
|
class PageInfo(graphene.ObjectType):
|
30
53
|
total_count = graphene.Int(required=True)
|
31
54
|
page_size = graphene.Int(required=False)
|
@@ -38,9 +61,9 @@ def getReadPermissionFilter(
|
|
38
61
|
info: GraphQLResolveInfo,
|
39
62
|
) -> list[tuple[dict[str, Any], dict[str, Any]]]:
|
40
63
|
"""
|
41
|
-
Returns
|
42
|
-
|
43
|
-
|
64
|
+
Returns permission-based filter and exclude constraints for querying instances of a manager class.
|
65
|
+
|
66
|
+
For the given manager class and user context, retrieves a list of (filter, exclude) dictionary pairs that represent the read access restrictions to be applied to queries. Returns an empty list if no permission class is defined.
|
44
67
|
"""
|
45
68
|
filters = []
|
46
69
|
PermissionClass: type[BasePermission] | None = getattr(
|
@@ -74,9 +97,9 @@ class GraphQL:
|
|
74
97
|
@classmethod
|
75
98
|
def createGraphqlMutation(cls, generalManagerClass: type[GeneralManager]) -> None:
|
76
99
|
"""
|
77
|
-
|
78
|
-
|
79
|
-
For each supported mutation,
|
100
|
+
Generates and registers GraphQL mutation classes for create, update, and delete operations on the specified manager class if its interface provides custom implementations.
|
101
|
+
|
102
|
+
For each supported mutation, a corresponding GraphQL mutation class is created and added to the mutation registry, enabling dynamic mutation support in the schema.
|
80
103
|
"""
|
81
104
|
|
82
105
|
interface_cls: InterfaceBase | None = getattr(
|
@@ -112,9 +135,9 @@ class GraphQL:
|
|
112
135
|
@classmethod
|
113
136
|
def createGraphqlInterface(cls, generalManagerClass: GeneralManagerMeta) -> None:
|
114
137
|
"""
|
115
|
-
Creates and registers a GraphQL ObjectType for
|
116
|
-
|
117
|
-
|
138
|
+
Creates and registers a GraphQL ObjectType for a GeneralManager subclass.
|
139
|
+
|
140
|
+
Introspects the manager's interface and GraphQLProperty fields, maps them to Graphene fields with appropriate resolvers, registers the resulting type in the internal registry, and adds corresponding query fields to the schema.
|
118
141
|
"""
|
119
142
|
interface_cls: InterfaceBase | None = getattr(
|
120
143
|
generalManagerClass, "Interface", None
|
@@ -155,10 +178,10 @@ class GraphQL:
|
|
155
178
|
generalManagerClass: GeneralManagerMeta,
|
156
179
|
) -> type[graphene.Enum] | None:
|
157
180
|
"""
|
158
|
-
|
159
|
-
|
181
|
+
Creates a Graphene Enum type representing sortable fields for a given GeneralManager class.
|
182
|
+
|
160
183
|
Returns:
|
161
|
-
A Graphene Enum type with options for each sortable attribute, including separate
|
184
|
+
A Graphene Enum type with options for each sortable attribute, including separate entries for the value and unit of Measurement fields. Returns None if there are no sortable fields.
|
162
185
|
"""
|
163
186
|
sort_options = []
|
164
187
|
for (
|
@@ -168,9 +191,6 @@ class GraphQL:
|
|
168
191
|
field_type = field_info["type"]
|
169
192
|
if issubclass(field_type, GeneralManager):
|
170
193
|
continue
|
171
|
-
elif issubclass(field_type, Measurement):
|
172
|
-
sort_options.append(f"{field_name}_value")
|
173
|
-
sort_options.append(f"{field_name}_unit")
|
174
194
|
else:
|
175
195
|
sort_options.append(field_name)
|
176
196
|
|
@@ -189,8 +209,15 @@ class GraphQL:
|
|
189
209
|
) -> type[graphene.InputObjectType] | None:
|
190
210
|
"""
|
191
211
|
Dynamically generates a Graphene InputObjectType for filtering fields of a GeneralManager subclass.
|
192
|
-
|
212
|
+
|
193
213
|
Creates filter fields for each attribute based on its type, supporting numeric and string filter operations, and specialized handling for Measurement attributes. Returns the generated InputObjectType, or None if no applicable filter fields exist.
|
214
|
+
|
215
|
+
Parameters:
|
216
|
+
field_name (str): The name of the field to generate filter options for.
|
217
|
+
field_type (GeneralManagerMeta): The manager class whose attributes are used to build filter fields.
|
218
|
+
|
219
|
+
Returns:
|
220
|
+
type[graphene.InputObjectType] | None: The generated filter input type, or None if no filter fields are applicable.
|
194
221
|
"""
|
195
222
|
number_options = ["exact", "gt", "gte", "lt", "lte"]
|
196
223
|
string_options = [
|
@@ -212,11 +239,9 @@ class GraphQL:
|
|
212
239
|
if issubclass(attr_type, GeneralManager):
|
213
240
|
continue
|
214
241
|
elif issubclass(attr_type, Measurement):
|
215
|
-
filter_fields[f"{attr_name}
|
216
|
-
filter_fields[f"{attr_name}_unit"] = graphene.String()
|
242
|
+
filter_fields[f"{attr_name}"] = MeasurementScalar()
|
217
243
|
for option in number_options:
|
218
|
-
filter_fields[f"{attr_name}
|
219
|
-
filter_fields[f"{attr_name}_unit__{option}"] = graphene.String()
|
244
|
+
filter_fields[f"{attr_name}__{option}"] = MeasurementScalar()
|
220
245
|
else:
|
221
246
|
filter_fields[attr_name] = GraphQL._mapFieldToGrapheneRead(
|
222
247
|
attr_type, attr_name
|
@@ -246,8 +271,8 @@ class GraphQL:
|
|
246
271
|
def _mapFieldToGrapheneRead(field_type: type, field_name: str) -> Any:
|
247
272
|
"""
|
248
273
|
Maps a Python field type and name to the appropriate Graphene field for GraphQL schema generation.
|
249
|
-
|
250
|
-
For `Measurement` fields, returns a field with an optional `target_unit` argument. For `GeneralManager` subclasses, returns a paginated field with filtering, exclusion, sorting, pagination, and grouping arguments if the field name ends with `_list`; otherwise, returns a single object field. For all other types, returns the corresponding Graphene scalar field.
|
274
|
+
|
275
|
+
For `Measurement` fields, returns a Graphene field with an optional `target_unit` argument. For `GeneralManager` subclasses, returns a paginated field with filtering, exclusion, sorting, pagination, and grouping arguments if the field name ends with `_list`; otherwise, returns a single object field. For all other types, returns the corresponding Graphene scalar field.
|
251
276
|
"""
|
252
277
|
if issubclass(field_type, Measurement):
|
253
278
|
return graphene.Field(MeasurementType, target_unit=graphene.String())
|
@@ -299,6 +324,8 @@ class GraphQL:
|
|
299
324
|
return graphene.Float
|
300
325
|
elif issubclass(field_type, (date, datetime)):
|
301
326
|
return graphene.Date
|
327
|
+
elif issubclass(field_type, Measurement):
|
328
|
+
return MeasurementScalar
|
302
329
|
else:
|
303
330
|
return graphene.String
|
304
331
|
|
@@ -325,17 +352,16 @@ class GraphQL:
|
|
325
352
|
reverse: bool,
|
326
353
|
) -> Bucket[GeneralManager]:
|
327
354
|
"""
|
328
|
-
|
329
|
-
|
355
|
+
Apply filtering, exclusion, and sorting to a queryset based on provided parameters.
|
356
|
+
|
330
357
|
Parameters:
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
358
|
+
filter_input: Filters to apply, as a dictionary or JSON string.
|
359
|
+
exclude_input: Exclusions to apply, as a dictionary or JSON string.
|
360
|
+
sort_by: Field to sort by, as a Graphene Enum value.
|
361
|
+
reverse: If True, reverses the sort order.
|
362
|
+
|
337
363
|
Returns:
|
338
|
-
|
364
|
+
The queryset after applying filters, exclusions, and sorting.
|
339
365
|
"""
|
340
366
|
filters = GraphQL._parseInput(filter_input)
|
341
367
|
if filters:
|
@@ -389,9 +415,9 @@ class GraphQL:
|
|
389
415
|
base_getter: Callable[[Any], Any], fallback_manager_class: type[GeneralManager]
|
390
416
|
) -> Callable[..., Any]:
|
391
417
|
"""
|
392
|
-
Creates a resolver for GraphQL list fields that returns paginated, filtered, sorted, and optionally grouped results with permission checks
|
393
|
-
|
394
|
-
The
|
418
|
+
Creates a resolver for GraphQL list fields that returns paginated, filtered, sorted, and optionally grouped results with permission checks.
|
419
|
+
|
420
|
+
The generated resolver applies permission-based filtering, user-specified filters and exclusions, sorting, grouping, and pagination to the list field. It returns a dictionary containing the resulting items and pagination metadata.
|
395
421
|
"""
|
396
422
|
|
397
423
|
def resolver(
|
@@ -406,19 +432,19 @@ class GraphQL:
|
|
406
432
|
group_by: list[str] | None = None,
|
407
433
|
) -> dict[str, Any]:
|
408
434
|
"""
|
409
|
-
Resolves a list field by returning
|
410
|
-
|
435
|
+
Resolves a list field by returning filtered, excluded, sorted, grouped, and paginated results with permission checks.
|
436
|
+
|
411
437
|
Parameters:
|
412
|
-
filter
|
413
|
-
exclude
|
414
|
-
sort_by
|
415
|
-
reverse
|
416
|
-
page
|
417
|
-
page_size
|
418
|
-
group_by
|
419
|
-
|
438
|
+
filter: Filter criteria as a dictionary or JSON string.
|
439
|
+
exclude: Exclusion criteria as a dictionary or JSON string.
|
440
|
+
sort_by: Field to sort by, as a Graphene Enum.
|
441
|
+
reverse: If True, reverses the sort order.
|
442
|
+
page: Page number for pagination.
|
443
|
+
page_size: Number of items per page.
|
444
|
+
group_by: List of field names to group results by.
|
445
|
+
|
420
446
|
Returns:
|
421
|
-
|
447
|
+
A dictionary containing the paginated items under "items" and pagination metadata under "pageInfo".
|
422
448
|
"""
|
423
449
|
base_queryset = base_getter(self)
|
424
450
|
# use _manager_class from the attribute if available, otherwise fallback
|
@@ -453,9 +479,16 @@ class GraphQL:
|
|
453
479
|
queryset: Bucket[GeneralManager], page: int | None, page_size: int | None
|
454
480
|
) -> Bucket[GeneralManager]:
|
455
481
|
"""
|
456
|
-
|
457
|
-
|
458
|
-
If
|
482
|
+
Returns a paginated subset of the queryset based on the given page number and page size.
|
483
|
+
|
484
|
+
If neither `page` nor `page_size` is provided, the entire queryset is returned. Defaults to page 1 and page size 10 if only one parameter is specified.
|
485
|
+
|
486
|
+
Parameters:
|
487
|
+
page (int | None): The page number to retrieve (1-based).
|
488
|
+
page_size (int | None): The number of items per page.
|
489
|
+
|
490
|
+
Returns:
|
491
|
+
Bucket[GeneralManager]: The paginated queryset.
|
459
492
|
"""
|
460
493
|
if page is not None or page_size is not None:
|
461
494
|
page = page or 1
|
@@ -469,8 +502,8 @@ class GraphQL:
|
|
469
502
|
queryset: Bucket[GeneralManager], group_by: list[str] | None
|
470
503
|
) -> Bucket[GeneralManager]:
|
471
504
|
"""
|
472
|
-
|
473
|
-
|
505
|
+
Groups the queryset by the specified fields.
|
506
|
+
|
474
507
|
If `group_by` is `[""]`, groups by all default fields. If `group_by` is a list of field names, groups by those fields. Returns the grouped queryset.
|
475
508
|
"""
|
476
509
|
if group_by is not None:
|
@@ -483,13 +516,9 @@ class GraphQL:
|
|
483
516
|
@staticmethod
|
484
517
|
def _createMeasurementResolver(field_name: str) -> Callable[..., Any]:
|
485
518
|
"""
|
486
|
-
Creates a resolver
|
487
|
-
|
488
|
-
|
489
|
-
field_name (str): The name of the Measurement field to resolve.
|
490
|
-
|
491
|
-
Returns:
|
492
|
-
Callable[..., dict[str, Any] | None]: A resolver that returns a dictionary with 'value' and 'unit' keys, or None if permission is denied or the field is not a Measurement.
|
519
|
+
Creates a resolver for a Measurement field that returns its value and unit, with optional unit conversion.
|
520
|
+
|
521
|
+
The generated resolver checks read permissions for the specified field. If permitted and the field is a Measurement, it returns a dictionary containing the measurement's value and unit, converting to the specified target unit if provided. Returns None if permission is denied or the field is not a Measurement.
|
493
522
|
"""
|
494
523
|
|
495
524
|
def resolver(
|
@@ -527,9 +556,9 @@ class GraphQL:
|
|
527
556
|
@classmethod
|
528
557
|
def _createResolver(cls, field_name: str, field_type: type) -> Callable[..., Any]:
|
529
558
|
"""
|
530
|
-
|
531
|
-
|
532
|
-
For fields ending with `_list`
|
559
|
+
Returns a resolver function for a field, selecting list, measurement, or standard resolution based on the field's type and name.
|
560
|
+
|
561
|
+
For fields ending with `_list` referencing a `GeneralManager` subclass, provides a resolver supporting pagination and filtering. For `Measurement` fields, returns a resolver that handles unit conversion and permission checks. For all other fields, returns a standard resolver with permission enforcement.
|
533
562
|
"""
|
534
563
|
if field_name.endswith("_list") and issubclass(field_type, GeneralManager):
|
535
564
|
return cls._createListResolver(
|
@@ -546,13 +575,9 @@ class GraphQL:
|
|
546
575
|
item_type: type[graphene.ObjectType] | Callable[[], type[graphene.ObjectType]],
|
547
576
|
) -> type[graphene.ObjectType]:
|
548
577
|
"""
|
549
|
-
|
550
|
-
|
551
|
-
|
552
|
-
page_type_name (str): The name to use for the paginated type.
|
553
|
-
|
554
|
-
Returns:
|
555
|
-
type[graphene.ObjectType]: A GraphQL ObjectType with `items` (list of item_type) and `pageInfo` (pagination metadata).
|
578
|
+
Returns a paginated GraphQL ObjectType for the specified item type, creating and caching it if it does not already exist.
|
579
|
+
|
580
|
+
The returned ObjectType includes an `items` field (a required list of the item type) and a `pageInfo` field (pagination metadata).
|
556
581
|
"""
|
557
582
|
if page_type_name not in cls._page_type_registry:
|
558
583
|
cls._page_type_registry[page_type_name] = type(
|
@@ -571,8 +596,8 @@ class GraphQL:
|
|
571
596
|
) -> None:
|
572
597
|
"""
|
573
598
|
Adds paginated list and single-item query fields for a GeneralManager subclass to the GraphQL schema.
|
574
|
-
|
575
|
-
The list query field
|
599
|
+
|
600
|
+
The list query field enables filtering, exclusion, sorting, pagination, and grouping, returning a paginated result with metadata. The single-item query field retrieves an instance by its identification fields. Both queries are registered with their respective resolvers.
|
576
601
|
"""
|
577
602
|
if not issubclass(generalManagerClass, GeneralManager):
|
578
603
|
raise TypeError(
|
@@ -642,10 +667,10 @@ class GraphQL:
|
|
642
667
|
@classmethod
|
643
668
|
def createWriteFields(cls, interface_cls: InterfaceBase) -> dict[str, Any]:
|
644
669
|
"""
|
645
|
-
|
646
|
-
|
647
|
-
Skips system-managed and derived attributes. For attributes referencing `GeneralManager` subclasses, uses an ID or list of IDs as appropriate. Other types are mapped to their corresponding Graphene scalar types. Each field is annotated with an `editable` attribute.
|
648
|
-
|
670
|
+
Generates a dictionary of Graphene input fields for mutations based on the attributes of the provided interface class.
|
671
|
+
|
672
|
+
Skips system-managed and derived attributes. For attributes referencing `GeneralManager` subclasses, uses an ID or list of IDs as appropriate. Other types are mapped to their corresponding Graphene scalar types. Each field is annotated with an `editable` attribute. Always includes an optional `history_comment` field marked as editable.
|
673
|
+
|
649
674
|
Returns:
|
650
675
|
dict[str, Any]: Mapping of attribute names to Graphene input fields for mutation arguments.
|
651
676
|
"""
|
@@ -698,11 +723,11 @@ class GraphQL:
|
|
698
723
|
) -> type[graphene.Mutation] | None:
|
699
724
|
"""
|
700
725
|
Dynamically generates a Graphene mutation class for creating an instance of a specified GeneralManager subclass.
|
701
|
-
|
702
|
-
The generated mutation class
|
703
|
-
|
726
|
+
|
727
|
+
The generated mutation class uses the manager's interface to define input arguments, filters out fields with `NOT_PROVIDED` values, and invokes the manager's `create` method with the provided data and the current user's ID. On success, it returns a dictionary with a success flag and the created instance; on failure, it raises a GraphQL error. Returns `None` if the manager class does not define an interface.
|
728
|
+
|
704
729
|
Returns:
|
705
|
-
The generated Graphene mutation class, or None if the manager class does not define an interface.
|
730
|
+
The generated Graphene mutation class, or `None` if the manager class does not define an interface.
|
706
731
|
"""
|
707
732
|
interface_cls: InterfaceBase | None = getattr(
|
708
733
|
generalManagerClass, "Interface", None
|
@@ -716,9 +741,9 @@ class GraphQL:
|
|
716
741
|
**kwargs: Any,
|
717
742
|
) -> dict:
|
718
743
|
"""
|
719
|
-
Creates a new instance of the manager class
|
720
|
-
|
721
|
-
Filters out fields set to `NOT_PROVIDED` before creation. Returns a dictionary
|
744
|
+
Creates a new instance of the manager class using the provided arguments.
|
745
|
+
|
746
|
+
Filters out any fields set to `NOT_PROVIDED` before invoking the creation method. Returns a dictionary with a success flag and the created instance keyed by the manager class name. If creation fails, raises a GraphQL error and returns a dictionary with `success` set to `False`.
|
722
747
|
"""
|
723
748
|
try:
|
724
749
|
kwargs = {
|
@@ -769,11 +794,11 @@ class GraphQL:
|
|
769
794
|
) -> type[graphene.Mutation] | None:
|
770
795
|
"""
|
771
796
|
Generates a GraphQL mutation class for updating an instance of a GeneralManager subclass.
|
772
|
-
|
773
|
-
The generated mutation accepts editable fields as arguments,
|
774
|
-
|
797
|
+
|
798
|
+
The generated mutation accepts editable fields as arguments, calls the manager's `update` method with the provided values and the current user's ID, and returns a dictionary containing a success flag and the updated instance. Returns `None` if the manager class does not define an `Interface`.
|
799
|
+
|
775
800
|
Returns:
|
776
|
-
The generated Graphene mutation class, or None if no interface is defined.
|
801
|
+
The generated Graphene mutation class, or `None` if no interface is defined.
|
777
802
|
"""
|
778
803
|
interface_cls: InterfaceBase | None = getattr(
|
779
804
|
generalManagerClass, "Interface", None
|
@@ -787,14 +812,14 @@ class GraphQL:
|
|
787
812
|
**kwargs: Any,
|
788
813
|
) -> dict:
|
789
814
|
"""
|
790
|
-
Updates an instance of
|
791
|
-
|
815
|
+
Updates an instance of a GeneralManager subclass with the specified field values.
|
816
|
+
|
792
817
|
Parameters:
|
793
|
-
info (GraphQLResolveInfo): The GraphQL resolver context,
|
794
|
-
**kwargs:
|
795
|
-
|
818
|
+
info (GraphQLResolveInfo): The GraphQL resolver context, including user and request data.
|
819
|
+
**kwargs: Field values to update, including the required 'id' of the instance.
|
820
|
+
|
796
821
|
Returns:
|
797
|
-
dict:
|
822
|
+
dict: A dictionary with 'success' (bool) and the updated instance keyed by its class name.
|
798
823
|
"""
|
799
824
|
try:
|
800
825
|
manager_id = kwargs.pop("id", None)
|
@@ -841,9 +866,9 @@ class GraphQL:
|
|
841
866
|
) -> type[graphene.Mutation] | None:
|
842
867
|
"""
|
843
868
|
Generates a GraphQL mutation class for deactivating (soft-deleting) an instance of a GeneralManager subclass.
|
844
|
-
|
845
|
-
The generated mutation accepts input fields defined by the manager's interface, deactivates the specified instance using its ID, and returns a dictionary containing a success status and the deactivated instance keyed by the class name.
|
846
|
-
|
869
|
+
|
870
|
+
The generated mutation accepts input fields defined by the manager's interface, deactivates the specified instance using its ID, and returns a dictionary containing a success status and the deactivated instance keyed by the class name. Returns None if the manager class does not define an interface.
|
871
|
+
|
847
872
|
Returns:
|
848
873
|
The generated Graphene mutation class, or None if no interface is defined.
|
849
874
|
"""
|
@@ -859,14 +884,10 @@ class GraphQL:
|
|
859
884
|
**kwargs: Any,
|
860
885
|
) -> dict:
|
861
886
|
"""
|
862
|
-
Deactivates an instance of
|
863
|
-
|
864
|
-
Parameters:
|
865
|
-
info (GraphQLResolveInfo): GraphQL resolver context containing user information.
|
866
|
-
**kwargs: Arguments including the instance ID to deactivate.
|
867
|
-
|
887
|
+
Deactivates an instance of a GeneralManager subclass and returns the operation result.
|
888
|
+
|
868
889
|
Returns:
|
869
|
-
dict:
|
890
|
+
dict: A dictionary with a "success" boolean and the deactivated instance keyed by its class name.
|
870
891
|
"""
|
871
892
|
try:
|
872
893
|
manager_id = kwargs.pop("id", None)
|
@@ -908,9 +929,9 @@ class GraphQL:
|
|
908
929
|
@staticmethod
|
909
930
|
def _handleGraphQLError(error: Exception) -> None:
|
910
931
|
"""
|
911
|
-
Raises a GraphQLError with
|
912
|
-
|
913
|
-
|
932
|
+
Raises a GraphQLError with a specific error code based on the exception type.
|
933
|
+
|
934
|
+
PermissionError results in "PERMISSION_DENIED", ValueError or ValidationError in "BAD_USER_INPUT", and all other exceptions in "INTERNAL_SERVER_ERROR".
|
914
935
|
"""
|
915
936
|
if isinstance(error, PermissionError):
|
916
937
|
raise GraphQLError(
|
{generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/measurement/measurement.py
RENAMED
@@ -24,7 +24,7 @@ class Measurement:
|
|
24
24
|
"""
|
25
25
|
Initialize a Measurement with a numeric value and unit.
|
26
26
|
|
27
|
-
|
27
|
+
Converts the provided value to a Decimal and associates it with the specified unit, creating a unit-aware measurement.
|
28
28
|
|
29
29
|
Raises:
|
30
30
|
ValueError: If the value cannot be converted to a Decimal.
|
@@ -226,12 +226,12 @@ class Measurement:
|
|
226
226
|
|
227
227
|
def __mul__(self, other: Any) -> Measurement:
|
228
228
|
"""
|
229
|
-
|
229
|
+
Multiply this measurement by another measurement or a numeric value.
|
230
230
|
|
231
|
-
Multiplication between two currency measurements is not
|
231
|
+
Multiplication between two currency measurements is not allowed. When multiplying by another measurement, the resulting measurement combines their units. When multiplying by a numeric value, only the magnitude is scaled.
|
232
232
|
|
233
233
|
Returns:
|
234
|
-
Measurement:
|
234
|
+
Measurement: The product as a new Measurement instance.
|
235
235
|
|
236
236
|
Raises:
|
237
237
|
TypeError: If both operands are currency measurements, or if the operand is neither a Measurement nor a numeric value.
|
@@ -304,17 +304,19 @@ class Measurement:
|
|
304
304
|
|
305
305
|
def _compare(self, other: Any, operation: Callable[..., bool]) -> bool:
|
306
306
|
"""
|
307
|
-
|
308
|
-
|
309
|
-
If `other` is a string, it is parsed as a Measurement.
|
310
|
-
|
307
|
+
Compare this Measurement to another using a specified comparison operation.
|
308
|
+
|
309
|
+
If `other` is a string, it is parsed as a Measurement. Returns `False` if `other` is `None` or an empty value. Raises `TypeError` if `other` is not a Measurement or a valid string. Raises `ValueError` if the measurements have incompatible dimensions.
|
310
|
+
|
311
311
|
Parameters:
|
312
|
-
other: The object to compare,
|
313
|
-
operation: A callable that takes two magnitudes and returns a boolean.
|
314
|
-
|
312
|
+
other: The object to compare, which can be a Measurement instance or a string in the format "value unit".
|
313
|
+
operation: A callable that takes two magnitudes and returns a boolean result.
|
314
|
+
|
315
315
|
Returns:
|
316
|
-
bool: The result of the comparison.
|
316
|
+
bool: The result of applying the comparison operation to the magnitudes.
|
317
317
|
"""
|
318
|
+
if other is None or other in ("", [], (), {}):
|
319
|
+
return False
|
318
320
|
if isinstance(other, str):
|
319
321
|
other = Measurement.from_string(other)
|
320
322
|
|
@@ -0,0 +1,271 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from django.db import models
|
4
|
+
from django.core.exceptions import ValidationError
|
5
|
+
from django.db.models.expressions import Col
|
6
|
+
from decimal import Decimal
|
7
|
+
import pint
|
8
|
+
from general_manager.measurement.measurement import Measurement, ureg, currency_units
|
9
|
+
|
10
|
+
|
11
|
+
class MeasurementField(models.Field):
|
12
|
+
description = "Stores a measurement (value + unit) but exposes a single field API"
|
13
|
+
|
14
|
+
empty_values = (None, "", [], (), {})
|
15
|
+
|
16
|
+
def __init__(
|
17
|
+
self, base_unit: str, *args, null=False, blank=False, editable=True, **kwargs
|
18
|
+
):
|
19
|
+
"""
|
20
|
+
Initialize a MeasurementField to store a numeric value and its unit with unit-aware validation.
|
21
|
+
|
22
|
+
Parameters:
|
23
|
+
base_unit (str): The canonical unit for the measurement, used for conversions and validation.
|
24
|
+
null (bool, optional): Whether the field allows NULL values. Defaults to False.
|
25
|
+
blank (bool, optional): Whether the field allows blank values. Defaults to False.
|
26
|
+
editable (bool, optional): Whether the field is editable in forms and admin. Defaults to True.
|
27
|
+
|
28
|
+
The field internally manages a DecimalField for the value and a CharField for the unit, both configured according to the provided options.
|
29
|
+
"""
|
30
|
+
self.base_unit = base_unit
|
31
|
+
self.base_dimension = ureg.parse_expression(self.base_unit).dimensionality
|
32
|
+
|
33
|
+
nb = {}
|
34
|
+
if null:
|
35
|
+
nb["null"] = True
|
36
|
+
if blank:
|
37
|
+
nb["blank"] = True
|
38
|
+
|
39
|
+
self.editable = editable
|
40
|
+
self.value_field = models.DecimalField(
|
41
|
+
max_digits=30, decimal_places=10, db_index=True, editable=editable, **nb
|
42
|
+
)
|
43
|
+
self.unit_field = models.CharField(max_length=30, editable=editable, **nb)
|
44
|
+
|
45
|
+
super().__init__(*args, null=null, blank=blank, editable=editable, **kwargs)
|
46
|
+
|
47
|
+
def contribute_to_class(self, cls, name, private_only=False, **kwargs):
|
48
|
+
# Register myself first (so opts.get_field('height') works)
|
49
|
+
"""
|
50
|
+
Registers the MeasurementField with the model class and attaches internal value and unit fields.
|
51
|
+
|
52
|
+
This method sets up the composite field by creating and adding separate fields for the numeric value and unit to the model class, ensuring they are not duplicated. It also overrides the model attribute with the MeasurementField descriptor itself to manage access and assignment.
|
53
|
+
"""
|
54
|
+
super().contribute_to_class(cls, name, private_only=private_only, **kwargs)
|
55
|
+
self.concrete = False
|
56
|
+
self.column = None # type: ignore # will not be set in db
|
57
|
+
self.field = self
|
58
|
+
|
59
|
+
self.value_attr = f"{name}_value"
|
60
|
+
self.unit_attr = f"{name}_unit"
|
61
|
+
|
62
|
+
# prevent duplicate attributes
|
63
|
+
if hasattr(cls, self.value_attr):
|
64
|
+
self.value_field = getattr(cls, self.value_attr).field
|
65
|
+
else:
|
66
|
+
self.value_field.set_attributes_from_name(self.value_attr)
|
67
|
+
self.value_field.contribute_to_class(cls, self.value_attr)
|
68
|
+
|
69
|
+
if hasattr(cls, self.unit_attr):
|
70
|
+
self.unit_field = getattr(cls, self.unit_attr).field
|
71
|
+
else:
|
72
|
+
self.unit_field.set_attributes_from_name(self.unit_attr)
|
73
|
+
self.unit_field.contribute_to_class(cls, self.unit_attr)
|
74
|
+
|
75
|
+
# Descriptor override
|
76
|
+
setattr(cls, name, self)
|
77
|
+
|
78
|
+
# ---- ORM Delegation ----
|
79
|
+
def get_col(self, alias, output_field=None):
|
80
|
+
"""
|
81
|
+
Returns a Django ORM column expression for the internal value field, enabling queries on the numeric part of the measurement.
|
82
|
+
"""
|
83
|
+
return Col(alias, self.value_field, output_field or self.value_field) # type: ignore
|
84
|
+
|
85
|
+
def get_lookup(self, lookup_name):
|
86
|
+
"""
|
87
|
+
Return the lookup class for the specified lookup name, delegating to the internal value field.
|
88
|
+
|
89
|
+
Parameters:
|
90
|
+
lookup_name (str): The name of the lookup to retrieve.
|
91
|
+
|
92
|
+
Returns:
|
93
|
+
The lookup class corresponding to the given name, as provided by the internal decimal value field.
|
94
|
+
"""
|
95
|
+
return self.value_field.get_lookup(lookup_name)
|
96
|
+
|
97
|
+
def get_transform(self, lookup_name) -> models.Transform | None:
|
98
|
+
"""
|
99
|
+
Delegates retrieval of a transform operation to the internal value field.
|
100
|
+
|
101
|
+
Returns:
|
102
|
+
The transform corresponding to the given lookup name, or None if not found.
|
103
|
+
"""
|
104
|
+
return self.value_field.get_transform(lookup_name)
|
105
|
+
|
106
|
+
def db_type(self, connection) -> None: # type: ignore
|
107
|
+
"""
|
108
|
+
Return None to indicate that MeasurementField does not correspond to a single database column.
|
109
|
+
|
110
|
+
This field manages its data using separate internal fields and does not require a direct database type.
|
111
|
+
"""
|
112
|
+
return None
|
113
|
+
|
114
|
+
def run_validators(self, value: Measurement | None) -> None:
|
115
|
+
"""
|
116
|
+
Runs all validators on the provided Measurement value if it is not None.
|
117
|
+
|
118
|
+
Parameters:
|
119
|
+
value (Measurement | None): The measurement to validate, or None to skip validation.
|
120
|
+
"""
|
121
|
+
if value is None:
|
122
|
+
return
|
123
|
+
for v in self.validators:
|
124
|
+
v(value)
|
125
|
+
|
126
|
+
def clean(
|
127
|
+
self, value: Measurement | None, model_instance: models.Model | None = None
|
128
|
+
) -> Measurement | None:
|
129
|
+
"""
|
130
|
+
Validates and cleans a Measurement value for use in the model field.
|
131
|
+
|
132
|
+
Runs field-level validation and all configured validators on the provided value, returning it unchanged if valid.
|
133
|
+
|
134
|
+
Parameters:
|
135
|
+
value (Measurement | None): The measurement value to validate and clean.
|
136
|
+
model_instance (models.Model | None): The model instance this value is associated with, if any.
|
137
|
+
|
138
|
+
Returns:
|
139
|
+
Measurement | None: The validated measurement value, or None if the input was None.
|
140
|
+
"""
|
141
|
+
self.validate(value, model_instance)
|
142
|
+
self.run_validators(value)
|
143
|
+
return value
|
144
|
+
|
145
|
+
def to_python(self, value):
|
146
|
+
"""
|
147
|
+
Returns the input value unchanged.
|
148
|
+
|
149
|
+
This method is required by Django custom fields to convert database values to Python objects, but no conversion is performed for this field.
|
150
|
+
"""
|
151
|
+
return value
|
152
|
+
|
153
|
+
def get_prep_value(self, value):
|
154
|
+
"""
|
155
|
+
Prepare a value for database storage by converting a Measurement to its decimal magnitude in the base unit.
|
156
|
+
|
157
|
+
If the input is a string, it is parsed into a Measurement. If the value cannot be converted to the base unit due to dimensionality mismatch, a ValidationError is raised. Only Measurement instances or None are accepted.
|
158
|
+
|
159
|
+
Returns:
|
160
|
+
Decimal: The numeric value of the measurement in the base unit, or None if the input is None.
|
161
|
+
|
162
|
+
Raises:
|
163
|
+
ValidationError: If the value is not a Measurement or cannot be converted to the base unit.
|
164
|
+
"""
|
165
|
+
if value is None:
|
166
|
+
return None
|
167
|
+
if isinstance(value, str):
|
168
|
+
value = Measurement.from_string(value)
|
169
|
+
if isinstance(value, Measurement):
|
170
|
+
try:
|
171
|
+
return Decimal(str(value.quantity.to(self.base_unit).magnitude))
|
172
|
+
except pint.errors.DimensionalityError as e:
|
173
|
+
raise ValidationError(
|
174
|
+
{self.name: [f"Unit must be compatible with '{self.base_unit}'."]}
|
175
|
+
) from e
|
176
|
+
raise ValidationError(
|
177
|
+
{self.name: ["Value must be a Measurement instance or None."]}
|
178
|
+
)
|
179
|
+
|
180
|
+
# ------------ Descriptor ------------
|
181
|
+
def __get__( # type: ignore
|
182
|
+
self, instance: models.Model | None, owner: None = None
|
183
|
+
) -> MeasurementField | Measurement | None:
|
184
|
+
"""
|
185
|
+
Retrieve the measurement value from the model instance, reconstructing it as a `Measurement` object with the stored unit.
|
186
|
+
|
187
|
+
Returns:
|
188
|
+
Measurement: The measurement with its original unit if both value and unit are present.
|
189
|
+
None: If either the value or unit is missing.
|
190
|
+
MeasurementField: If accessed from the class rather than an instance.
|
191
|
+
"""
|
192
|
+
if instance is None:
|
193
|
+
return self
|
194
|
+
val = getattr(instance, self.value_attr)
|
195
|
+
unit = getattr(instance, self.unit_attr)
|
196
|
+
if val is None or unit is None:
|
197
|
+
return None
|
198
|
+
qty_base = Decimal(val) * ureg(self.base_unit)
|
199
|
+
try:
|
200
|
+
qty_orig = qty_base.to(unit)
|
201
|
+
except pint.errors.DimensionalityError:
|
202
|
+
qty_orig = qty_base
|
203
|
+
return Measurement(qty_orig.magnitude, str(qty_orig.units))
|
204
|
+
|
205
|
+
def __set__(self, instance, value):
|
206
|
+
"""
|
207
|
+
Assigns a measurement value to the model instance, validating type, unit compatibility, and editability.
|
208
|
+
|
209
|
+
If the value is a string, attempts to parse it as a Measurement. Ensures the unit matches the expected base unit's dimensionality or currency status. Stores the numeric value (converted to the base unit) and the original unit string in the instance. Raises ValidationError if the value is invalid or incompatible.
|
210
|
+
"""
|
211
|
+
if not self.editable:
|
212
|
+
raise ValidationError(f"{self.name} is not editable.")
|
213
|
+
if value is None:
|
214
|
+
setattr(instance, self.value_attr, None)
|
215
|
+
setattr(instance, self.unit_attr, None)
|
216
|
+
return
|
217
|
+
if isinstance(value, str):
|
218
|
+
try:
|
219
|
+
value = Measurement.from_string(value)
|
220
|
+
except ValueError as e:
|
221
|
+
raise ValidationError(
|
222
|
+
{self.name: ["Value must be a Measurement instance or None."]}
|
223
|
+
) from e
|
224
|
+
if not isinstance(value, Measurement):
|
225
|
+
raise ValidationError(
|
226
|
+
{self.name: ["Value must be a Measurement instance or None."]}
|
227
|
+
)
|
228
|
+
|
229
|
+
if str(self.base_unit) in currency_units:
|
230
|
+
if not value.is_currency():
|
231
|
+
raise ValidationError(
|
232
|
+
{
|
233
|
+
self.name: [
|
234
|
+
f"Unit must be a currency ({', '.join(currency_units)})."
|
235
|
+
]
|
236
|
+
}
|
237
|
+
)
|
238
|
+
else:
|
239
|
+
if value.is_currency():
|
240
|
+
raise ValidationError({self.name: ["Unit cannot be a currency."]})
|
241
|
+
|
242
|
+
try:
|
243
|
+
base_mag = value.quantity.to(self.base_unit).magnitude
|
244
|
+
except pint.errors.DimensionalityError as e:
|
245
|
+
raise ValidationError(
|
246
|
+
{self.name: [f"Unit must be compatible with '{self.base_unit}'."]}
|
247
|
+
) from e
|
248
|
+
|
249
|
+
setattr(instance, self.value_attr, Decimal(str(base_mag)))
|
250
|
+
setattr(instance, self.unit_attr, str(value.quantity.units))
|
251
|
+
|
252
|
+
def validate(
|
253
|
+
self, value: Measurement | None, model_instance: models.Model | None = None
|
254
|
+
) -> None:
|
255
|
+
"""
|
256
|
+
Validates a measurement value against null and blank constraints and applies all field validators.
|
257
|
+
|
258
|
+
Raises:
|
259
|
+
ValidationError: If the value is None and the field does not allow nulls, or if the value is blank and the field does not allow blanks, or if any validator fails.
|
260
|
+
"""
|
261
|
+
if value is None:
|
262
|
+
if not self.null:
|
263
|
+
raise ValidationError(self.error_messages["null"], code="null")
|
264
|
+
return
|
265
|
+
if value in ("", [], (), {}):
|
266
|
+
if not self.blank:
|
267
|
+
raise ValidationError(self.error_messages["blank"], code="blank")
|
268
|
+
return
|
269
|
+
|
270
|
+
for validator in self.validators:
|
271
|
+
validator(value)
|
@@ -1,168 +0,0 @@
|
|
1
|
-
# fields.py
|
2
|
-
from __future__ import annotations
|
3
|
-
from django.db import models
|
4
|
-
from django.core.exceptions import ValidationError
|
5
|
-
from decimal import Decimal
|
6
|
-
from general_manager.measurement.measurement import Measurement, ureg, currency_units
|
7
|
-
import pint
|
8
|
-
from typing import Any
|
9
|
-
|
10
|
-
|
11
|
-
class MeasurementField(models.Field): # type: ignore
|
12
|
-
description = (
|
13
|
-
"A field that stores a measurement value, both in base unit and original unit"
|
14
|
-
)
|
15
|
-
|
16
|
-
def __init__(
|
17
|
-
self,
|
18
|
-
base_unit: str,
|
19
|
-
null: bool = False,
|
20
|
-
blank: bool = False,
|
21
|
-
editable: bool = True,
|
22
|
-
*args: Any,
|
23
|
-
**kwargs: Any,
|
24
|
-
):
|
25
|
-
"""
|
26
|
-
Initialize a MeasurementField to store values in a specified base unit and retain the original unit.
|
27
|
-
|
28
|
-
Parameters:
|
29
|
-
base_unit (str): The canonical unit in which values are stored (e.g., 'meter').
|
30
|
-
null (bool, optional): Whether the field allows NULL values.
|
31
|
-
blank (bool, optional): Whether the field allows blank values.
|
32
|
-
editable (bool, optional): Whether the field is editable in Django admin and forms.
|
33
|
-
|
34
|
-
The field internally manages a DecimalField for the value (in the base unit) and a CharField for the original unit.
|
35
|
-
"""
|
36
|
-
self.base_unit = base_unit # E.g., 'meter' for length units
|
37
|
-
# Determine the dimensionality of the base unit
|
38
|
-
self.base_dimension = ureg.parse_expression(self.base_unit).dimensionality
|
39
|
-
# Internal fields
|
40
|
-
null_blank_kwargs = {}
|
41
|
-
if null is True:
|
42
|
-
null_blank_kwargs["null"] = True
|
43
|
-
if blank is True:
|
44
|
-
null_blank_kwargs["blank"] = True
|
45
|
-
self.editable = editable
|
46
|
-
self.value_field: models.DecimalField[Decimal] = models.DecimalField(
|
47
|
-
max_digits=30,
|
48
|
-
decimal_places=10,
|
49
|
-
db_index=True,
|
50
|
-
**null_blank_kwargs,
|
51
|
-
editable=editable,
|
52
|
-
)
|
53
|
-
self.unit_field: models.CharField[str] = models.CharField(
|
54
|
-
max_length=30, **null_blank_kwargs, editable=editable
|
55
|
-
)
|
56
|
-
super().__init__(null=null, blank=blank, *args, **kwargs)
|
57
|
-
|
58
|
-
def contribute_to_class(
|
59
|
-
self, cls: type, name: str, private_only: bool = False, **kwargs: Any
|
60
|
-
) -> None:
|
61
|
-
"""
|
62
|
-
Integrates the MeasurementField into the Django model class, setting up internal fields for value and unit storage.
|
63
|
-
|
64
|
-
This method assigns unique attribute names for the value and unit fields, attaches them to the model, and sets the descriptor for the custom field on the model class.
|
65
|
-
"""
|
66
|
-
self.name = name
|
67
|
-
self.value_attr = f"{name}_value"
|
68
|
-
self.unit_attr = f"{name}_unit"
|
69
|
-
self.value_field.attname = self.value_attr
|
70
|
-
self.unit_field.attname = self.unit_attr
|
71
|
-
self.value_field.name = self.value_attr
|
72
|
-
self.unit_field.name = self.unit_attr
|
73
|
-
self.value_field.column = self.value_attr
|
74
|
-
self.unit_field.column = self.unit_attr
|
75
|
-
|
76
|
-
self.value_field.model = cls
|
77
|
-
self.unit_field.model = cls
|
78
|
-
|
79
|
-
self.value_field.contribute_to_class(cls, self.value_attr)
|
80
|
-
self.unit_field.contribute_to_class(cls, self.unit_attr)
|
81
|
-
|
82
|
-
setattr(cls, self.name, self)
|
83
|
-
|
84
|
-
def __get__(self, instance: Any, owner: Any) -> Any:
|
85
|
-
if instance is None:
|
86
|
-
return self
|
87
|
-
value = getattr(instance, self.value_attr)
|
88
|
-
unit = getattr(instance, self.unit_attr)
|
89
|
-
if value is None or unit is None:
|
90
|
-
return None
|
91
|
-
# Create a Measurement object with the value in the original unit
|
92
|
-
quantity_in_base_unit = Decimal(value) * ureg(self.base_unit)
|
93
|
-
# Convert back to the original unit
|
94
|
-
try:
|
95
|
-
quantity_in_original_unit: pint.Quantity = quantity_in_base_unit.to(unit) # type: ignore
|
96
|
-
except pint.errors.DimensionalityError:
|
97
|
-
# If the unit is not compatible, return the value in base unit
|
98
|
-
quantity_in_original_unit = quantity_in_base_unit
|
99
|
-
return Measurement(
|
100
|
-
quantity_in_original_unit.magnitude, str(quantity_in_original_unit.units)
|
101
|
-
)
|
102
|
-
|
103
|
-
def __set__(self, instance: Any, value: Any) -> None:
|
104
|
-
if self.editable is False:
|
105
|
-
raise ValidationError(f"{self.name} is not editable.")
|
106
|
-
if value is None:
|
107
|
-
setattr(instance, self.value_attr, None)
|
108
|
-
setattr(instance, self.unit_attr, None)
|
109
|
-
return
|
110
|
-
elif isinstance(value, str):
|
111
|
-
try:
|
112
|
-
value = Measurement.from_string(value)
|
113
|
-
except ValueError:
|
114
|
-
raise ValidationError(
|
115
|
-
{self.name: ["Value must be a Measurement instance or None."]}
|
116
|
-
)
|
117
|
-
if isinstance(value, Measurement):
|
118
|
-
if str(self.base_unit) in currency_units:
|
119
|
-
# Base unit is a currency
|
120
|
-
if not value.is_currency():
|
121
|
-
raise ValidationError(
|
122
|
-
{
|
123
|
-
self.name: [
|
124
|
-
f"The unit must be a currency ({', '.join(currency_units)})."
|
125
|
-
]
|
126
|
-
}
|
127
|
-
)
|
128
|
-
else:
|
129
|
-
# Physical unit
|
130
|
-
if value.is_currency():
|
131
|
-
raise ValidationError(
|
132
|
-
{self.name: ["The unit cannot be a currency."]}
|
133
|
-
)
|
134
|
-
elif value.quantity.dimensionality != self.base_dimension:
|
135
|
-
raise ValidationError(
|
136
|
-
{
|
137
|
-
self.name: [
|
138
|
-
f"The unit must be compatible with '{self.base_unit}'."
|
139
|
-
]
|
140
|
-
}
|
141
|
-
)
|
142
|
-
# Store the value in the base unit
|
143
|
-
try:
|
144
|
-
value_in_base_unit: Any = value.quantity.to(self.base_unit).magnitude # type: ignore
|
145
|
-
except pint.errors.DimensionalityError:
|
146
|
-
raise ValidationError(
|
147
|
-
{
|
148
|
-
self.name: [
|
149
|
-
f"The unit must be compatible with '{self.base_unit}'."
|
150
|
-
]
|
151
|
-
}
|
152
|
-
)
|
153
|
-
setattr(instance, self.value_attr, Decimal(str(value_in_base_unit)))
|
154
|
-
# Store the original unit
|
155
|
-
setattr(instance, self.unit_attr, str(value.quantity.units))
|
156
|
-
else:
|
157
|
-
raise ValidationError(
|
158
|
-
{self.name: ["Value must be a Measurement instance or None."]}
|
159
|
-
)
|
160
|
-
|
161
|
-
def get_prep_value(self, value: Any) -> Any:
|
162
|
-
# Not needed since we use internal fields
|
163
|
-
pass
|
164
|
-
|
165
|
-
def deconstruct(self):
|
166
|
-
name, path, args, kwargs = super().deconstruct()
|
167
|
-
kwargs["base_unit"] = self.base_unit
|
168
|
-
return name, path, args, kwargs
|
File without changes
|
{generalmanager-0.12.0 → generalmanager-0.12.1}/GeneralManager.egg-info/dependency_links.txt
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/bucket/calculationBucket.py
RENAMED
File without changes
|
{generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/bucket/databaseBucket.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/cache/dependencyIndex.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/factory/factoryMethods.py
RENAMED
File without changes
|
File without changes
|
{generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/interface/baseInterface.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
{generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/interface/databaseInterface.py
RENAMED
File without changes
|
File without changes
|
{generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/interface/readOnlyInterface.py
RENAMED
File without changes
|
File without changes
|
{generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/manager/generalManager.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/permission/basePermission.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
{generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/permission/mutationPermission.py
RENAMED
File without changes
|
{generalmanager-0.12.0 → generalmanager-0.12.1}/src/general_manager/permission/permissionChecks.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|