GeneralManager 0.11.2__tar.gz → 0.12.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {generalmanager-0.11.2 → generalmanager-0.12.1}/GeneralManager.egg-info/PKG-INFO +1 -1
  2. {generalmanager-0.11.2 → generalmanager-0.12.1}/PKG-INFO +1 -1
  3. {generalmanager-0.11.2 → generalmanager-0.12.1}/pyproject.toml +1 -1
  4. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/api/graphql.py +242 -101
  5. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/api/mutation.py +26 -16
  6. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/measurement/measurement.py +19 -19
  7. generalmanager-0.12.1/src/general_manager/measurement/measurementField.py +271 -0
  8. generalmanager-0.11.2/src/general_manager/measurement/measurementField.py +0 -168
  9. {generalmanager-0.11.2 → generalmanager-0.12.1}/GeneralManager.egg-info/SOURCES.txt +0 -0
  10. {generalmanager-0.11.2 → generalmanager-0.12.1}/GeneralManager.egg-info/dependency_links.txt +0 -0
  11. {generalmanager-0.11.2 → generalmanager-0.12.1}/GeneralManager.egg-info/requires.txt +0 -0
  12. {generalmanager-0.11.2 → generalmanager-0.12.1}/GeneralManager.egg-info/top_level.txt +0 -0
  13. {generalmanager-0.11.2 → generalmanager-0.12.1}/LICENSE +0 -0
  14. {generalmanager-0.11.2 → generalmanager-0.12.1}/README.md +0 -0
  15. {generalmanager-0.11.2 → generalmanager-0.12.1}/setup.cfg +0 -0
  16. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/__init__.py +0 -0
  17. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/api/property.py +0 -0
  18. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/apps.py +0 -0
  19. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/bucket/baseBucket.py +0 -0
  20. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/bucket/calculationBucket.py +0 -0
  21. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/bucket/databaseBucket.py +0 -0
  22. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/bucket/groupBucket.py +0 -0
  23. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/cache/cacheDecorator.py +0 -0
  24. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/cache/cacheTracker.py +0 -0
  25. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/cache/dependencyIndex.py +0 -0
  26. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/cache/modelDependencyCollector.py +0 -0
  27. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/cache/signals.py +0 -0
  28. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/factory/__init__.py +0 -0
  29. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/factory/autoFactory.py +0 -0
  30. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/factory/factories.py +0 -0
  31. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/factory/factoryMethods.py +0 -0
  32. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/interface/__init__.py +0 -0
  33. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/interface/baseInterface.py +0 -0
  34. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/interface/calculationInterface.py +0 -0
  35. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/interface/databaseBasedInterface.py +0 -0
  36. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/interface/databaseInterface.py +0 -0
  37. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/interface/models.py +0 -0
  38. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/interface/readOnlyInterface.py +0 -0
  39. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/manager/__init__.py +0 -0
  40. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/manager/generalManager.py +0 -0
  41. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/manager/groupManager.py +0 -0
  42. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/manager/input.py +0 -0
  43. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/manager/meta.py +0 -0
  44. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/measurement/__init__.py +0 -0
  45. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/permission/__init__.py +0 -0
  46. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/permission/basePermission.py +0 -0
  47. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/permission/fileBasedPermission.py +0 -0
  48. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/permission/managerBasedPermission.py +0 -0
  49. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/permission/mutationPermission.py +0 -0
  50. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/permission/permissionChecks.py +0 -0
  51. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/permission/permissionDataManager.py +0 -0
  52. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/permission/utils.py +0 -0
  53. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/rule/__init__.py +0 -0
  54. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/rule/handler.py +0 -0
  55. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/rule/rule.py +0 -0
  56. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/utils/__init__.py +0 -0
  57. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/utils/argsToKwargs.py +0 -0
  58. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/utils/filterParser.py +0 -0
  59. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/utils/formatString.py +0 -0
  60. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/utils/jsonEncoder.py +0 -0
  61. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/utils/makeCacheKey.py +0 -0
  62. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/utils/noneToZero.py +0 -0
  63. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/utils/pathMapping.py +0 -0
  64. {generalmanager-0.11.2 → generalmanager-0.12.1}/src/general_manager/utils/testing.py +0 -0
  65. {generalmanager-0.11.2 → generalmanager-0.12.1}/tests/test_settings.py +0 -0
  66. {generalmanager-0.11.2 → generalmanager-0.12.1}/tests/test_urls.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: GeneralManager
3
- Version: 0.11.2
3
+ Version: 0.12.1
4
4
  Summary: Modular Django-based data management framework with ORM, GraphQL, fine-grained permissions, rule validation, calculations and caching.
