base-api-utils 1.1.115__tar.gz → 1.1.117__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 (116) hide show
  1. {base_api_utils-1.1.115/src/base_api_utils.egg-info → base_api_utils-1.1.117}/PKG-INFO +1 -1
  2. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/pyproject.toml +1 -1
  3. base_api_utils-1.1.117/src/base_api_utils/__init__.py +14 -0
  4. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/filters/filter_utils_mixin.py +12 -0
  5. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/v2/abstract_serializer.py +21 -1
  6. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/amqp_service.py +34 -8
  7. base_api_utils-1.1.117/src/base_api_utils/tests/test_task_status_endpoints.py +104 -0
  8. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/views/tasks_result_view.py +5 -5
  9. {base_api_utils-1.1.115 → base_api_utils-1.1.117/src/base_api_utils.egg-info}/PKG-INFO +1 -1
  10. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils.egg-info/SOURCES.txt +4 -1
  11. base_api_utils-1.1.117/tests/test_amqp_service.py +218 -0
  12. base_api_utils-1.1.117/tests/test_lazy_imports.py +136 -0
  13. base_api_utils-1.1.117/tests/test_v2_abstract_serializer.py +290 -0
  14. base_api_utils-1.1.115/src/base_api_utils/__init__.py +0 -1
  15. base_api_utils-1.1.115/tests/test_lazy_imports.py +0 -58
  16. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/LICENSE +0 -0
  17. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/README.md +0 -0
  18. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/setup.cfg +0 -0
  19. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/commands/__init__.py +0 -0
  20. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/commands/failed_task_management_mixin.py +0 -0
  21. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/domain/__init__.py +0 -0
  22. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/domain/event_message.py +0 -0
  23. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/domain/event_message_factory.py +0 -0
  24. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/domain/events.py +0 -0
  25. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/dtos/__init__.py +0 -0
  26. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/dtos/authz_user.py +0 -0
  27. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/fields/__init__.py +0 -0
  28. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/fields/email_list_field.py +0 -0
  29. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/filters/__init__.py +0 -0
  30. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/filters/base_filter.py +0 -0
  31. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/filters/custom_ordering_filter.py +0 -0
  32. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/handlers/__init__.py +0 -0
  33. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/handlers/base_event_handler.py +0 -0
  34. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/handlers/sponsor_event_handler.py +0 -0
  35. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/handlers/sponsorship_addon_event_handler.py +0 -0
  36. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/handlers/sponsorship_event_handler.py +0 -0
  37. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/handlers/summit_event_handler.py +0 -0
  38. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/ioc/__init__.py +0 -0
  39. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/ioc/container.py +0 -0
  40. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/models/__init__.py +0 -0
  41. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/models/item_image.py +0 -0
  42. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/__init__.py +0 -0
  43. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/abstract_access_token_service.py +0 -0
  44. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/abstract_oauth2_api_client.py +0 -0
  45. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/access_token_service.py +0 -0
  46. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/check_sponsor_user_permissions.py +0 -0
  47. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/group_required.py +0 -0
  48. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/oauth2_authentication.py +0 -0
  49. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/oauth2_client_factory.py +0 -0
  50. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/oauth2_scope_required.py +0 -0
  51. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/permission_event_handler.py +0 -0
  52. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/shared.py +0 -0
  53. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/user_permissions_service.py +0 -0
  54. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/__init__.py +0 -0
  55. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/base_model_serializer.py +0 -0
  56. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/email_list_char_field.py +0 -0
  57. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/nullable_slug_related_field.py +0 -0
  58. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/serializers_registry.py +0 -0
  59. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/task_result_serializer.py +0 -0
  60. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/timestamp_field.py +0 -0
  61. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/v2/__init__.py +0 -0
  62. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/v2/base_model_serializer.py +0 -0
  63. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/v2/expands.py +0 -0
  64. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/v2/query_params.py +0 -0
  65. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/v2/spectacular.py +0 -0
  66. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/__init__.py +0 -0
  67. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/abstract_domain_events_service.py +0 -0
  68. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/abstract_event_dispatcher.py +0 -0
  69. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/abstract_images_service.py +0 -0
  70. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/abstract_sponsor_metadata_service.py +0 -0
  71. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/abstract_storage_service.py +0 -0
  72. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/abstract_summit_metadata_service.py +0 -0
  73. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/celery_failed.py +0 -0
  74. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/event_dispatcher.py +0 -0
  75. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/images_service.py +0 -0
  76. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/s3_service.py +0 -0
  77. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/strategies/__init__.py +0 -0
  78. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/strategies/abstract_task_runner_strategy.py +0 -0
  79. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/strategies/publish_domain_event_strategy.py +0 -0
  80. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/tests/__init__.py +0 -0
  81. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/tests/test_failed_celery_task_management.py +0 -0
  82. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/urls.py +0 -0
  83. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/__init__.py +0 -0
  84. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/async_helpers.py +0 -0
  85. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/circuit_breaker.py +0 -0
  86. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/config.py +0 -0
  87. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/custom_auto_schema.py +0 -0
  88. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/db_connection_helpers.py +0 -0
  89. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/debounce_helpers.py +0 -0
  90. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/enums.py +0 -0
  91. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/env_var_eval.py +0 -0
  92. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/exceptions.py +0 -0
  93. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/expand_queryset_optimization_mixin.py +0 -0
  94. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/external_errors.py +0 -0
  95. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/file_lock.py +0 -0
  96. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/fingerprint_helpers.py +0 -0
  97. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/http_helpers.py +0 -0
  98. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/inject_job.py +0 -0
  99. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/look_up_value_mixin.py +0 -0
  100. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/model_helpers.py +0 -0
  101. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/oauth2_custom_schema_base.py +0 -0
  102. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/pagination.py +0 -0
  103. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/rate_utils.py +0 -0
  104. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/read_write_serializer_mixin.py +0 -0
  105. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/serializers.py +0 -0
  106. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/service_utils.py +0 -0
  107. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/string.py +0 -0
  108. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/tests.py +0 -0
  109. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/views/__init__.py +0 -0
  110. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/views/base_view.py +0 -0
  111. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/views/parent_children_crud.py +0 -0
  112. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/views/root_entity_crud.py +0 -0
  113. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils.egg-info/dependency_links.txt +0 -0
  114. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils.egg-info/requires.txt +0 -0
  115. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils.egg-info/top_level.txt +0 -0
  116. {base_api_utils-1.1.115 → base_api_utils-1.1.117}/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.115
