GeneralManager 0.11.2__py3-none-any.whl → 0.12.1__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.
- general_manager/api/graphql.py +242 -101
- general_manager/api/mutation.py +26 -16
- general_manager/measurement/measurement.py +19 -19
- general_manager/measurement/measurementField.py +221 -118
- {generalmanager-0.11.2.dist-info → generalmanager-0.12.1.dist-info}/METADATA +1 -1
- {generalmanager-0.11.2.dist-info → generalmanager-0.12.1.dist-info}/RECORD +9 -9
- {generalmanager-0.11.2.dist-info → generalmanager-0.12.1.dist-info}/WHEEL +0 -0
- {generalmanager-0.11.2.dist-info → generalmanager-0.12.1.dist-info}/licenses/LICENSE +0 -0
- {generalmanager-0.11.2.dist-info → generalmanager-0.12.1.dist-info}/top_level.txt +0 -0
general_manager/api/graphql.py
CHANGED
@@ -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
|
@@ -11,6 +12,10 @@ from general_manager.api.property import GraphQLProperty
|
|
11
12
|
from general_manager.bucket.baseBucket import Bucket
|
12
13
|
from general_manager.interface.baseInterface import InterfaceBase
|
13
14
|
from django.db.models import NOT_PROVIDED
|
15
|
+
from django.core.exceptions import ValidationError
|
16
|
+
|
17
|
+
from graphql import GraphQLError
|
18
|
+
|
14
19
|
|
15
20
|
if TYPE_CHECKING:
|
16
21
|
from general_manager.permission.basePermission import BasePermission
|
@@ -22,13 +27,43 @@ class MeasurementType(graphene.ObjectType):
|
|
22
27
|
unit = graphene.String()
|
23
28
|
|
24
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
|
+
|
52
|
+
class PageInfo(graphene.ObjectType):
|
53
|
+
total_count = graphene.Int(required=True)
|
54
|
+
page_size = graphene.Int(required=False)
|
55
|
+
current_page = graphene.Int(required=True)
|
56
|
+
total_pages = graphene.Int(required=True)
|
57
|
+
|
58
|
+
|
25
59
|
def getReadPermissionFilter(
|
26
60
|
generalManagerClass: GeneralManagerMeta,
|
27
61
|
info: GraphQLResolveInfo,
|
28
62
|
) -> list[tuple[dict[str, Any], dict[str, Any]]]:
|
29
63
|
"""
|
30
|
-
|
31
|
-
|
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.
|
32
67
|
"""
|
33
68
|
filters = []
|
34
69
|
PermissionClass: type[BasePermission] | None = getattr(
|
@@ -55,17 +90,16 @@ class GraphQL:
|
|
55
90
|
_mutation_class: type[graphene.ObjectType] | None = None
|
56
91
|
_mutations: dict[str, Any] = {}
|
57
92
|
_query_fields: dict[str, Any] = {}
|
93
|
+
_page_type_registry: dict[str, type[graphene.ObjectType]] = {}
|
58
94
|
graphql_type_registry: dict[str, type] = {}
|
59
95
|
graphql_filter_type_registry: dict[str, type] = {}
|
60
96
|
|
61
97
|
@classmethod
|
62
98
|
def createGraphqlMutation(cls, generalManagerClass: type[GeneralManager]) -> None:
|
63
99
|
"""
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
- Zu jedem Feld ein Resolver generiert und hinzugefügt
|
68
|
-
- Der neue Type in das Registry eingetragen und Mutationen angehängt.
|
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.
|
69
103
|
"""
|
70
104
|
|
71
105
|
interface_cls: InterfaceBase | None = getattr(
|
@@ -76,7 +110,6 @@ class GraphQL:
|
|
76
110
|
|
77
111
|
default_return_values = {
|
78
112
|
"success": graphene.Boolean(),
|
79
|
-
"errors": graphene.List(graphene.String),
|
80
113
|
generalManagerClass.__name__: graphene.Field(
|
81
114
|
lambda: GraphQL.graphql_type_registry[generalManagerClass.__name__]
|
82
115
|
),
|
@@ -102,9 +135,9 @@ class GraphQL:
|
|
102
135
|
@classmethod
|
103
136
|
def createGraphqlInterface(cls, generalManagerClass: GeneralManagerMeta) -> None:
|
104
137
|
"""
|
105
|
-
|
106
|
-
|
107
|
-
|
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.
|
108
141
|
"""
|
109
142
|
interface_cls: InterfaceBase | None = getattr(
|
110
143
|
generalManagerClass, "Interface", None
|
@@ -146,9 +179,9 @@ class GraphQL:
|
|
146
179
|
) -> type[graphene.Enum] | None:
|
147
180
|
"""
|
148
181
|
Creates a Graphene Enum type representing sortable fields for a given GeneralManager class.
|
149
|
-
|
182
|
+
|
150
183
|
Returns:
|
151
|
-
A Graphene Enum type with options for each sortable attribute,
|
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.
|
152
185
|
"""
|
153
186
|
sort_options = []
|
154
187
|
for (
|
@@ -158,9 +191,6 @@ class GraphQL:
|
|
158
191
|
field_type = field_info["type"]
|
159
192
|
if issubclass(field_type, GeneralManager):
|
160
193
|
continue
|
161
|
-
elif issubclass(field_type, Measurement):
|
162
|
-
sort_options.append(f"{field_name}_value")
|
163
|
-
sort_options.append(f"{field_name}_unit")
|
164
194
|
else:
|
165
195
|
sort_options.append(field_name)
|
166
196
|
|
@@ -179,8 +209,15 @@ class GraphQL:
|
|
179
209
|
) -> type[graphene.InputObjectType] | None:
|
180
210
|
"""
|
181
211
|
Dynamically generates a Graphene InputObjectType for filtering fields of a GeneralManager subclass.
|
182
|
-
|
183
|
-
Creates filter fields for each attribute based on its type, supporting numeric and string filter
|
212
|
+
|
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.
|
184
221
|
"""
|
185
222
|
number_options = ["exact", "gt", "gte", "lt", "lte"]
|
186
223
|
string_options = [
|
@@ -202,11 +239,9 @@ class GraphQL:
|
|
202
239
|
if issubclass(attr_type, GeneralManager):
|
203
240
|
continue
|
204
241
|
elif issubclass(attr_type, Measurement):
|
205
|
-
filter_fields[f"{attr_name}
|
206
|
-
filter_fields[f"{attr_name}_unit"] = graphene.String()
|
242
|
+
filter_fields[f"{attr_name}"] = MeasurementScalar()
|
207
243
|
for option in number_options:
|
208
|
-
filter_fields[f"{attr_name}
|
209
|
-
filter_fields[f"{attr_name}_unit__{option}"] = graphene.String()
|
244
|
+
filter_fields[f"{attr_name}__{option}"] = MeasurementScalar()
|
210
245
|
else:
|
211
246
|
filter_fields[attr_name] = GraphQL._mapFieldToGrapheneRead(
|
212
247
|
attr_type, attr_name
|
@@ -236,8 +271,8 @@ class GraphQL:
|
|
236
271
|
def _mapFieldToGrapheneRead(field_type: type, field_name: str) -> Any:
|
237
272
|
"""
|
238
273
|
Maps a Python field type and name to the appropriate Graphene field for GraphQL schema generation.
|
239
|
-
|
240
|
-
For `Measurement`
|
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.
|
241
276
|
"""
|
242
277
|
if issubclass(field_type, Measurement):
|
243
278
|
return graphene.Field(MeasurementType, target_unit=graphene.String())
|
@@ -257,6 +292,13 @@ class GraphQL:
|
|
257
292
|
sort_by_options = GraphQL._sortByOptions(field_type)
|
258
293
|
if sort_by_options:
|
259
294
|
attributes["sort_by"] = sort_by_options()
|
295
|
+
|
296
|
+
page_type = GraphQL._getOrCreatePageType(
|
297
|
+
field_type.__name__ + "Page",
|
298
|
+
lambda: GraphQL.graphql_type_registry[field_type.__name__],
|
299
|
+
)
|
300
|
+
return graphene.Field(page_type, **attributes)
|
301
|
+
|
260
302
|
return graphene.List(
|
261
303
|
lambda: GraphQL.graphql_type_registry[field_type.__name__],
|
262
304
|
**attributes,
|
@@ -282,6 +324,8 @@ class GraphQL:
|
|
282
324
|
return graphene.Float
|
283
325
|
elif issubclass(field_type, (date, datetime)):
|
284
326
|
return graphene.Date
|
327
|
+
elif issubclass(field_type, Measurement):
|
328
|
+
return MeasurementScalar
|
285
329
|
else:
|
286
330
|
return graphene.String
|
287
331
|
|
@@ -306,11 +350,18 @@ class GraphQL:
|
|
306
350
|
exclude_input: dict[str, Any] | str | None,
|
307
351
|
sort_by: graphene.Enum | None,
|
308
352
|
reverse: bool,
|
309
|
-
page: int | None,
|
310
|
-
page_size: int | None,
|
311
353
|
) -> Bucket[GeneralManager]:
|
312
354
|
"""
|
313
|
-
|
355
|
+
Apply filtering, exclusion, and sorting to a queryset based on provided parameters.
|
356
|
+
|
357
|
+
Parameters:
|
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
|
+
|
363
|
+
Returns:
|
364
|
+
The queryset after applying filters, exclusions, and sorting.
|
314
365
|
"""
|
315
366
|
filters = GraphQL._parseInput(filter_input)
|
316
367
|
if filters:
|
@@ -324,12 +375,6 @@ class GraphQL:
|
|
324
375
|
sort_by_str = cast(str, getattr(sort_by, "value", sort_by))
|
325
376
|
queryset = queryset.sort(sort_by_str, reverse=reverse)
|
326
377
|
|
327
|
-
if page is not None or page_size is not None:
|
328
|
-
page = page or 1
|
329
|
-
page_size = page_size or 10
|
330
|
-
offset = (page - 1) * page_size
|
331
|
-
queryset = cast(Bucket, queryset[offset : offset + page_size])
|
332
|
-
|
333
378
|
return queryset
|
334
379
|
|
335
380
|
@staticmethod
|
@@ -370,14 +415,9 @@ class GraphQL:
|
|
370
415
|
base_getter: Callable[[Any], Any], fallback_manager_class: type[GeneralManager]
|
371
416
|
) -> Callable[..., Any]:
|
372
417
|
"""
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
base_getter: Function to obtain the base queryset from the parent instance.
|
377
|
-
fallback_manager_class: Manager class to use if the queryset does not specify one.
|
378
|
-
|
379
|
-
Returns:
|
380
|
-
A resolver function that processes list queries with filtering, sorting, pagination, and grouping.
|
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.
|
381
421
|
"""
|
382
422
|
|
383
423
|
def resolver(
|
@@ -390,21 +430,21 @@ class GraphQL:
|
|
390
430
|
page: int | None = None,
|
391
431
|
page_size: int | None = None,
|
392
432
|
group_by: list[str] | None = None,
|
393
|
-
) -> Any:
|
433
|
+
) -> dict[str, Any]:
|
394
434
|
"""
|
395
|
-
Resolves a list field by returning
|
396
|
-
|
435
|
+
Resolves a list field by returning filtered, excluded, sorted, grouped, and paginated results with permission checks.
|
436
|
+
|
397
437
|
Parameters:
|
398
438
|
filter: Filter criteria as a dictionary or JSON string.
|
399
439
|
exclude: Exclusion criteria as a dictionary or JSON string.
|
400
440
|
sort_by: Field to sort by, as a Graphene Enum.
|
401
|
-
reverse:
|
441
|
+
reverse: If True, reverses the sort order.
|
402
442
|
page: Page number for pagination.
|
403
443
|
page_size: Number of items per page.
|
404
444
|
group_by: List of field names to group results by.
|
405
|
-
|
445
|
+
|
406
446
|
Returns:
|
407
|
-
|
447
|
+
A dictionary containing the paginated items under "items" and pagination metadata under "pageInfo".
|
408
448
|
"""
|
409
449
|
base_queryset = base_getter(self)
|
410
450
|
# use _manager_class from the attribute if available, otherwise fallback
|
@@ -412,22 +452,73 @@ class GraphQL:
|
|
412
452
|
base_queryset, "_manager_class", fallback_manager_class
|
413
453
|
)
|
414
454
|
qs = GraphQL._applyPermissionFilters(base_queryset, manager_class, info)
|
415
|
-
qs = GraphQL._applyQueryParameters(
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
455
|
+
qs = GraphQL._applyQueryParameters(qs, filter, exclude, sort_by, reverse)
|
456
|
+
qs = GraphQL._applyGrouping(qs, group_by)
|
457
|
+
|
458
|
+
total_count = len(qs)
|
459
|
+
|
460
|
+
qs_paginated = GraphQL._applyPagination(qs, page, page_size)
|
461
|
+
|
462
|
+
page_info = {
|
463
|
+
"total_count": total_count,
|
464
|
+
"page_size": page_size,
|
465
|
+
"current_page": page or 1,
|
466
|
+
"total_pages": (
|
467
|
+
((total_count + page_size - 1) // page_size) if page_size else 1
|
468
|
+
),
|
469
|
+
}
|
470
|
+
return {
|
471
|
+
"items": qs_paginated,
|
472
|
+
"pageInfo": page_info,
|
473
|
+
}
|
424
474
|
|
425
475
|
return resolver
|
426
476
|
|
477
|
+
@staticmethod
|
478
|
+
def _applyPagination(
|
479
|
+
queryset: Bucket[GeneralManager], page: int | None, page_size: int | None
|
480
|
+
) -> Bucket[GeneralManager]:
|
481
|
+
"""
|
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.
|
492
|
+
"""
|
493
|
+
if page is not None or page_size is not None:
|
494
|
+
page = page or 1
|
495
|
+
page_size = page_size or 10
|
496
|
+
offset = (page - 1) * page_size
|
497
|
+
queryset = cast(Bucket, queryset[offset : offset + page_size])
|
498
|
+
return queryset
|
499
|
+
|
500
|
+
@staticmethod
|
501
|
+
def _applyGrouping(
|
502
|
+
queryset: Bucket[GeneralManager], group_by: list[str] | None
|
503
|
+
) -> Bucket[GeneralManager]:
|
504
|
+
"""
|
505
|
+
Groups the queryset by the specified fields.
|
506
|
+
|
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.
|
508
|
+
"""
|
509
|
+
if group_by is not None:
|
510
|
+
if group_by == [""]:
|
511
|
+
queryset = queryset.group_by()
|
512
|
+
else:
|
513
|
+
queryset = queryset.group_by(*group_by)
|
514
|
+
return queryset
|
515
|
+
|
427
516
|
@staticmethod
|
428
517
|
def _createMeasurementResolver(field_name: str) -> Callable[..., Any]:
|
429
518
|
"""
|
430
|
-
|
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.
|
431
522
|
"""
|
432
523
|
|
433
524
|
def resolver(
|
@@ -465,7 +556,9 @@ class GraphQL:
|
|
465
556
|
@classmethod
|
466
557
|
def _createResolver(cls, field_name: str, field_type: type) -> Callable[..., Any]:
|
467
558
|
"""
|
468
|
-
|
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.
|
469
562
|
"""
|
470
563
|
if field_name.endswith("_list") and issubclass(field_type, GeneralManager):
|
471
564
|
return cls._createListResolver(
|
@@ -475,14 +568,36 @@ class GraphQL:
|
|
475
568
|
return cls._createMeasurementResolver(field_name)
|
476
569
|
return cls._createNormalResolver(field_name)
|
477
570
|
|
571
|
+
@classmethod
|
572
|
+
def _getOrCreatePageType(
|
573
|
+
cls,
|
574
|
+
page_type_name: str,
|
575
|
+
item_type: type[graphene.ObjectType] | Callable[[], type[graphene.ObjectType]],
|
576
|
+
) -> type[graphene.ObjectType]:
|
577
|
+
"""
|
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).
|
581
|
+
"""
|
582
|
+
if page_type_name not in cls._page_type_registry:
|
583
|
+
cls._page_type_registry[page_type_name] = type(
|
584
|
+
page_type_name,
|
585
|
+
(graphene.ObjectType,),
|
586
|
+
{
|
587
|
+
"items": graphene.List(item_type, required=True),
|
588
|
+
"pageInfo": graphene.Field(PageInfo, required=True),
|
589
|
+
},
|
590
|
+
)
|
591
|
+
return cls._page_type_registry[page_type_name]
|
592
|
+
|
478
593
|
@classmethod
|
479
594
|
def _addQueriesToSchema(
|
480
595
|
cls, graphene_type: type, generalManagerClass: GeneralManagerMeta
|
481
596
|
) -> None:
|
482
597
|
"""
|
483
|
-
|
484
|
-
|
485
|
-
|
598
|
+
Adds paginated list and single-item query fields for a GeneralManager subclass to the GraphQL schema.
|
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.
|
486
601
|
"""
|
487
602
|
if not issubclass(generalManagerClass, GeneralManager):
|
488
603
|
raise TypeError(
|
@@ -509,10 +624,11 @@ class GraphQL:
|
|
509
624
|
sort_by_options = cls._sortByOptions(generalManagerClass)
|
510
625
|
if sort_by_options:
|
511
626
|
attributes["sort_by"] = sort_by_options()
|
512
|
-
|
513
|
-
|
514
|
-
|
627
|
+
|
628
|
+
page_type = cls._getOrCreatePageType(
|
629
|
+
graphene_type.__name__ + "Page", graphene_type
|
515
630
|
)
|
631
|
+
list_field = graphene.Field(page_type, **attributes)
|
516
632
|
|
517
633
|
list_resolver = cls._createListResolver(
|
518
634
|
lambda self: generalManagerClass.all(), generalManagerClass
|
@@ -551,10 +667,10 @@ class GraphQL:
|
|
551
667
|
@classmethod
|
552
668
|
def createWriteFields(cls, interface_cls: InterfaceBase) -> dict[str, Any]:
|
553
669
|
"""
|
554
|
-
|
555
|
-
|
556
|
-
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.
|
557
|
-
|
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
|
+
|
558
674
|
Returns:
|
559
675
|
dict[str, Any]: Mapping of attribute names to Graphene input fields for mutation arguments.
|
560
676
|
"""
|
@@ -607,11 +723,11 @@ class GraphQL:
|
|
607
723
|
) -> type[graphene.Mutation] | None:
|
608
724
|
"""
|
609
725
|
Dynamically generates a Graphene mutation class for creating an instance of a specified GeneralManager subclass.
|
610
|
-
|
611
|
-
The generated mutation class filters out fields with `NOT_PROVIDED` values,
|
612
|
-
|
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
|
+
|
613
729
|
Returns:
|
614
|
-
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.
|
615
731
|
"""
|
616
732
|
interface_cls: InterfaceBase | None = getattr(
|
617
733
|
generalManagerClass, "Interface", None
|
@@ -625,9 +741,9 @@ class GraphQL:
|
|
625
741
|
**kwargs: Any,
|
626
742
|
) -> dict:
|
627
743
|
"""
|
628
|
-
|
629
|
-
|
630
|
-
Filters out any fields
|
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`.
|
631
747
|
"""
|
632
748
|
try:
|
633
749
|
kwargs = {
|
@@ -639,14 +755,13 @@ class GraphQL:
|
|
639
755
|
**kwargs, creator_id=info.context.user.id
|
640
756
|
)
|
641
757
|
except Exception as e:
|
758
|
+
GraphQL._handleGraphQLError(e)
|
642
759
|
return {
|
643
760
|
"success": False,
|
644
|
-
"errors": [str(e)],
|
645
761
|
}
|
646
762
|
|
647
763
|
return {
|
648
764
|
"success": True,
|
649
|
-
"errors": [],
|
650
765
|
generalManagerClass.__name__: instance,
|
651
766
|
}
|
652
767
|
|
@@ -679,11 +794,11 @@ class GraphQL:
|
|
679
794
|
) -> type[graphene.Mutation] | None:
|
680
795
|
"""
|
681
796
|
Generates a GraphQL mutation class for updating an instance of a GeneralManager subclass.
|
682
|
-
|
683
|
-
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
|
684
|
-
|
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
|
+
|
685
800
|
Returns:
|
686
|
-
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.
|
687
802
|
"""
|
688
803
|
interface_cls: InterfaceBase | None = getattr(
|
689
804
|
generalManagerClass, "Interface", None
|
@@ -697,14 +812,14 @@ class GraphQL:
|
|
697
812
|
**kwargs: Any,
|
698
813
|
) -> dict:
|
699
814
|
"""
|
700
|
-
Updates a GeneralManager
|
701
|
-
|
815
|
+
Updates an instance of a GeneralManager subclass with the specified field values.
|
816
|
+
|
702
817
|
Parameters:
|
703
|
-
info (GraphQLResolveInfo): The GraphQL resolver context,
|
704
|
-
**kwargs:
|
705
|
-
|
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
|
+
|
706
821
|
Returns:
|
707
|
-
dict: A dictionary with
|
822
|
+
dict: A dictionary with 'success' (bool) and the updated instance keyed by its class name.
|
708
823
|
"""
|
709
824
|
try:
|
710
825
|
manager_id = kwargs.pop("id", None)
|
@@ -712,13 +827,13 @@ class GraphQL:
|
|
712
827
|
creator_id=info.context.user.id, **kwargs
|
713
828
|
)
|
714
829
|
except Exception as e:
|
830
|
+
GraphQL._handleGraphQLError(e)
|
715
831
|
return {
|
716
832
|
"success": False,
|
717
|
-
"errors": [str(e)],
|
718
833
|
}
|
834
|
+
|
719
835
|
return {
|
720
836
|
"success": True,
|
721
|
-
"errors": [],
|
722
837
|
generalManagerClass.__name__: instance,
|
723
838
|
}
|
724
839
|
|
@@ -751,11 +866,11 @@ class GraphQL:
|
|
751
866
|
) -> type[graphene.Mutation] | None:
|
752
867
|
"""
|
753
868
|
Generates a GraphQL mutation class for deactivating (soft-deleting) an instance of a GeneralManager subclass.
|
754
|
-
|
755
|
-
The generated mutation accepts input fields defined by the manager's interface, deactivates the specified instance using its ID, and returns a dictionary containing
|
756
|
-
|
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
|
+
|
757
872
|
Returns:
|
758
|
-
The generated Graphene mutation class, or None if
|
873
|
+
The generated Graphene mutation class, or None if no interface is defined.
|
759
874
|
"""
|
760
875
|
interface_cls: InterfaceBase | None = getattr(
|
761
876
|
generalManagerClass, "Interface", None
|
@@ -769,13 +884,10 @@ class GraphQL:
|
|
769
884
|
**kwargs: Any,
|
770
885
|
) -> dict:
|
771
886
|
"""
|
772
|
-
Deactivates an instance of
|
773
|
-
|
887
|
+
Deactivates an instance of a GeneralManager subclass and returns the operation result.
|
888
|
+
|
774
889
|
Returns:
|
775
|
-
dict: A dictionary with
|
776
|
-
- "success": Boolean indicating if the operation was successful.
|
777
|
-
- "errors": List of error messages, empty if successful.
|
778
|
-
- <ClassName>: The deactivated instance, keyed by the class name.
|
890
|
+
dict: A dictionary with a "success" boolean and the deactivated instance keyed by its class name.
|
779
891
|
"""
|
780
892
|
try:
|
781
893
|
manager_id = kwargs.pop("id", None)
|
@@ -783,13 +895,13 @@ class GraphQL:
|
|
783
895
|
creator_id=info.context.user.id
|
784
896
|
)
|
785
897
|
except Exception as e:
|
898
|
+
GraphQL._handleGraphQLError(e)
|
786
899
|
return {
|
787
900
|
"success": False,
|
788
|
-
"errors": [str(e)],
|
789
901
|
}
|
902
|
+
|
790
903
|
return {
|
791
904
|
"success": True,
|
792
|
-
"errors": [],
|
793
905
|
generalManagerClass.__name__: instance,
|
794
906
|
}
|
795
907
|
|
@@ -813,3 +925,32 @@ class GraphQL:
|
|
813
925
|
"mutate": delete_mutation,
|
814
926
|
},
|
815
927
|
)
|
928
|
+
|
929
|
+
@staticmethod
|
930
|
+
def _handleGraphQLError(error: Exception) -> None:
|
931
|
+
"""
|
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".
|
935
|
+
"""
|
936
|
+
if isinstance(error, PermissionError):
|
937
|
+
raise GraphQLError(
|
938
|
+
str(error),
|
939
|
+
extensions={
|
940
|
+
"code": "PERMISSION_DENIED",
|
941
|
+
},
|
942
|
+
)
|
943
|
+
elif isinstance(error, (ValueError, ValidationError)):
|
944
|
+
raise GraphQLError(
|
945
|
+
str(error),
|
946
|
+
extensions={
|
947
|
+
"code": "BAD_USER_INPUT",
|
948
|
+
},
|
949
|
+
)
|
950
|
+
else:
|
951
|
+
raise GraphQLError(
|
952
|
+
str(error),
|
953
|
+
extensions={
|
954
|
+
"code": "INTERNAL_SERVER_ERROR",
|
955
|
+
},
|
956
|
+
)
|
general_manager/api/mutation.py
CHANGED
@@ -21,19 +21,23 @@ from general_manager.permission.mutationPermission import MutationPermission
|
|
21
21
|
|
22
22
|
def graphQlMutation(permission: Optional[Type[MutationPermission]] = None):
|
23
23
|
"""
|
24
|
-
Decorator that
|
25
|
-
|
26
|
-
The decorated function must
|
27
|
-
|
24
|
+
Decorator that converts a function into a GraphQL mutation class for use with Graphene, automatically generating argument and output fields from the function's signature and type annotations.
|
25
|
+
|
26
|
+
The decorated function must provide type hints for all parameters (except `info`) and a return annotation. The decorator dynamically constructs a mutation class with appropriate Graphene fields, enforces permission checks if a `permission` class is provided, and registers the mutation for use in the GraphQL API.
|
27
|
+
|
28
28
|
Parameters:
|
29
|
-
|
30
|
-
|
31
|
-
|
29
|
+
permission (Optional[Type[MutationPermission]]): An optional permission class to enforce access control on the mutation.
|
30
|
+
|
32
31
|
Returns:
|
33
32
|
Callable: A decorator that registers the mutation and returns the original function.
|
34
33
|
"""
|
35
34
|
|
36
35
|
def decorator(fn):
|
36
|
+
"""
|
37
|
+
Decorator that transforms a function into a GraphQL mutation class compatible with Graphene.
|
38
|
+
|
39
|
+
Analyzes the decorated function's signature and type hints to dynamically generate a mutation class with appropriate argument and output fields. Handles permission checks if a permission class is provided, manages mutation execution, and registers the mutation for use in the GraphQL API. On success, returns output fields and a `success` flag; on error, returns only `success=False`.
|
40
|
+
"""
|
37
41
|
sig = inspect.signature(fn)
|
38
42
|
hints = get_type_hints(fn)
|
39
43
|
|
@@ -86,10 +90,9 @@ def graphQlMutation(permission: Optional[Type[MutationPermission]] = None):
|
|
86
90
|
|
87
91
|
Arguments = type("Arguments", (), arg_fields)
|
88
92
|
|
89
|
-
# Build output fields: success
|
93
|
+
# Build output fields: success + fn return types
|
90
94
|
outputs = {
|
91
95
|
"success": graphene.Boolean(required=True),
|
92
|
-
"errors": graphene.List(graphene.String),
|
93
96
|
}
|
94
97
|
return_ann: type | tuple[type] | None = hints.get("return")
|
95
98
|
if return_ann is None:
|
@@ -120,28 +123,35 @@ def graphQlMutation(permission: Optional[Type[MutationPermission]] = None):
|
|
120
123
|
# Define mutate method
|
121
124
|
def _mutate(root, info, **kwargs):
|
122
125
|
|
126
|
+
"""
|
127
|
+
Handles the execution of a GraphQL mutation, including permission checks, result unpacking, and error handling.
|
128
|
+
|
129
|
+
Returns:
|
130
|
+
An instance of the mutation class with output fields populated from the mutation result and a success status.
|
131
|
+
"""
|
123
132
|
if permission:
|
124
133
|
permission.check(kwargs, info.context.user)
|
125
134
|
try:
|
126
135
|
result = fn(info, **kwargs)
|
127
136
|
data = {}
|
128
137
|
if isinstance(result, tuple):
|
129
|
-
# unpack according to outputs ordering after success
|
138
|
+
# unpack according to outputs ordering after success
|
130
139
|
for (field, _), val in zip(
|
131
|
-
outputs.items(),
|
140
|
+
outputs.items(),
|
141
|
+
[None, *list(result)], # None for success field to be set later
|
132
142
|
):
|
133
|
-
# skip success
|
134
|
-
if field
|
143
|
+
# skip success
|
144
|
+
if field == "success":
|
135
145
|
continue
|
136
146
|
data[field] = val
|
137
147
|
else:
|
138
|
-
only = next(k for k in outputs if k
|
148
|
+
only = next(k for k in outputs if k != "success")
|
139
149
|
data[only] = result
|
140
150
|
data["success"] = True
|
141
|
-
data["errors"] = []
|
142
151
|
return mutation_class(**data)
|
143
152
|
except Exception as e:
|
144
|
-
|
153
|
+
GraphQL._handleGraphQLError(e)
|
154
|
+
return mutation_class(**{"success": False})
|
145
155
|
|
146
156
|
# Assemble class dict
|
147
157
|
class_dict = {
|
@@ -22,20 +22,18 @@ for currency in currency_units:
|
|
22
22
|
class Measurement:
|
23
23
|
def __init__(self, value: Decimal | float | int | str, unit: str):
|
24
24
|
"""
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
unit: The unit of measurement as a string.
|
30
|
-
|
25
|
+
Initialize a Measurement with a numeric value and unit.
|
26
|
+
|
27
|
+
Converts the provided value to a Decimal and associates it with the specified unit, creating a unit-aware measurement.
|
28
|
+
|
31
29
|
Raises:
|
32
|
-
|
30
|
+
ValueError: If the value cannot be converted to a Decimal.
|
33
31
|
"""
|
34
32
|
if not isinstance(value, (Decimal, float, int)):
|
35
33
|
try:
|
36
34
|
value = Decimal(str(value))
|
37
35
|
except Exception:
|
38
|
-
raise
|
36
|
+
raise ValueError("Value must be a Decimal, float, int or compatible.")
|
39
37
|
if not isinstance(value, Decimal):
|
40
38
|
value = Decimal(str(value))
|
41
39
|
self.__quantity = ureg.Quantity(self.formatDecimal(value), unit)
|
@@ -230,13 +228,13 @@ class Measurement:
|
|
230
228
|
"""
|
231
229
|
Multiply this measurement by another measurement or a numeric value.
|
232
230
|
|
233
|
-
|
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.
|
234
232
|
|
235
233
|
Returns:
|
236
|
-
Measurement: The
|
234
|
+
Measurement: The product as a new Measurement instance.
|
237
235
|
|
238
236
|
Raises:
|
239
|
-
TypeError: If both operands are currency measurements, or if the operand is
|
237
|
+
TypeError: If both operands are currency measurements, or if the operand is neither a Measurement nor a numeric value.
|
240
238
|
"""
|
241
239
|
if isinstance(other, Measurement):
|
242
240
|
if self.is_currency() and other.is_currency():
|
@@ -306,17 +304,19 @@ class Measurement:
|
|
306
304
|
|
307
305
|
def _compare(self, other: Any, operation: Callable[..., bool]) -> bool:
|
308
306
|
"""
|
309
|
-
|
310
|
-
|
311
|
-
If `other` is a string, it is parsed as a Measurement.
|
312
|
-
|
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
|
+
|
313
311
|
Parameters:
|
314
|
-
other: The object to compare,
|
315
|
-
operation: A callable that takes two magnitudes and returns a boolean.
|
316
|
-
|
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
|
+
|
317
315
|
Returns:
|
318
|
-
bool: The result of the comparison.
|
316
|
+
bool: The result of applying the comparison operation to the magnitudes.
|
319
317
|
"""
|
318
|
+
if other is None or other in ("", [], (), {}):
|
319
|
+
return False
|
320
320
|
if isinstance(other, str):
|
321
321
|
other = Measurement.from_string(other)
|
322
322
|
|
@@ -1,168 +1,271 @@
|
|
1
|
-
# fields.py
|
2
1
|
from __future__ import annotations
|
2
|
+
|
3
3
|
from django.db import models
|
4
4
|
from django.core.exceptions import ValidationError
|
5
|
+
from django.db.models.expressions import Col
|
5
6
|
from decimal import Decimal
|
6
|
-
from general_manager.measurement.measurement import Measurement, ureg, currency_units
|
7
7
|
import pint
|
8
|
-
from
|
8
|
+
from general_manager.measurement.measurement import Measurement, ureg, currency_units
|
9
9
|
|
10
10
|
|
11
|
-
class MeasurementField(models.Field):
|
12
|
-
description = (
|
13
|
-
|
14
|
-
)
|
11
|
+
class MeasurementField(models.Field):
|
12
|
+
description = "Stores a measurement (value + unit) but exposes a single field API"
|
13
|
+
|
14
|
+
empty_values = (None, "", [], (), {})
|
15
15
|
|
16
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,
|
17
|
+
self, base_unit: str, *args, null=False, blank=False, editable=True, **kwargs
|
24
18
|
):
|
25
19
|
"""
|
26
|
-
Initialize a MeasurementField to store
|
27
|
-
|
20
|
+
Initialize a MeasurementField to store a numeric value and its unit with unit-aware validation.
|
21
|
+
|
28
22
|
Parameters:
|
29
|
-
base_unit (str): The canonical unit
|
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
|
33
|
-
|
34
|
-
The field internally manages a DecimalField for the value
|
35
|
-
"""
|
36
|
-
self.base_unit = base_unit
|
37
|
-
# Determine the dimensionality of the base unit
|
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
|
38
31
|
self.base_dimension = ureg.parse_expression(self.base_unit).dimensionality
|
39
|
-
|
40
|
-
|
41
|
-
if null
|
42
|
-
|
43
|
-
if blank
|
44
|
-
|
32
|
+
|
33
|
+
nb = {}
|
34
|
+
if null:
|
35
|
+
nb["null"] = True
|
36
|
+
if blank:
|
37
|
+
nb["blank"] = True
|
38
|
+
|
45
39
|
self.editable = editable
|
46
|
-
self.value_field
|
47
|
-
max_digits=30,
|
48
|
-
decimal_places=10,
|
49
|
-
db_index=True,
|
50
|
-
**null_blank_kwargs,
|
51
|
-
editable=editable,
|
40
|
+
self.value_field = models.DecimalField(
|
41
|
+
max_digits=30, decimal_places=10, db_index=True, editable=editable, **nb
|
52
42
|
)
|
53
|
-
self.unit_field
|
54
|
-
max_length=30, **null_blank_kwargs, editable=editable
|
55
|
-
)
|
56
|
-
super().__init__(null=null, blank=blank, *args, **kwargs)
|
43
|
+
self.unit_field = models.CharField(max_length=30, editable=editable, **nb)
|
57
44
|
|
58
|
-
|
59
|
-
|
60
|
-
|
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)
|
61
49
|
"""
|
62
|
-
|
63
|
-
|
64
|
-
This method
|
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.
|
65
53
|
"""
|
66
|
-
|
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
|
+
|
67
59
|
self.value_attr = f"{name}_value"
|
68
60
|
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
61
|
|
76
|
-
|
77
|
-
self.
|
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)
|
78
74
|
|
79
|
-
|
80
|
-
|
75
|
+
# Descriptor override
|
76
|
+
setattr(cls, name, self)
|
81
77
|
|
82
|
-
|
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.
|
83
137
|
|
84
|
-
|
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
|
+
"""
|
85
192
|
if instance is None:
|
86
193
|
return self
|
87
|
-
|
194
|
+
val = getattr(instance, self.value_attr)
|
88
195
|
unit = getattr(instance, self.unit_attr)
|
89
|
-
if
|
196
|
+
if val is None or unit is None:
|
90
197
|
return None
|
91
|
-
|
92
|
-
quantity_in_base_unit = Decimal(value) * ureg(self.base_unit)
|
93
|
-
# Convert back to the original unit
|
198
|
+
qty_base = Decimal(val) * ureg(self.base_unit)
|
94
199
|
try:
|
95
|
-
|
200
|
+
qty_orig = qty_base.to(unit)
|
96
201
|
except pint.errors.DimensionalityError:
|
97
|
-
|
98
|
-
|
99
|
-
return Measurement(
|
100
|
-
quantity_in_original_unit.magnitude, str(quantity_in_original_unit.units)
|
101
|
-
)
|
202
|
+
qty_orig = qty_base
|
203
|
+
return Measurement(qty_orig.magnitude, str(qty_orig.units))
|
102
204
|
|
103
|
-
def __set__(self, instance
|
104
|
-
|
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:
|
105
212
|
raise ValidationError(f"{self.name} is not editable.")
|
106
213
|
if value is None:
|
107
214
|
setattr(instance, self.value_attr, None)
|
108
215
|
setattr(instance, self.unit_attr, None)
|
109
216
|
return
|
110
|
-
|
217
|
+
if isinstance(value, str):
|
111
218
|
try:
|
112
219
|
value = Measurement.from_string(value)
|
113
|
-
except ValueError:
|
220
|
+
except ValueError as e:
|
114
221
|
raise ValidationError(
|
115
222
|
{self.name: ["Value must be a Measurement instance or None."]}
|
116
|
-
)
|
117
|
-
if isinstance(value, Measurement):
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
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:
|
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():
|
146
231
|
raise ValidationError(
|
147
232
|
{
|
148
233
|
self.name: [
|
149
|
-
f"
|
234
|
+
f"Unit must be a currency ({', '.join(currency_units)})."
|
150
235
|
]
|
151
236
|
}
|
152
237
|
)
|
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
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:
|
157
245
|
raise ValidationError(
|
158
|
-
{self.name: ["
|
159
|
-
)
|
246
|
+
{self.name: [f"Unit must be compatible with '{self.base_unit}'."]}
|
247
|
+
) from e
|
160
248
|
|
161
|
-
|
162
|
-
|
163
|
-
|
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
|
164
269
|
|
165
|
-
|
166
|
-
|
167
|
-
kwargs["base_unit"] = self.base_unit
|
168
|
-
return name, path, args, kwargs
|
270
|
+
for validator in self.validators:
|
271
|
+
validator(value)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: GeneralManager
|
3
|
-
Version: 0.
|
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,7 +1,7 @@
|
|
1
1
|
general_manager/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
2
2
|
general_manager/apps.py,sha256=ii-48klC2kBRjxtdQU9-RtPq66lZ8fDa7BmqyH-mbzk,9904
|
3
|
-
general_manager/api/graphql.py,sha256=
|
4
|
-
general_manager/api/mutation.py,sha256=
|
3
|
+
general_manager/api/graphql.py,sha256=FyukDVM3doDAcD_TTeGkrIIHtsMqTEDX5Opa-hUZcrk,39351
|
4
|
+
general_manager/api/mutation.py,sha256=9T892INzH4ip5sX3_BxB2WX145zt8S_0HGVPhGzyXZA,6736
|
5
5
|
general_manager/api/property.py,sha256=oc93p1P8dcIvrNorRuqD1EJVsd6eYttYhZuAS0s28gs,696
|
6
6
|
general_manager/bucket/baseBucket.py,sha256=UjEai7cVxgDJysoVY2-7s3YULW7bi-sx2mPqRKFWyTw,7728
|
7
7
|
general_manager/bucket/calculationBucket.py,sha256=a41YVdfhxKf_gpWlRKjXYmS1YuO-6VC0hn60RyLKByU,18777
|
@@ -29,8 +29,8 @@ general_manager/manager/groupManager.py,sha256=8dpZUfm7aFL4lraUWv4qbbDRClQZaYxw4
|
|
29
29
|
general_manager/manager/input.py,sha256=-pJXGJ-g2-OxZfl4Buj3mQkf05fN4p8MsR2Lh9BQcEo,3208
|
30
30
|
general_manager/manager/meta.py,sha256=-9celpo-oZmkTb8TnHfvcd_4XWTy1cn2UO-jp13NFmQ,6387
|
31
31
|
general_manager/measurement/__init__.py,sha256=X97meFujBldE5v0WMF7SmKeGpC5R0JTczfLo_Lq1Xek,84
|
32
|
-
general_manager/measurement/measurement.py,sha256=
|
33
|
-
general_manager/measurement/measurementField.py,sha256=
|
32
|
+
general_manager/measurement/measurement.py,sha256=wrxwbmaKOUuvwyDa6wUZ5t2mF2mTtXdy6urzA7Q7k_M,16169
|
33
|
+
general_manager/measurement/measurementField.py,sha256=hesh8YMQqBuX-thcMUcawHpQWEKUqRapn-3tbNgvoPA,11466
|
34
34
|
general_manager/permission/__init__.py,sha256=5UlDERN60Vn8obGVkT-cOM8kHjzmoxgK5w5FgTCDhGE,59
|
35
35
|
general_manager/permission/basePermission.py,sha256=DeiAX2sQQhtdquO13jys2MSkp0kPdg2oo7PSqB9q5Bw,5653
|
36
36
|
general_manager/permission/fileBasedPermission.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
@@ -51,8 +51,8 @@ general_manager/utils/makeCacheKey.py,sha256=UlFsxHXgsYy69AAelkF6GDvY4h7AImT2bBn
|
|
51
51
|
general_manager/utils/noneToZero.py,sha256=KfQtMQnrT6vsYST0K7lv6pVujkDcK3XL8czHYOhgqKQ,539
|
52
52
|
general_manager/utils/pathMapping.py,sha256=nrz5owQg2a69Yig1eCXorR9U0NSw7NmBAk5OkeoHTdA,6842
|
53
53
|
general_manager/utils/testing.py,sha256=ElZ8p4iZHxsHjDN8Lm5TmI6527CW747ltDOmtY6gAhk,11872
|
54
|
-
generalmanager-0.
|
55
|
-
generalmanager-0.
|
56
|
-
generalmanager-0.
|
57
|
-
generalmanager-0.
|
58
|
-
generalmanager-0.
|
54
|
+
generalmanager-0.12.1.dist-info/licenses/LICENSE,sha256=YGFm0ieb4KpkMRRt2qnWue6uFh0cUMtobwEBkHwajhc,1450
|
55
|
+
generalmanager-0.12.1.dist-info/METADATA,sha256=SdQ9KGXGoKes7mp8Rw1HhyJKCJzh__AlOCyRZSBBwW4,6206
|
56
|
+
generalmanager-0.12.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
57
|
+
generalmanager-0.12.1.dist-info/top_level.txt,sha256=sTDtExP9ga-YP3h3h42yivUY-A2Q23C2nw6LNKOho4I,16
|
58
|
+
generalmanager-0.12.1.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|