5
5
  Author-email: Tim Kleindick <tkleindick@yahoo.de>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: GeneralManager
3
- Version: 0.11.2
3
+ Version: 0.12.1
4
4
  Summary: Modular Django-based data management framework with ORM, GraphQL, fine-grained permissions, rule validation, calculations and caching.
5
5
  Author-email: Tim Kleindick <tkleindick@yahoo.de>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "GeneralManager"
7
- version = "0.11.2"
7
+ version = "0.12.1"
8
8
  description = "Modular Django-based data management framework with ORM, GraphQL, fine-grained permissions, rule validation, calculations and caching."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Tim Kleindick", email = "tkleindick@yahoo.de" }]
@@ -4,6 +4,7 @@ from typing import Any, Callable, get_args, TYPE_CHECKING, cast, Type
4
4
  from decimal import Decimal
5
5
  from datetime import date, datetime
6
6
  import json
7
+ from graphql.language import ast
7
8
 
8
9
  from general_manager.measurement.measurement import Measurement
9
10
  from general_manager.manager.generalManager import GeneralManagerMeta, GeneralManager
@@ -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
- Ermittelt die Filter, die auf Basis der read-Permission für den angegebenen
31
- Manager angewendet werden müssen.
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
- Erzeugt ein GraphQL-Mutation-Interface für die übergebene Manager-Klasse.
65
- Dabei werden:
66
- - Attribute aus dem Interface in Graphene-Felder abgebildet
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
- Generates and registers a GraphQL ObjectType for the specified GeneralManager class.
106
-
107
- This method maps interface attributes and GraphQLProperty fields to Graphene fields, creates appropriate resolvers, registers the resulting type in the internal registry, and adds related 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.
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, or None if no sortable fields exist.
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 options, as well as specialized handling for Measurement attributes. Returns the generated InputObjectType or None if no filter fields are applicable.
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}_value"] = graphene.Float()
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}_value__{option}"] = graphene.Float()
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` types, returns a field with an optional `target_unit` argument. For `GeneralManager` subclasses, returns either a list field (with optional filtering, exclusion, sorting, pagination, and grouping arguments if available) or a single field, depending on the field name. For 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.
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
- Wendet Filter, Excludes, Sortierung und Paginierung auf das Queryset an.
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
- Create a resolver function for GraphQL list fields that retrieves and returns a queryset with permission filters, query filters, sorting, pagination, and optional grouping applied.
374
-
375
- Parameters:
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 a queryset with permission filters, query filters, sorting, pagination, and optional grouping applied.
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: Whether to reverse the sort order.
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
- The resulting queryset after applying permissions, filters, sorting, pagination, and grouping.
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
- qs, filter, exclude, sort_by, reverse, page, page_size
417
- )
418
- if group_by is not None:
419
- if group_by == [""]:
420
- qs = qs.group_by()
421
- else:
422
- qs = qs.group_by(*group_by)
423
- return qs
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
- Erzeugt einen Resolver für Felder vom Typ 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.
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
- Wählt anhand des Feldtyps den passenden Resolver aus.
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
- Add list and single-item query fields for a GeneralManager subclass to the GraphQL schema.
484
-
485
- Registers a list query field with optional filtering, exclusion, sorting, pagination, and grouping arguments, as well as a single-item query field using identification fields from the manager's interface. Attaches the corresponding resolvers for both queries to the schema.
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
- list_field = graphene.List(
513
- graphene_type,
514
- **attributes,
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
- Generate a dictionary of Graphene input fields for mutations based on the attributes of the provided interface class.
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. An optional `history_comment` field, also marked as editable, is always included.
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, calls the manager's `create` method with the provided arguments and the current user's ID, and returns a dictionary containing a success flag, any errors, and the created instance. Returns None if the manager class does not define an interface.
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
- Create a new instance of the manager class using the provided arguments.
629
-
630
- Filters out any fields with values set to `NOT_PROVIDED` before invoking the creation method. Returns a dictionary with a success flag, a list of errors if creation fails, and the created instance keyed by the manager class name.
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 the operation's success status, any error messages, and the updated instance. Returns None if the manager class does not define an `Interface`.
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 instance with the provided fields.
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, typically containing user and request information.
704
- **kwargs: Fields to update, including the required 'id' of the instance.
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 keys 'success' (bool), 'errors' (list of str), 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.
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 the operation's success status, any error messages, and the deactivated instance keyed by the class name.
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 the manager class does not define an interface.
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 the specified GeneralManager class and returns the operation result.
773
-
887
+ Deactivates an instance of a GeneralManager subclass and returns the operation result.
888
+
774
889
  Returns:
775
- dict: A dictionary with keys:
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
+ )