GeneralManager 0.12.0__py3-none-any.whl → 0.12.2__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.
@@ -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 a list of filter and exclude dictionaries based on the read permissions for the specified manager class and user context.
42
-
43
- Each tuple in the returned list contains a filter dictionary and an exclude dictionary, representing permission-based constraints to be applied to queries.
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
- Creates and registers GraphQL mutation classes (create, update, delete) for the given manager class if its interface overrides the corresponding base methods.
78
-
79
- For each supported mutation, generates a GraphQL mutation class with appropriate input and output fields, and adds it to the mutation registry.
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 the given GeneralManager subclass.
116
-
117
- This method 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.
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
- Generate a Graphene Enum type listing the sortable fields for a given GeneralManager class.
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 options for the value and unit of Measurement fields, or None if no sortable fields are found.
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}_value"] = graphene.Float()
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}_value__{option}"] = graphene.Float()
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
- Applies filtering, exclusion, and sorting parameters to a queryset.
329
-
355
+ Apply filtering, exclusion, and sorting to a queryset based on provided parameters.
356
+
330
357
  Parameters:
331
- queryset (Bucket[GeneralManager]): The queryset to modify.
332
- filter_input (dict | str | None): Filters to apply, as a dict or JSON string.
333
- exclude_input (dict | str | None): Exclusions to apply, as a dict or JSON string.
334
- sort_by (graphene.Enum | None): Field to sort by, if provided.
335
- reverse (bool): Whether to reverse the sort order.
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
- Bucket[GeneralManager]: The modified queryset after applying filters, exclusions, and sorting.
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 applied.
393
-
394
- The returned resolver processes list queries by applying permission-based filtering, user-specified filters and exclusions, sorting, grouping, and pagination. It returns a dictionary containing the paginated items and pagination metadata.
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 paginated, filtered, sorted, and optionally grouped results with permission checks applied.
410
-
435
+ Resolves a list field by returning filtered, excluded, sorted, grouped, and paginated results with permission checks.
436
+
411
437
  Parameters:
412
- filter (dict[str, Any] | str | None): Filter criteria as a dictionary or JSON string.
413
- exclude (dict[str, Any] | str | None): Exclusion criteria as a dictionary or JSON string.
414
- sort_by (graphene.Enum | None): Field to sort by.
415
- reverse (bool): Whether to reverse the sort order.
416
- page (int | None): Page number for pagination.
417
- page_size (int | None): Number of items per page.
418
- group_by (list[str] | None): List of field names to group results 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
- dict[str, Any]: A dictionary containing the paginated items under "items" and pagination metadata under "pageInfo".
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
- Return a paginated subset of the queryset based on the specified page number and page size.
457
-
458
- If either `page` or `page_size` is provided, pagination is applied; otherwise, the original queryset is returned.
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
- Group a queryset by the specified fields.
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 function for Measurement fields that returns the value and unit, optionally converting to a specified target unit.
487
-
488
- Parameters:
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
- Selects and returns the appropriate resolver function for a given field based on its type and name.
531
-
532
- For fields ending with `_list` and referencing a `GeneralManager` subclass, returns a list resolver supporting pagination and filtering. For `Measurement` fields, returns a measurement resolver. For all other fields, returns a standard resolver.
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
- Return a paginated GraphQL ObjectType for the given item type, creating and caching it if it does not already exist.
550
-
551
- Parameters:
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 supports 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 corresponding resolvers.
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
- Generate a dictionary of Graphene input fields for mutations based on the attributes of the provided interface class.
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. An optional `history_comment` field, also marked as editable, is always included.
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 accepts input fields defined by the manager's interface, filters out fields with `NOT_PROVIDED` values, and calls the manager's `create` method with the provided arguments and the current user's ID. If creation succeeds, it returns a dictionary with a success flag and the created instance; if an error occurs, a GraphQL error is raised. Returns None if the manager class does not define an interface.
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 with the provided arguments.
720
-
721
- Filters out fields set to `NOT_PROVIDED` before creation. Returns a dictionary containing a success flag and the created instance keyed by the manager class name. Raises a GraphQL error if creation fails.
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, invokes the manager's `update` method with the provided values and the current user's ID, and returns a dictionary containing the operation's success status and the updated instance. If the manager class does not define an `Interface`, returns None.
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 the specified GeneralManager class with the provided field values.
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, containing user and request information.
794
- **kwargs: Fields to update, including the required 'id' of the instance.
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: Contains 'success' (bool) and the updated instance keyed by its class name.
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. If the manager class does not define an interface, returns None.
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 the specified GeneralManager class and returns the result.
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: Contains "success" (bool) and the deactivated instance keyed by its class name.
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 an appropriate error code based on the type of exception.
912
-
913
- If the error is a PermissionError, the code is set to "PERMISSION_DENIED". For ValueError or ValidationError, the code is "BAD_USER_INPUT". All other exceptions result in a code of "INTERNAL_SERVER_ERROR".
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(
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
  from typing import Type, Any, Callable, TYPE_CHECKING, TypeVar, Generic, cast
3
3
  from django.db import models
4
+
4
5
  from datetime import datetime, timedelta
5
6
  from general_manager.measurement.measurement import Measurement
6
7
  from general_manager.measurement.measurementField import MeasurementField
@@ -66,6 +67,25 @@ class DBBasedInterface(InterfaceBase, Generic[MODEL_TYPE]):
66
67
  instance = self.getHistoricalRecord(instance, search_date)
67
68
  return instance
68
69
 
70
+ @staticmethod
71
+ def __parseKwargs(**kwargs: Any) -> dict[str, Any]:
72
+ """
73
+ Parses keyword arguments to ensure they are compatible with the model's fields.
74
+
75
+ Converts GeneralManager instances to their primary key values and returns a dictionary of parsed arguments.
76
+ """
77
+ from general_manager.manager.generalManager import GeneralManager
78
+
79
+ parsed_kwargs: dict[str, Any] = {}
80
+ for key, value in kwargs.items():
81
+ if isinstance(value, GeneralManager):
82
+ parsed_kwargs[key] = getattr(
83
+ value._interface, "_instance", value.identification["id"]
84
+ )
85
+ else:
86
+ parsed_kwargs[key] = value
87
+ return parsed_kwargs
88
+
69
89
  @classmethod
70
90
  def filter(cls, **kwargs: Any) -> DatabaseBucket:
71
91
  """
@@ -77,6 +97,9 @@ class DBBasedInterface(InterfaceBase, Generic[MODEL_TYPE]):
77
97
  Returns:
78
98
  A DatabaseBucket wrapping the filtered queryset and associated metadata.
79
99
  """
100
+
101
+ kwargs = cls.__parseKwargs(**kwargs)
102
+
80
103
  return DatabaseBucket(
81
104
  cls._model.objects.filter(**kwargs),
82
105
  cls._parent_class,
@@ -94,6 +117,8 @@ class DBBasedInterface(InterfaceBase, Generic[MODEL_TYPE]):
94
117
  Returns:
95
118
  A DatabaseBucket wrapping the queryset of excluded model instances.
96
119
  """
120
+ kwargs = cls.__parseKwargs(**kwargs)
121
+
97
122
  return DatabaseBucket(
98
123
  cls._model.objects.exclude(**kwargs),
99
124
  cls._parent_class,
@@ -135,9 +160,9 @@ class DBBasedInterface(InterfaceBase, Generic[MODEL_TYPE]):
135
160
  @classmethod
136
161
  def getAttributeTypes(cls) -> dict[str, AttributeTypedDict]:
137
162
  """
138
- Return a dictionary mapping each attribute name of the model to its type information and metadata.
163
+ Return a dictionary mapping each model attribute name to its type information and metadata.
139
164
 
140
- The returned dictionary includes all standard model fields, custom fields, foreign keys, many-to-many, and reverse relation fields, excluding any GenericForeignKey fields. For each attribute, the metadata specifies its Python type (translated from Django field types when possible), whether it is required, editable, derived, and its default value. For related models with a general manager class, the type is set to that class.
165
+ Includes standard fields, custom fields, foreign keys, many-to-many, and reverse relation fields, excluding GenericForeignKey fields. For each attribute, provides its Python type (translated from Django field types when possible), required and editable status, whether it is derived, and its default value. For related models with a general manager class, the type is set to that class.
141
166
 
142
167
  Returns:
143
168
  dict[str, AttributeTypedDict]: Mapping of attribute names to their type information and metadata.
@@ -178,7 +203,8 @@ class DBBasedInterface(InterfaceBase, Generic[MODEL_TYPE]):
178
203
  fields[field_name] = {
179
204
  "type": type(field),
180
205
  "is_derived": False,
181
- "is_required": not field.null,
206
+ "is_required": not field.null
207
+ and field.default is models.NOT_PROVIDED,
182
208
  "is_editable": field.editable,
183
209
  "default": field.default,
184
210
  }
@@ -196,7 +222,7 @@ class DBBasedInterface(InterfaceBase, Generic[MODEL_TYPE]):
196
222
  ):
197
223
  related_model = related_model._general_manager_class # type: ignore
198
224
 
199
- elif related_model is not None:
225
+ if related_model is not None:
200
226
  default = None
201
227
  if hasattr(field, "default"):
202
228
  default = field.default # type: ignore
@@ -247,12 +273,12 @@ class DBBasedInterface(InterfaceBase, Generic[MODEL_TYPE]):
247
273
  @classmethod
248
274
  def getAttributes(cls) -> dict[str, Callable[[DBBasedInterface], Any]]:
249
275
  """
250
- Return a dictionary mapping attribute names to callables that retrieve values from a DBBasedInterface instance.
276
+ Return a mapping of attribute names to callables that extract values from a DBBasedInterface instance.
251
277
 
252
- The mapping includes accessors for custom fields, standard model fields, foreign keys, many-to-many relations, and reverse relations. For related models with a general manager class, the accessor returns an instance of that class; otherwise, it returns the related object or queryset. Raises a ValueError if a field name conflict is detected.
278
+ The returned dictionary includes accessors for custom fields, standard model fields, foreign keys, many-to-many relations, and reverse relations. For related models with a general manager class, the accessor returns an instance or queryset of that class; otherwise, it returns the related object or queryset directly. Raises a ValueError if a field name conflict is detected.
253
279
 
254
280
  Returns:
255
- dict: A dictionary where keys are attribute names and values are callables that extract the corresponding value from a DBBasedInterface instance.
281
+ dict[str, Callable[[DBBasedInterface], Any]]: Mapping of attribute names to callables for retrieving values from a DBBasedInterface instance.
256
282
  """
257
283
  from general_manager.manager.generalManager import GeneralManager
258
284
 
@@ -306,12 +332,22 @@ class DBBasedInterface(InterfaceBase, Generic[MODEL_TYPE]):
306
332
  cls._model._meta.get_field(field_name).related_model,
307
333
  "_general_manager_class",
308
334
  ):