3
+ Version: 1.1.117
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.115"
10
+ version = "1.1.117"
11
11
  dependencies = [
12
12
  "boto3",
13
13
  "celery",
@@ -0,0 +1,14 @@
1
+ __all__ = ["private_tasks_admin_patterns"]
2
+
3
+
4
+ def __getattr__(name: str):
5
+ # Lazy import: loading private_tasks_admin_patterns eagerly chains through
6
+ # views → tasks_result_view → @extend_schema → DEFAULT_SCHEMA_CLASS,
7
+ # which causes a circular import when any consumer of this package is
8
+ # itself imported as DEFAULT_SCHEMA_CLASS (e.g. a custom AutoSchema
9
+ # subclass that inherits from base_api_utils.utils.CustomAutoSchema).
10
+ if name == "private_tasks_admin_patterns":
11
+ from .urls import private_tasks_admin_patterns
12
+
13
+ return private_tasks_admin_patterns
14
+ raise AttributeError(f"module 'base_api_utils' has no attribute {name!r}")
@@ -56,3 +56,15 @@ class FilterUtilsMixin:
56
56
  def filter_isnull(self, queryset, field_name, value):
57
57
  is_null = str(value).lower() == 'true'
58
58
  return Q(**{f"{field_name}__isnull": is_null})
59
+
60
+ def get_and_filter(self, filter_name):
61
+ """Returns (operator, value) for the named AND filter, or None if absent."""
62
+ for f in self.data.getlist(self.filters_param, []):
63
+ fname, fop, fval = self.parse_filter(f)
64
+ if fname == filter_name:
65
+ return fop, fval
66
+ return None
67
+
68
+ def has_and_filter(self, filter_name):
69
+ """Returns True if the named filter is present in the AND filter list."""
70
+ return self.get_and_filter(filter_name) is not None
@@ -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 = []
@@ -122,10 +123,28 @@ class AbstractSerializer:
122
123
  ctx["relations_tree"] = {k: {} for k in dr}
123
124
 
124
125
  def _filter_local_fields(self, fields):
126
+ fields = dict(fields)
125
127
  ctx = self._own_context
126
128
  ft = ctx.get("fields_tree")
127
129
  if ft is None or (isinstance(ft, dict) and len(ft) == 0):
128
130
  return fields
131
+ # Navigation-only fields_tree (every top-level key has a non-empty
132
+ # subtree pointing deeper) means the user only constrained nested
133
+ # levels and never listed a scalar at this one. Preserve defaults
134
+ # here and let the deeper constraints apply at child serializers.
135
+ has_explicit_leaf = any(not subtree for subtree in ft.values())
136
+ if not has_explicit_leaf:
137
+ af = self._merged_allowed_fields()
138
+ if af is not None:
139
+ allowed_keep = (
140
+ set(af)
141
+ | set(ctx.get("expand_tree", {}).keys())
142
+ | set(ctx.get("relations_tree", {}).keys())
143
+ )
144
+ for k in list(fields.keys()):
145
+ if k not in allowed_keep:
146
+ fields.pop(k, None)
147
+ return fields
129
148
  keep = (
130
149
  set(ft.keys())
131
150
  | set(ctx.get("expand_tree", {}).keys())
@@ -169,4 +188,5 @@ class AbstractSerializer:
169
188
  return ChildSerializer(context=self.get_child_context('field_name')).to_representation(obj)
170
189
  """
171
190
  from .expands import _child_context
172
- return _child_context(self._own_context, attr)
191
+
192
+ return _child_context(self._own_context, attr)
@@ -194,18 +194,44 @@ class AMQPService(AbstractDomainEventsService):
194
194
  logging.getLogger('api').error(f'Error closing connection: {close_error}')
195
195
 
196
196
  def subscribe(self):
197
- try:
198
- """Start listening messages from the queue."""
199
- if not self.channel:
197
+ """Start listening for messages. Reconnects indefinitely on connection loss."""
198
+ if not self.queue_name:
199
+ raise ValueError('AMQPService::subscribe: queue_name is required')
200
+ attempt = 0
201
+ while True:
202
+ clean_return = False
203
+ try:
204
+ self.connection = None
205
+ self.channel = None
200
206
  self._connect()
201
207
 
202
- if self.queue_name:
203
208
  self.channel.basic_consume(
204
209
  queue=self.queue_name,
205
210
  on_message_callback=self._callback
206
211
  )
207
212
 
208
- logging.getLogger('api').info("Waiting for messages...")
209
- self.channel.start_consuming()
210
- except Exception as e:
211
- logging.getLogger('api').error(f'AMQPService::subscribe: Failed to subscribe to {self.queue_name}: {e}')
213
+ logging.getLogger('api').info("Waiting for messages...")
214
+ self.channel.start_consuming()
215
+
216
+ # start_consuming() returned — broker closed the connection (e.g. maintenance mode)
217
+ logging.getLogger('api').warning(
218
+ f'AMQPService::subscribe: Connection to {self.queue_name} closed by broker, reconnecting...')
219
+ attempt = 0 # reset backoff after a successful session
220
+ clean_return = True
221
+ except Exception as e:
222
+ logging.getLogger('api').error(
223
+ f'AMQPService::subscribe: Failed to subscribe to {self.queue_name}: {e}')
224
+ finally:
225
+ try:
226
+ if self.connection and not self.connection.is_closed:
227
+ self.connection.close()
228
+ except Exception as close_err:
229
+ logging.getLogger('api').warning(
230
+ f'AMQPService::subscribe: could not close connection: {close_err}')
231
+
232
+ delay = self._calculate_delay(attempt)
233
+ logging.getLogger('api').info(
234
+ f'AMQPService::subscribe: Reconnecting to {self.queue_name} in {delay:.2f}s...')
235
+ time.sleep(delay)
236
+ if not clean_return:
237
+ attempt += 1
@@ -0,0 +1,104 @@
1
+ from unittest.mock import patch, MagicMock
2
+
3
+ from django.core.management import call_command
4
+ from rest_framework.test import APITestCase
5
+ from django.urls import reverse
6
+ import json
7
+ from rest_framework import status
8
+
9
+
10
+ class TaskStatusEndpointsTest(APITestCase):
11
+ def setUp(self):
12
+ self.access_token = 'ACCESS_TOKEN'
13
+ call_command('seed_data')
14
+
15
+ def test_task_status_pending(self):
16
+ task_id = 'test-task-pending'
17
+ url = reverse('private-tasks-admin-endpoints:retrieve_async_task_status', kwargs={'task_id': task_id})
18
+
19
+ mock_result = MagicMock()
20
+ mock_result.status = 'PENDING'
21
+
22
+ with patch('base_api_utils.views.tasks_result_view.AsyncResult', return_value=mock_result):
23
+ response = self.client.get(f'{url}?access_token={self.access_token}')
24
+
25
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
26
+ json_response = json.loads(response.content)
27
+ self.assertEqual(json_response['task_id'], task_id)
28
+ self.assertEqual(json_response['status'], 'PENDING')
29
+ self.assertNotIn('result', json_response)
30
+ self.assertNotIn('error', json_response)
31
+
32
+ def test_task_status_started(self):
33
+ task_id = 'test-task-started'
34
+ url = reverse('private-tasks-admin-endpoints:retrieve_async_task_status', kwargs={'task_id': task_id})
35
+
36
+ mock_result = MagicMock()
37
+ mock_result.status = 'STARTED'
38
+
39
+ with patch('base_api_utils.views.tasks_result_view.AsyncResult', return_value=mock_result):
40
+ response = self.client.get(f'{url}?access_token={self.access_token}')
41
+
42
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
43
+ json_response = json.loads(response.content)
44
+ self.assertEqual(json_response['status'], 'STARTED')
45
+ self.assertNotIn('result', json_response)
46
+ self.assertNotIn('error', json_response)
47
+
48
+ def test_task_status_success(self):
49
+ task_id = 'test-task-success'
50
+ url = reverse('private-tasks-admin-endpoints:retrieve_async_task_status', kwargs={'task_id': task_id})
51
+
52
+ mock_result = MagicMock()
53
+ mock_result.status = 'SUCCESS'
54
+ mock_result.result = {'imported': [1667], 'errors': []}
55
+
56
+ with patch('base_api_utils.views.tasks_result_view.AsyncResult', return_value=mock_result):
57
+ response = self.client.get(f'{url}?access_token={self.access_token}')
58
+
59
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
60
+ json_response = json.loads(response.content)
61
+ self.assertEqual(json_response['task_id'], task_id)
62
+ self.assertEqual(json_response['status'], 'SUCCESS')
63
+ self.assertIn('result', json_response)
64
+ self.assertEqual(json_response['result'], {'imported': [1667], 'errors': []})
65
+ self.assertNotIn('error', json_response)
66
+
67
+ def test_task_status_success_with_partial_errors(self):
68
+ task_id = 'test-task-partial'
69
+ url = reverse('private-tasks-admin-endpoints:retrieve_async_task_status', kwargs={'task_id': task_id})
70
+
71
+ mock_result = MagicMock()
72
+ mock_result.status = 'SUCCESS'
73
+ mock_result.result = {
74
+ 'imported': [1667],
75
+ 'errors': [{'user_id': 9999, 'error': 'There is no sponsor user registered with the id 9999'}]
76
+ }
77
+
78
+ with patch('base_api_utils.views.tasks_result_view.AsyncResult', return_value=mock_result):
79
+ response = self.client.get(f'{url}?access_token={self.access_token}')
80
+
81
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
82
+ json_response = json.loads(response.content)
83
+ self.assertEqual(json_response['status'], 'SUCCESS')
84
+ self.assertEqual(len(json_response['result']['imported']), 1)
85
+ self.assertEqual(len(json_response['result']['errors']), 1)
86
+ self.assertEqual(json_response['result']['errors'][0]['user_id'], 9999)
87
+
88
+ def test_task_status_failure(self):
89
+ task_id = 'test-task-failure'
90
+ url = reverse('private-tasks-admin-endpoints:retrieve_async_task_status', kwargs={'task_id': task_id})
91
+
92
+ mock_result = MagicMock()
93
+ mock_result.status = 'FAILURE'
94
+ mock_result.result = Exception('Sponsor 999 not found')
95
+
96
+ with patch('base_api_utils.views.tasks_result_view.AsyncResult', return_value=mock_result):
97
+ response = self.client.get(f'{url}?access_token={self.access_token}')
98
+
99
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
100
+ json_response = json.loads(response.content)
101
+ self.assertEqual(json_response['task_id'], task_id)
102
+ self.assertEqual(json_response['status'], 'FAILURE')
103
+ self.assertIn('error', json_response)
104
+ self.assertNotIn('result', json_response)
@@ -1,24 +1,24 @@
1
1
  from celery.result import AsyncResult
2
+ from drf_spectacular.utils import extend_schema
2
3
  from rest_framework import viewsets, status
3
4
  from rest_framework.response import Response
4
5
  from django_celery_results.models import TaskResult
5
6
  from celery import current_app
6
7
 
7
- from ..security import oauth2_scope_required, group_required
8
+ from ..security import oauth2_scope_required, group_required, OAuth2Authentication
8
9
  from ..serializers import TaskResultSerializer
9
10
 
10
11
 
12
+ @extend_schema(tags=["Celery Tasks"])
11
13
  class TasksResultAPIView(viewsets.ReadOnlyModelViewSet):
12
- """
13
- ViewSet to view failed Celery tasks and retry them manually.
14
- """
14
+ authentication_classes = [OAuth2Authentication]
15
+
15
16
  queryset = TaskResult.objects.filter(status='FAILURE')
16
17
  serializer_class = TaskResultSerializer
17
18
 
18
19
  @oauth2_scope_required()
19
20
  @group_required()
20
21
  def retry(self, request, task_id, *args, **kwargs):
21
- """Rerun a failed task manually"""
22
22
  task_result = self.get_queryset().filter(task_id=task_id).first()
23
23
  if not task_result:
24
24
  return Response({"error": "Task not registered as failed"}, status=status.HTTP_404_NOT_FOUND)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: base-api-utils
3
- Version: 1.1.115
3
+ Version: 1.1.117
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
@@ -74,6 +74,7 @@ src/base_api_utils/services/strategies/abstract_task_runner_strategy.py
74
74
  src/base_api_utils/services/strategies/publish_domain_event_strategy.py
75
75
  src/base_api_utils/tests/__init__.py
76
76
  src/base_api_utils/tests/test_failed_celery_task_management.py
77
+ src/base_api_utils/tests/test_task_status_endpoints.py
77
78
  src/base_api_utils/utils/__init__.py
78
79
  src/base_api_utils/utils/async_helpers.py
79
80
  src/base_api_utils/utils/circuit_breaker.py
@@ -105,5 +106,7 @@ src/base_api_utils/views/base_view.py
105
106
  src/base_api_utils/views/parent_children_crud.py
106
107
  src/base_api_utils/views/root_entity_crud.py
107
108
  src/base_api_utils/views/tasks_result_view.py
109
+ tests/test_amqp_service.py
108
110
  tests/test_circuit_breaker.py
109
- tests/test_lazy_imports.py
111
+ tests/test_lazy_imports.py
112
+ tests/test_v2_abstract_serializer.py
@@ -0,0 +1,218 @@
1
+ import importlib.util
2
+ import sys
3
+ from pathlib import Path
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+
8
+
9
+ # ── Pre-stub amqp_service.py's dependencies ────────────────────────────────────
10
+ # Must happen at module level before _load_module executes the file.
11
+ # Base classes must be real Python classes so AMQPService inherits correctly.
12
+
13
+ class _AbstractDomainEventsService:
14
+ pass
15
+
16
+
17
+ class _AbstractEventDispatcher:
18
+ pass
19
+
20
+
21
+ class _AMQPConnectionError(Exception):
22
+ pass
23
+
24
+
25
+ class _AMQPChannelError(Exception):
26
+ pass
27
+
28
+
29
+ def _make_stub_module(**attrs):
30
+ m = MagicMock()
31
+ for k, v in attrs.items():
32
+ setattr(m, k, v)
33
+ return m
34
+
35
+
36
+ _mock_pika_exc = _make_stub_module(
37
+ AMQPConnectionError=_AMQPConnectionError,
38
+ AMQPChannelError=_AMQPChannelError,
39
+ )
40
+ _mock_pika = _make_stub_module(exceptions=_mock_pika_exc)
41
+
42
+ for _name, _mod in [
43
+ ("pika", _mock_pika),
44
+ ("pika.exceptions", _mock_pika_exc),
45
+ ("base_api_utils", MagicMock()),
46
+ ("base_api_utils.utils", MagicMock()),
47
+ ("base_api_utils.domain", MagicMock()),
48
+ ("base_api_utils.services", MagicMock()),
49
+ (
50
+ "base_api_utils.services.abstract_event_dispatcher",
51
+ _make_stub_module(AbstractEventDispatcher=_AbstractEventDispatcher),
52
+ ),
53
+ (
54
+ "base_api_utils.services.abstract_domain_events_service",
55
+ _make_stub_module(AbstractDomainEventsService=_AbstractDomainEventsService),
56
+ ),
57
+ ]:
58
+ sys.modules.setdefault(_name, _mod)
59
+
60
+
61
+ def _load_module(name, file_path):
62
+ """Load a module directly from file path, bypassing package __init__.py."""
63
+ spec = importlib.util.spec_from_file_location(name, file_path)
64
+ module = importlib.util.module_from_spec(spec)
65
+ sys.modules[name] = module
66
+ spec.loader.exec_module(module)
67
+ return module
68
+
69
+
70
+ _services_dir = (
71
+ Path(__file__).resolve().parent.parent / "src" / "base_api_utils" / "services"
72
+ )
73
+ _amqp_module = _load_module(
74
+ "base_api_utils.services.amqp_service",
75
+ _services_dir / "amqp_service.py",
76
+ )
77
+ AMQPService = _amqp_module.AMQPService
78
+
79
+
80
+ # ── Helpers ───────────────────────────────────────────────────────────────────
81
+
82
+ def _make_service(**kwargs):
83
+ defaults = dict(
84
+ exchange_name="test-exchange",
85
+ queue_name="test-queue",
86
+ rabbit_host="localhost",
87
+ rabbit_port=5672,
88
+ rabbit_username="guest",
89
+ rabbit_password="guest",
90
+ virtual_host="/",
91
+ exchange_type="direct",
92
+ routing_keys=["test.event"],
93
+ base_delay=1.0,
94
+ max_delay=60.0,
95
+ jitter=False,
96
+ )
97
+ defaults.update(kwargs)
98
+ return AMQPService(**defaults)
99
+
100
+
101
+ # ── Tests ─────────────────────────────────────────────────────────────────────
102
+
103
+ @pytest.mark.unit
104
+ class TestAMQPServiceSubscribeReconnection:
105
+
106
+ def test_reconnects_when_broker_closes_connection(self):
107
+ """start_consuming() returning (e.g. maintenance-mode close) triggers reconnect."""
108
+ service = _make_service()
109
+ connect_calls = 0
110
+
111
+ def fake_connect():
112
+ nonlocal connect_calls
113
+ connect_calls += 1
114
+ mock_conn = MagicMock(is_closed=True)
115
+ mock_ch = MagicMock()
116
+ # Second call raises to break the infinite loop
117
+ if connect_calls >= 2:
118
+ mock_ch.start_consuming.side_effect = KeyboardInterrupt
119
+ service.connection = mock_conn
120
+ service.channel = mock_ch
121
+
122
+ with patch.object(service, "_connect", side_effect=fake_connect), \
123
+ patch("base_api_utils.services.amqp_service.time.sleep"):
124
+ with pytest.raises(KeyboardInterrupt):
125
+ service.subscribe()
126
+
127
+ assert connect_calls == 2
128
+
129
+ def test_reconnects_after_connection_exception(self):
130
+ """An exception during _connect triggers a retry after delay."""
131
+ service = _make_service()
132
+ connect_attempts = 0
133
+
134
+ def failing_connect():
135
+ nonlocal connect_attempts
136
+ connect_attempts += 1
137
+ if connect_attempts > 2:
138
+ raise KeyboardInterrupt
139
+ raise ConnectionError("broker unavailable")
140
+
141
+ with patch.object(service, "_connect", side_effect=failing_connect), \
142
+ patch("base_api_utils.services.amqp_service.time.sleep") as mock_sleep:
143
+ with pytest.raises(KeyboardInterrupt):
144
+ service.subscribe()
145
+
146
+ assert connect_attempts == 3
147
+ assert mock_sleep.call_count == 2
148
+
149
+ def test_resets_backoff_after_successful_session(self):
150
+ """After start_consuming returns cleanly, backoff resets to base_delay."""
151
+ service = _make_service(base_delay=1.0, max_delay=60.0, jitter=False)
152
+ connect_calls = 0
153
+
154
+ def fake_connect():
155
+ nonlocal connect_calls
156
+ connect_calls += 1
157
+ if connect_calls == 1:
158
+ mock_ch = MagicMock()
159
+ mock_ch.start_consuming.return_value = None # returns cleanly
160
+ service.connection = MagicMock(is_closed=True)
161
+ service.channel = mock_ch
162
+ elif connect_calls == 2:
163
+ raise ConnectionError("first failure after clean session")
164
+ else:
165
+ raise KeyboardInterrupt
166
+
167
+ with patch.object(service, "_connect", side_effect=fake_connect), \
168
+ patch("base_api_utils.services.amqp_service.time.sleep") as mock_sleep:
169
+ with pytest.raises(KeyboardInterrupt):
170
+ service.subscribe()
171
+
172
+ # Clean session resets attempt to 0; the first failure after it must use base_delay, not 2×
173
+ delays = [c.args[0] for c in mock_sleep.call_args_list]
174
+ assert delays == pytest.approx([1.0, 1.0])
175
+
176
+ def test_cleans_up_open_connection_on_error(self):
177
+ """An open connection is closed in the finally block after an error."""
178
+ service = _make_service()
179
+ mock_conn = MagicMock(is_closed=False)
180
+
181
+ def fake_connect():
182
+ service.connection = mock_conn
183
+ mock_ch = MagicMock()
184
+ mock_ch.start_consuming.side_effect = KeyboardInterrupt
185
+ service.channel = mock_ch
186
+
187
+ with patch.object(service, "_connect", side_effect=fake_connect), \
188
+ patch("base_api_utils.services.amqp_service.time.sleep"):
189
+ with pytest.raises(KeyboardInterrupt):
190
+ service.subscribe()
191
+
192
+ mock_conn.close.assert_called_once()
193
+
194
+ def test_delay_grows_exponentially_across_failures(self):
195
+ """Reconnection delay doubles on each successive failure."""
196
+ service = _make_service(base_delay=1.0, max_delay=60.0, jitter=False)
197
+ connect_attempts = 0
198
+
199
+ def failing_connect():
200
+ nonlocal connect_attempts
201
+ connect_attempts += 1
202
+ if connect_attempts > 3:
203
+ raise KeyboardInterrupt
204
+ raise ConnectionError("broker unavailable")
205
+
206
+ with patch.object(service, "_connect", side_effect=failing_connect), \
207
+ patch("base_api_utils.services.amqp_service.time.sleep") as mock_sleep:
208
+ with pytest.raises(KeyboardInterrupt):
209
+ service.subscribe()
210
+
211
+ delays = [c.args[0] for c in mock_sleep.call_args_list]
212
+ assert delays == [1.0, 2.0, 4.0]
213
+
214
+ def test_subscribe_raises_when_queue_name_is_none(self):
215
+ """subscribe() must fail fast when queue_name is not configured."""
216
+ service = _make_service(queue_name=None)
217
+ with pytest.raises(ValueError, match="queue_name is required"):
218
+ service.subscribe()
@@ -0,0 +1,136 @@
1
+ import importlib.util
2
+ import sys
3
+ import types
4
+ import unittest.mock
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+
10
+ def _load_module(name, file_path):
11
+ """Load a module directly from file path, bypassing package __init__.py."""
12
+ spec = importlib.util.spec_from_file_location(name, file_path)
13
+ if spec is None or spec.loader is None:
14
+ raise ImportError(f"Could not load module {name} from {file_path}")
15
+ module = importlib.util.module_from_spec(spec)
16
+ sys.modules[name] = module
17
+ spec.loader.exec_module(module)
18
+ return module
19
+
20
+
21
+ _utils_dir = Path(__file__).resolve().parent.parent / "src" / "base_api_utils" / "utils"
22
+
23
+
24
+ @pytest.mark.unit
25
+ class TestExpandQuerysetOptimizationMixinImports:
26
+ """Regression: ExpandQuerysetOptimizationMixin must not trigger
27
+ serializers/__init__.py at module load time, because that chain
28
+ reaches django_celery_results.models.TaskResult (a Django model
29
+ that requires the app registry to be ready).
30
+ """
31
+
32
+ def test_mixin_module_loads_without_triggering_serializers_init(self):
33
+ """Loading the mixin module should NOT execute serializers/__init__.py."""
34
+ # Clear any cached state
35
+ mod_name = "base_api_utils.utils.expand_queryset_optimization_mixin"
36
+ for key in list(sys.modules):
37
+ if key.startswith("base_api_utils.serializers"):
38
+ del sys.modules[key]
39
+ if mod_name in sys.modules:
40
+ del sys.modules[mod_name]
41
+
42
+ module = _load_module(mod_name, _utils_dir / "expand_queryset_optimization_mixin.py")
43
+
44
+ # Module should load successfully and define the mixin class
45
+ assert hasattr(module, "ExpandQuerysetOptimizationMixin")
46
+
47
+ # serializers/__init__.py should NOT have been executed during load
48
+ assert "base_api_utils.serializers" not in sys.modules
49
+ assert "base_api_utils.serializers.task_result_serializer" not in sys.modules
50
+
51
+ def test_mixin_has_no_module_level_query_params_import(self):
52
+ """The query_params import should be deferred to method level."""
53
+ mod_name = "base_api_utils.utils.expand_queryset_optimization_mixin"
54
+ if mod_name in sys.modules:
55
+ del sys.modules[mod_name]
56
+
57
+ module = _load_module(mod_name, _utils_dir / "expand_queryset_optimization_mixin.py")
58
+
59
+ # query_params should NOT be loaded at module level
60
+ assert "base_api_utils.serializers.v2.query_params" not in sys.modules
61
+
62
+
63
+ @pytest.mark.unit
64
+ class TestBaseApiUtilsInitLazyImport:
65
+ """Regression: importing base_api_utils must NOT eagerly load
66
+ urls → views → tasks_result_view → @extend_schema → DEFAULT_SCHEMA_CLASS.
67
+ That chain causes a circular import when base_api_utils is imported by
68
+ a custom AutoSchema subclass that is itself registered as DEFAULT_SCHEMA_CLASS.
69
+
70
+ private_tasks_admin_patterns must be accessible but only materialised on
71
+ first attribute access, not at package-import time.
72
+ """
73
+
74
+ def test_import_base_api_utils_does_not_load_views(self):
75
+ """Importing the package must not pull in views/tasks_result_view."""
76
+ for key in list(sys.modules):
77
+ if key.startswith("base_api_utils"):
78
+ del sys.modules[key]
79
+
80
+ _base_dir = Path(__file__).resolve().parent.parent / "src" / "base_api_utils"
81
+ _load_module("base_api_utils", _base_dir / "__init__.py")
82
+
83
+ assert "base_api_utils.views" not in sys.modules
84
+ assert "base_api_utils.views.tasks_result_view" not in sys.modules
85
+ assert "base_api_utils.urls" not in sys.modules
86
+
87
+ def test_unknown_attribute_raises_attribute_error(self):
88
+ """__getattr__ must re-raise AttributeError for unknown names so that
89
+ normal attribute-missing semantics are preserved."""
90
+ for key in list(sys.modules):
91
+ if key.startswith("base_api_utils"):
92
+ del sys.modules[key]
93
+
94
+ _base_dir = Path(__file__).resolve().parent.parent / "src" / "base_api_utils"
95
+ module = _load_module("base_api_utils", _base_dir / "__init__.py")
96
+
97
+ with pytest.raises(AttributeError):
98
+ _ = module.no_such_attribute_xyz
99
+
100
+ def test_all_declares_private_tasks_admin_patterns(self):
101
+ """__all__ must list private_tasks_admin_patterns so star imports work."""
102
+ for key in list(sys.modules):
103
+ if key.startswith("base_api_utils"):
104
+ del sys.modules[key]
105
+
106
+ _base_dir = Path(__file__).resolve().parent.parent / "src" / "base_api_utils"
107
+ module = _load_module("base_api_utils", _base_dir / "__init__.py")
108
+
109
+ assert hasattr(module, "__all__")
110
+ assert "private_tasks_admin_patterns" in module.__all__
111
+
112
+ def test_private_tasks_admin_patterns_lazy_resolution_with_stubbed_urls(self):
113
+ """Actually triggering getattr resolves the attribute via __getattr__
114
+ without needing Django's app registry.
115
+
116
+ Stubs base_api_utils.urls in sys.modules so the relative import inside
117
+ __getattr__ never reaches the real urls.py → views → Django app-registry
118
+ chain. Verifies the lazy-import path executes correctly end-to-end.
119
+ """
120
+ for key in list(sys.modules):
121
+ if key.startswith("base_api_utils"):
122
+ del sys.modules[key]
123
+
124
+ _base_dir = Path(__file__).resolve().parent.parent / "src" / "base_api_utils"
125
+ module = _load_module("base_api_utils", _base_dir / "__init__.py")
126
+ # Ensure the module is recognized as a package so relative imports work.
127
+ module.__package__ = "base_api_utils"
128
+
129
+ sentinel = object()
130
+ fake_urls = types.ModuleType("base_api_utils.urls")
131
+ setattr(fake_urls, "private_tasks_admin_patterns", sentinel)
132
+
133
+ with unittest.mock.patch.dict(sys.modules, {"base_api_utils.urls": fake_urls}):
134
+ result = getattr(module, "private_tasks_admin_patterns")
135
+
136
+ assert result is sentinel