base-api-utils 1.1.117.dev2__tar.gz → 1.1.117.dev4__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 (114) hide show
  1. {base_api_utils-1.1.117.dev2/src/base_api_utils.egg-info → base_api_utils-1.1.117.dev4}/PKG-INFO +1 -1
  2. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/pyproject.toml +1 -1
  3. base_api_utils-1.1.117.dev4/src/base_api_utils/__init__.py +11 -0
  4. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/serializers/v2/abstract_serializer.py +20 -1
  5. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4/src/base_api_utils.egg-info}/PKG-INFO +1 -1
  6. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils.egg-info/SOURCES.txt +2 -1
  7. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/tests/test_lazy_imports.py +34 -0
  8. base_api_utils-1.1.117.dev4/tests/test_v2_abstract_serializer.py +200 -0
  9. base_api_utils-1.1.117.dev2/src/base_api_utils/__init__.py +0 -1
  10. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/LICENSE +0 -0
  11. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/README.md +0 -0
  12. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/setup.cfg +0 -0
  13. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/commands/__init__.py +0 -0
  14. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/commands/failed_task_management_mixin.py +0 -0
  15. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/domain/__init__.py +0 -0
  16. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/domain/event_message.py +0 -0
  17. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/domain/event_message_factory.py +0 -0
  18. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/domain/events.py +0 -0
  19. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/dtos/__init__.py +0 -0
  20. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/dtos/authz_user.py +0 -0
  21. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/fields/__init__.py +0 -0
  22. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/fields/email_list_field.py +0 -0
  23. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/filters/__init__.py +0 -0
  24. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/filters/base_filter.py +0 -0
  25. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/filters/custom_ordering_filter.py +0 -0
  26. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/filters/filter_utils_mixin.py +0 -0
  27. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/handlers/__init__.py +0 -0
  28. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/handlers/base_event_handler.py +0 -0
  29. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/handlers/sponsor_event_handler.py +0 -0
  30. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/handlers/sponsorship_addon_event_handler.py +0 -0
  31. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/handlers/sponsorship_event_handler.py +0 -0
  32. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/handlers/summit_event_handler.py +0 -0
  33. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/ioc/__init__.py +0 -0
  34. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/ioc/container.py +0 -0
  35. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/models/__init__.py +0 -0
  36. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/models/item_image.py +0 -0
  37. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/security/__init__.py +0 -0
  38. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/security/abstract_access_token_service.py +0 -0
  39. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/security/abstract_oauth2_api_client.py +0 -0
  40. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/security/access_token_service.py +0 -0
  41. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/security/check_sponsor_user_permissions.py +0 -0
  42. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/security/group_required.py +0 -0
  43. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/security/oauth2_authentication.py +0 -0
  44. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/security/oauth2_client_factory.py +0 -0
  45. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/security/oauth2_scope_required.py +0 -0
  46. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/security/permission_event_handler.py +0 -0
  47. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/security/shared.py +0 -0
  48. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/security/user_permissions_service.py +0 -0
  49. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/serializers/__init__.py +0 -0
  50. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/serializers/base_model_serializer.py +0 -0
  51. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/serializers/email_list_char_field.py +0 -0
  52. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/serializers/nullable_slug_related_field.py +0 -0
  53. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/serializers/serializers_registry.py +0 -0
  54. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/serializers/task_result_serializer.py +0 -0
  55. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/serializers/timestamp_field.py +0 -0
  56. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/serializers/v2/__init__.py +0 -0
  57. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/serializers/v2/base_model_serializer.py +0 -0
  58. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/serializers/v2/expands.py +0 -0
  59. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/serializers/v2/query_params.py +0 -0
  60. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/serializers/v2/spectacular.py +0 -0
  61. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/services/__init__.py +0 -0
  62. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/services/abstract_domain_events_service.py +0 -0
  63. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/services/abstract_event_dispatcher.py +0 -0
  64. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/services/abstract_images_service.py +0 -0
  65. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/services/abstract_sponsor_metadata_service.py +0 -0
  66. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/services/abstract_storage_service.py +0 -0
  67. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/services/abstract_summit_metadata_service.py +0 -0
  68. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/services/amqp_service.py +0 -0
  69. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/services/celery_failed.py +0 -0
  70. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/services/event_dispatcher.py +0 -0
  71. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/services/images_service.py +0 -0
  72. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/services/s3_service.py +0 -0
  73. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/services/strategies/__init__.py +0 -0
  74. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/services/strategies/abstract_task_runner_strategy.py +0 -0
  75. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/services/strategies/publish_domain_event_strategy.py +0 -0
  76. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/tests/__init__.py +0 -0
  77. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/tests/test_failed_celery_task_management.py +0 -0
  78. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/tests/test_task_status_endpoints.py +0 -0
  79. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/urls.py +0 -0
  80. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/__init__.py +0 -0
  81. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/async_helpers.py +0 -0
  82. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/circuit_breaker.py +0 -0
  83. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/config.py +0 -0
  84. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/custom_auto_schema.py +0 -0
  85. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/db_connection_helpers.py +0 -0
  86. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/debounce_helpers.py +0 -0
  87. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/enums.py +0 -0
  88. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/env_var_eval.py +0 -0
  89. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/exceptions.py +0 -0
  90. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/expand_queryset_optimization_mixin.py +0 -0
  91. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/external_errors.py +0 -0
  92. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/file_lock.py +0 -0
  93. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/fingerprint_helpers.py +0 -0
  94. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/http_helpers.py +0 -0
  95. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/inject_job.py +0 -0
  96. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/look_up_value_mixin.py +0 -0
  97. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/model_helpers.py +0 -0
  98. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/oauth2_custom_schema_base.py +0 -0
  99. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/pagination.py +0 -0
  100. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/rate_utils.py +0 -0
  101. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/read_write_serializer_mixin.py +0 -0
  102. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/serializers.py +0 -0
  103. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/service_utils.py +0 -0
  104. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/string.py +0 -0
  105. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/utils/tests.py +0 -0
  106. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/views/__init__.py +0 -0
  107. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/views/base_view.py +0 -0
  108. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/views/parent_children_crud.py +0 -0
  109. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/views/root_entity_crud.py +0 -0
  110. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils/views/tasks_result_view.py +0 -0
  111. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils.egg-info/dependency_links.txt +0 -0
  112. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils.egg-info/requires.txt +0 -0
  113. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/src/base_api_utils.egg-info/top_level.txt +0 -0
  114. {base_api_utils-1.1.117.dev2 → base_api_utils-1.1.117.dev4}/tests/test_circuit_breaker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: base-api-utils