335
+ related_model = cast(
336
+ Type[models.Model],
337
+ cls._model._meta.get_field(field_name).related_model,
338
+ )
339
+ related_fields = [
340
+ f
341
+ for f in related_model._meta.get_fields()
342
+ if f.related_model == cls._model
343
+ ]
344
+
309
345
  field_values[
310
346
  f"{field_name}_list"
311
- ] = lambda self, field_name=field_name: self._instance._meta.get_field(
347
+ ] = lambda self, field_name=field_name, related_fields=related_fields: self._instance._meta.get_field(
312
348
  field_name
313
349
  ).related_model._general_manager_class.filter(
314
- **{self._instance.__class__.__name__.lower(): self.pk}
350
+ **{related_field.name: self.pk for related_field in related_fields}
315
351
  )
316
352
  else:
317
353
  field_values[f"{field_name}_list"] = (
@@ -319,6 +355,7 @@ class DBBasedInterface(InterfaceBase, Generic[MODEL_TYPE]):
319
355
  self._instance, field_call
320
356
  ).all()
321
357
  )
358
+
322
359
  return field_values
323
360
 
324
361
  @staticmethod
@@ -84,19 +84,23 @@ class DatabaseInterface(DBBasedInterface[GeneralManagerModel]):
84
84
  instance: GeneralManagerModel, many_to_many_kwargs: dict[str, list[Any]]
85
85
  ) -> GeneralManagerModel:
86
86
  """
87
- Sets many-to-many attributes for the given instance based on the provided kwargs.
87
+ Set many-to-many relationship fields on a model instance using provided values.
88
88
 
89
- Args:
90
- instance: The model instance to update.
91
- many_to_many_kwargs: A dictionary containing many-to-many field names and their corresponding values.
92
-
93
- Returns:
94
- The updated model instance.
89
+ Converts lists of `GeneralManager` instances to their corresponding IDs before updating the relationships. Returns the updated instance.
95
90
  """
91
+ from general_manager.manager.generalManager import GeneralManager
92
+
96
93
  for key, value in many_to_many_kwargs.items():
97
94
  if not value:
98
95
  continue
99
- field_name = key.split("_id_list")[0]
96
+ field_name = key.removesuffix("_id_list")
97
+ if isinstance(value, list) and all(
98
+ isinstance(v, GeneralManager) for v in value
99
+ ):
100
+ value = [
101
+ v.identification["id"] if hasattr(v, "identification") else v
102
+ for v in value
103
+ ]
100
104
  getattr(instance, field_name).set(value)
101
105
 
102
106
  return instance
@@ -39,7 +39,7 @@ def getFullCleanMethode(model: Type[models.Model]) -> Callable[..., None]:
39
39
  except ValidationError as e:
40
40
  errors.update(e.message_dict)
41
41
 
42
- rules: list[Rule] = getattr(self._meta, "rules")
42
+ rules: list[Rule] = getattr(self._meta, "rules", [])
43
43
  for rule in rules:
44
44
  if rule.evaluate(self) is False:
45
45
  error_message = rule.getErrorMessage()
@@ -24,7 +24,7 @@ class Measurement:
24
24
  """
25
25
  Initialize a Measurement with a numeric value and unit.
26
26
 
27
- Creates a Measurement instance by converting the provided value to a Decimal and associating it with the specified unit. Accepts numeric types or strings convertible to Decimal.
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
- Multiplies this measurement by another measurement or a numeric value.
229
+ Multiply this measurement by another measurement or a numeric value.
230
230
 
231
- Multiplication between two currency measurements is not permitted. When multiplying by another measurement, the resulting measurement combines their units. When multiplying by a numeric value, only the magnitude is scaled.
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: A new measurement representing the product.
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
- Compares this Measurement to another using a specified comparison operation.
308
-
309
- If `other` is a string, it is parsed as a Measurement. The comparison is performed after converting `other` to this instance's unit. Raises a TypeError if `other` is not a Measurement or a valid string, and a ValueError if the measurements have incompatible dimensions.
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, either a Measurement or a string in the format "value unit".
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
 
@@ -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 typing import Any
8
+ from general_manager.measurement.measurement import Measurement, ureg, currency_units
9
9
 
10
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
- )
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 values in a specified base unit and retain the original unit.
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 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
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
- # 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
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: models.DecimalField[Decimal] = models.DecimalField(
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: models.CharField[str] = models.CharField(
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
- def contribute_to_class(
59
- self, cls: type, name: str, private_only: bool = False, **kwargs: Any
60
- ) -> None:
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
- 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.
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
- self.name = name
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
- self.value_field.model = cls
77
- self.unit_field.model = cls
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
- self.value_field.contribute_to_class(cls, self.value_attr)
80
- self.unit_field.contribute_to_class(cls, self.unit_attr)
75
+ # Descriptor override
76
+ setattr(cls, name, self)
81
77
 
82
- setattr(cls, self.name, self)
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
- def __get__(self, instance: Any, owner: Any) -> Any:
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
- value = getattr(instance, self.value_attr)
194
+ val = getattr(instance, self.value_attr)
88
195
  unit = getattr(instance, self.unit_attr)
89
- if value is None or unit is None:
196
+ if val is None or unit is None:
90
197
  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
198
+ qty_base = Decimal(val) * ureg(self.base_unit)
94
199
  try:
95
- quantity_in_original_unit: pint.Quantity = quantity_in_base_unit.to(unit) # type: ignore
200
+ qty_orig = qty_base.to(unit)
96
201
  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
- )
202
+ qty_orig = qty_base
203
+ return Measurement(qty_orig.magnitude, str(qty_orig.units))
102
204
 
103
- def __set__(self, instance: Any, value: Any) -> None:
104
- if self.editable is False:
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
- elif isinstance(value, str):
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
- 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:
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"The unit must be compatible with '{self.base_unit}'."
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: ["Value must be a Measurement instance or None."]}
159
- )
246
+ {self.name: [f"Unit must be compatible with '{self.base_unit}'."]}
247
+ ) from e
160
248
 
161
- def get_prep_value(self, value: Any) -> Any:
162
- # Not needed since we use internal fields
163
- pass
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
- def deconstruct(self):
166
- name, path, args, kwargs = super().deconstruct()
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.12.0
3
+ Version: 0.12.2
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
  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=xNL2jt2x2U7x7UIFfoDm6zxH-gag2yWS3MbOu9ccIEQ,39033
3
+ general_manager/api/graphql.py,sha256=FyukDVM3doDAcD_TTeGkrIIHtsMqTEDX5Opa-hUZcrk,39351
4
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
@@ -19,9 +19,9 @@ general_manager/factory/factoryMethods.py,sha256=9Bag891j0XHe3dUBAFi7gUKcKeUwcBZ
19
19
  general_manager/interface/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
20
  general_manager/interface/baseInterface.py,sha256=cFsDU-nhj_O6Gir3eO0ukGKNn9Pplhe6gEMccHNi4O0,8648
21
21
  general_manager/interface/calculationInterface.py,sha256=fTD3WQpsn3ImaxGW5S-JwVJyJJPoPp2mR6lAambdB8U,4755
22
- general_manager/interface/databaseBasedInterface.py,sha256=F-a6P9rvPUmlcoZw1rKSWEAzbYGccQRL6uokv6C46qo,21782
23
- general_manager/interface/databaseInterface.py,sha256=rhKVXhg0ztdIxKikTWtgjrkA7cwZTOYlEsRh0RWajDQ,6732
24
- general_manager/interface/models.py,sha256=P4bYb-Q7-moZR-hgIqwruMuftDgUW6FSqjPtWfHW04o,3311
22
+ general_manager/interface/databaseBasedInterface.py,sha256=uB4_kJ1kNSK-QnUrFKR0zrDP-UulBKeeHIslXqEdU4Q,23058
23
+ general_manager/interface/databaseInterface.py,sha256=WRJehbV2Rov9rSL001eEdzacCYzx2AzvOCkwZ2Zv8ow,7001
24
+ general_manager/interface/models.py,sha256=iYuSTMWKGrH5cjmxTii8HRpSmUUMhtw6xvatRzB4zuA,3315
25
25
  general_manager/interface/readOnlyInterface.py,sha256=TkfbOeaa2wCq5kCv0a3IwJWcYOTVbtNsdNWmGAz0Mns,11217
26
26
  general_manager/manager/__init__.py,sha256=l3RYp62aEhj3Y975_XUTIzo35LUnkTJHkb_hgChnXXI,111
27
27
  general_manager/manager/generalManager.py,sha256=4Qn9TYpZpqh5qC95BEAQhpiZgDrRXrAJjO2BbbXUdNg,9129
@@ -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=YaGcYS1ZyHMEPp8b_RojXU9GMVk7NCvlPwWdTdtgRR0,16090
33
- general_manager/measurement/measurementField.py,sha256=ixkZR_t--HGckK2iYi6sXOOa_Vbni4QRxK5Ngmy5YKc,6863
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.12.0.dist-info/licenses/LICENSE,sha256=YGFm0ieb4KpkMRRt2qnWue6uFh0cUMtobwEBkHwajhc,1450
55
- generalmanager-0.12.0.dist-info/METADATA,sha256=I7psaouHX8DtSRATISXfZRFECK-1x4srKdSe2CvuW2Q,6206
56
- generalmanager-0.12.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
57
- generalmanager-0.12.0.dist-info/top_level.txt,sha256=sTDtExP9ga-YP3h3h42yivUY-A2Q23C2nw6LNKOho4I,16
58
- generalmanager-0.12.0.dist-info/RECORD,,
54
+ generalmanager-0.12.2.dist-info/licenses/LICENSE,sha256=YGFm0ieb4KpkMRRt2qnWue6uFh0cUMtobwEBkHwajhc,1450
55
+ generalmanager-0.12.2.dist-info/METADATA,sha256=5pw28Ng1rz1k2PTf7bI-EbulRwyZHkdikpqYAxRqJ24,6206
56
+ generalmanager-0.12.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
57
+ generalmanager-0.12.2.dist-info/top_level.txt,sha256=sTDtExP9ga-YP3h3h42yivUY-A2Q23C2nw6LNKOho4I,16
58
+ generalmanager-0.12.2.dist-info/RECORD,,