3
- Version: 1.1.117.dev2
3
+ Version: 1.1.117.dev4
4
4
  Summary: Django Rest Framework micro services common utilities
5
5
  Author-email: Román Gutierrez <roman@tipit.net>, Sebastian Marcet <sebastian@tipit.net>
6
6
  Project-URL: Homepage, https://github.com/fntechgit/base-api-utils
@@ -7,7 +7,7 @@ where = ["src"]
7
7
 
8
8
  [project]
9
9
  name = "base-api-utils"
10
- version = "1.1.117.dev2"
10
+ version = "1.1.117.dev4"
11
11
  dependencies = [
12
12
  "boto3",
13
13
  "celery",
@@ -0,0 +1,11 @@
1
+ def __getattr__(name: str):
2
+ # Lazy import: loading private_tasks_admin_patterns eagerly chains through
3
+ # views → tasks_result_view → @extend_schema → DEFAULT_SCHEMA_CLASS,
4
+ # which causes a circular import when any consumer of this package is
5
+ # itself imported as DEFAULT_SCHEMA_CLASS (e.g. a custom AutoSchema
6
+ # subclass that inherits from base_api_utils.utils.CustomAutoSchema).
7
+ if name == "private_tasks_admin_patterns":
8
+ from .urls import private_tasks_admin_patterns
9
+
10
+ return private_tasks_admin_patterns
11
+ raise AttributeError(f"module 'base_api_utils' has no attribute {name!r}")
@@ -3,6 +3,7 @@ import copy
3
3
  from .expands import ExpandMapping
4
4
  from .query_params import normalize_none, parse_tree
5
5
 
6
+
6
7
  class AbstractSerializer:
7
8
  allowed_fields = None
8
9
  allowed_relations = []
@@ -126,6 +127,23 @@ class AbstractSerializer:
126
127
  ft = ctx.get("fields_tree")
127
128
  if ft is None or (isinstance(ft, dict) and len(ft) == 0):
128
129
  return fields
130
+ # Navigation-only fields_tree (every top-level key has a non-empty
131
+ # subtree pointing deeper) means the user only constrained nested
132
+ # levels and never listed a scalar at this one. Preserve defaults
133
+ # here and let the deeper constraints apply at child serializers.
134
+ has_explicit_leaf = any(not subtree for subtree in ft.values())
135
+ if not has_explicit_leaf:
136
+ af = self._merged_allowed_fields()
137
+ if af is not None:
138
+ allowed_keep = (
139
+ set(af)
140
+ | set(ctx.get("expand_tree", {}).keys())
141
+ | set(ctx.get("relations_tree", {}).keys())
142
+ )
143
+ for k in list(fields.keys()):
144
+ if k not in allowed_keep:
145
+ fields.pop(k, None)
146
+ return fields
129
147
  keep = (
130
148
  set(ft.keys())
131
149
  | set(ctx.get("expand_tree", {}).keys())
@@ -169,4 +187,5 @@ class AbstractSerializer:
169
187
  return ChildSerializer(context=self.get_child_context('field_name')).to_representation(obj)
170
188
  """
171
189
  from .expands import _child_context
172
- return _child_context(self._own_context, attr)
190
+
191
+ return _child_context(self._own_context, attr)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: base-api-utils
3
- Version: 1.1.117.dev2
3
+ Version: 1.1.117.dev4
4
4
  Summary: Django Rest Framework micro services common utilities
5
5
  Author-email: Román Gutierrez <roman@tipit.net>, Sebastian Marcet <sebastian@tipit.net>
6
6
  Project-URL: Homepage, https://github.com/fntechgit/base-api-utils
@@ -107,4 +107,5 @@ src/base_api_utils/views/parent_children_crud.py
107
107
  src/base_api_utils/views/root_entity_crud.py
108
108
  src/base_api_utils/views/tasks_result_view.py
109
109
  tests/test_circuit_breaker.py
110
- tests/test_lazy_imports.py
110
+ tests/test_lazy_imports.py
111
+ tests/test_v2_abstract_serializer.py
@@ -56,3 +56,37 @@ class TestExpandQuerysetOptimizationMixinImports:
56
56
 
57
57
  # query_params should NOT be loaded at module level
58
58
  assert "base_api_utils.serializers.v2.query_params" not in sys.modules
59
+
60
+
61
+ @pytest.mark.unit
62
+ class TestBaseApiUtilsInitLazyImport:
63
+ """Regression: importing base_api_utils must NOT eagerly load
64
+ urls → views → tasks_result_view → @extend_schema → DEFAULT_SCHEMA_CLASS.
65
+ That chain causes a circular import when base_api_utils is imported by
66
+ a custom AutoSchema subclass that is itself registered as DEFAULT_SCHEMA_CLASS.
67
+
68
+ private_tasks_admin_patterns must be accessible but only materialised on
69
+ first attribute access, not at package-import time.
70
+ """
71
+
72
+ def test_import_base_api_utils_does_not_load_views(self):
73
+ """Importing the package must not pull in views/tasks_result_view."""
74
+ for key in list(sys.modules):
75
+ if key.startswith("base_api_utils"):
76
+ del sys.modules[key]
77
+
78
+ _base_dir = Path(__file__).resolve().parent.parent / "src" / "base_api_utils"
79
+ _load_module("base_api_utils", _base_dir / "__init__.py")
80
+
81
+ assert "base_api_utils.views" not in sys.modules
82
+ assert "base_api_utils.views.tasks_result_view" not in sys.modules
83
+ assert "base_api_utils.urls" not in sys.modules
84
+
85
+ def test_private_tasks_admin_patterns_accessible_via_getattr(self):
86
+ """private_tasks_admin_patterns is reachable even with the lazy import."""
87
+ import base_api_utils # noqa: F401 — confirm attribute exists when accessed
88
+ # We can't resolve the attribute without Django apps ready (Django model
89
+ # registration); confirming the __getattr__ path exists is sufficient here.
90
+ assert hasattr(type(sys.modules["base_api_utils"]), "__getattr__") or callable(
91
+ getattr(sys.modules["base_api_utils"], "__getattr__", None)
92
+ )
@@ -0,0 +1,200 @@
1
+ """Unit tests for base_api_utils.serializers.v2.abstract_serializer._filter_local_fields.
2
+
3
+ Covers the contract that ``fields_tree`` containing only navigation entries
4
+ (keys with non-empty subtrees that drill deeper) MUST NOT strip scalar fields
5
+ at the current serializer level. Only when at least one explicit leaf entry
6
+ is present at this level does the strict-subset filter apply.
7
+ """
8
+
9
+ import importlib.util
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+
16
+ def _load_module(name, file_path):
17
+ spec = importlib.util.spec_from_file_location(name, file_path)
18
+ if spec is None or spec.loader is None:
19
+ raise ImportError(f"Could not load {name} from {file_path}")
20
+ module = importlib.util.module_from_spec(spec)
21
+ sys.modules[name] = module
22
+ spec.loader.exec_module(module)
23
+ return module
24
+
25
+
26
+ _v2_dir = (
27
+ Path(__file__).resolve().parent.parent
28
+ / "src"
29
+ / "base_api_utils"
30
+ / "serializers"
31
+ / "v2"
32
+ )
33
+
34
+ # Load query_params first (abstract_serializer imports from it).
35
+ _load_module(
36
+ "base_api_utils.serializers.v2.query_params",
37
+ _v2_dir / "query_params.py",
38
+ )
39
+ _load_module(
40
+ "base_api_utils.serializers.v2.expands",
41
+ _v2_dir / "expands.py",
42
+ )
43
+ _abstract = _load_module(
44
+ "base_api_utils.serializers.v2.abstract_serializer",
45
+ _v2_dir / "abstract_serializer.py",
46
+ )
47
+
48
+ AbstractSerializer = _abstract.AbstractSerializer
49
+
50
+
51
+ def _make_serializer(fields_tree, expand_tree=None, relations_tree=None):
52
+ """Build a bare AbstractSerializer with a controlled context.
53
+
54
+ Bypasses __init__ (which would require DRF/Django wiring) and seeds the
55
+ context dict directly. _own_context reads from _original_context.
56
+ """
57
+ s = object.__new__(AbstractSerializer)
58
+ s._original_context = {
59
+ "fields_tree": fields_tree,
60
+ "expand_tree": expand_tree or {},
61
+ "relations_tree": relations_tree or {},
62
+ }
63
+ return s
64
+
65
+
66
+ @pytest.mark.unit
67
+ class TestFilterLocalFieldsNavigationOnly:
68
+ """Regression: a navigation-only fields_tree preserves scalars at the
69
+ current level (the user only constrained deeper levels)."""
70
+
71
+ def test_navigation_only_preserves_all_scalar_fields(self):
72
+ """fields_tree={'foo': {'bar': {'baz': {}}}} keeps siblings of foo."""
73
+ s = _make_serializer(
74
+ fields_tree={"foo": {"bar": {"baz": {}}}},
75
+ )
76
+ fields = {"id": object(), "name": object(), "foo": object()}
77
+ result = s._filter_local_fields(fields)
78
+ assert set(result.keys()) == {"id", "name", "foo"}
79
+
80
+ def test_navigation_only_with_relations_tree_preserves_scalars(self):
81
+ """relations_tree at this level should not force scalars to disappear
82
+ when the fields_tree is navigation-only."""
83
+ s = _make_serializer(
84
+ fields_tree={"foo": {"bar": {}}},
85
+ relations_tree={"foo": {"bar": {}}},
86
+ )
87
+ fields = {"id": object(), "name": object(), "foo": object()}
88
+ result = s._filter_local_fields(fields)
89
+ assert set(result.keys()) == {"id", "name", "foo"}
90
+
91
+
92
+ @pytest.mark.unit
93
+ class TestFilterLocalFieldsLeafSelection:
94
+ """Existing strict-subset behaviour must be preserved when the user
95
+ actually lists scalar fields at this level."""
96
+
97
+ def test_leaf_only_keeps_only_listed_scalars(self):
98
+ """fields_tree={'name': {}} strips every other scalar."""
99
+ s = _make_serializer(fields_tree={"name": {}})
100
+ fields = {"id": object(), "name": object(), "description": object()}
101
+ result = s._filter_local_fields(fields)
102
+ assert set(result.keys()) == {"name"}
103
+
104
+ def test_mixed_leaf_and_navigation_keeps_listed_scalars_and_navigation_key(self):
105
+ """fields_tree={'name': {}, 'foo': {'bar': {}}} keeps 'name' (leaf) and
106
+ 'foo' (navigation key kept because it appears in fields_tree). Other
107
+ scalars are stripped — at least one leaf at this level switches the
108
+ filter into strict-subset mode."""
109
+ s = _make_serializer(fields_tree={"name": {}, "foo": {"bar": {}}})
110
+ fields = {
111
+ "id": object(),
112
+ "name": object(),
113
+ "description": object(),
114
+ "foo": object(),
115
+ }
116
+ result = s._filter_local_fields(fields)
117
+ assert set(result.keys()) == {"name", "foo"}
118
+
119
+ def test_none_sentinel_strips_all_scalar_fields(self):
120
+ """fields_tree={'none': {}} is a leaf entry (empty subtree) whose key
121
+ matches no real field, so every scalar is stripped."""
122
+ s = _make_serializer(fields_tree={"none": {}})
123
+ fields = {"id": object(), "name": object()}
124
+ result = s._filter_local_fields(fields)
125
+ assert set(result.keys()) == set()
126
+
127
+
128
+ @pytest.mark.unit
129
+ class TestFilterLocalFieldsEmptyOrMissing:
130
+ """Empty / missing fields_tree means 'use defaults' — no stripping."""
131
+
132
+ def test_none_fields_tree_returns_fields_unchanged(self):
133
+ s = _make_serializer(fields_tree=None)
134
+ fields = {"id": object(), "name": object()}
135
+ result = s._filter_local_fields(fields)
136
+ assert set(result.keys()) == {"id", "name"}
137
+
138
+ def test_empty_fields_tree_returns_fields_unchanged(self):
139
+ s = _make_serializer(fields_tree={})
140
+ fields = {"id": object(), "name": object()}
141
+ result = s._filter_local_fields(fields)
142
+ assert set(result.keys()) == {"id", "name"}
143
+
144
+
145
+ @pytest.mark.unit
146
+ class TestFilterLocalFieldsAllowedFieldsWithNavigation:
147
+ """Regression (Copilot r3229018506): navigation-only fields_tree must still
148
+ respect allowed_fields defined on a subclass, not expose every DRF field."""
149
+
150
+ def _make(self, fields_tree, allowed_fields_list=None, expand_tree=None, relations_tree=None):
151
+ class _Serializer(AbstractSerializer):
152
+ pass
153
+ if allowed_fields_list is not None:
154
+ _Serializer.allowed_fields = allowed_fields_list
155
+ s = object.__new__(_Serializer)
156
+ s._original_context = {
157
+ "fields_tree": fields_tree,
158
+ "expand_tree": expand_tree or {},
159
+ "relations_tree": relations_tree or {},
160
+ }
161
+ return s
162
+
163
+ def test_navigation_only_respects_allowed_fields(self):
164
+ """?fields=sample_configuration_types.id on a serializer with restricted
165
+ allowed_fields must not expose fields outside the allowed set."""
166
+ s = self._make(
167
+ fields_tree={"sample_configuration_types": {"id": {}}},
168
+ allowed_fields_list=["id", "name", "sample_configuration_types"],
169
+ )
170
+ fields = {
171
+ "id": object(),
172
+ "name": object(),
173
+ "sample_configuration_types": object(),
174
+ "internal_notes": object(),
175
+ "draft_flag": object(),
176
+ }
177
+ result = s._filter_local_fields(fields)
178
+ assert set(result.keys()) == {"id", "name", "sample_configuration_types"}
179
+
180
+ def test_navigation_key_not_in_allowed_fields_is_stripped(self):
181
+ """A navigation key absent from allowed_fields is dropped when
182
+ allowed_fields is configured and no scalar leaf exists at this level."""
183
+ s = self._make(
184
+ fields_tree={"foo": {"bar": {}}},
185
+ allowed_fields_list=["id", "name"],
186
+ )
187
+ fields = {"id": object(), "name": object(), "foo": object(), "secret": object()}
188
+ result = s._filter_local_fields(fields)
189
+ assert set(result.keys()) == {"id", "name"}
190
+
191
+ def test_navigation_only_without_allowed_fields_returns_all(self):
192
+ """When allowed_fields is None, navigation-only fields_tree preserves
193
+ all fields — existing behaviour for unrestricted serializers unchanged."""
194
+ s = self._make(
195
+ fields_tree={"foo": {"bar": {}}},
196
+ allowed_fields_list=None,
197
+ )
198
+ fields = {"id": object(), "name": object(), "foo": object()}
199
+ result = s._filter_local_fields(fields)
200
+ assert set(result.keys()) == {"id", "name", "foo"}
@@ -1 +0,0 @@
1
- from .urls import private_tasks_admin_patterns # noqa: F401