base-api-utils 1.1.117.dev4__tar.gz → 1.1.119__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 (117) hide show
  1. {base_api_utils-1.1.117.dev4/src/base_api_utils.egg-info → base_api_utils-1.1.119}/PKG-INFO +1 -1
  2. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/pyproject.toml +1 -1
  3. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/__init__.py +3 -0
  4. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/security/check_sponsor_user_permissions.py +5 -2
  5. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/security/oauth2_scope_required.py +11 -4
  6. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/serializers/timestamp_field.py +2 -0
  7. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/serializers/v2/abstract_serializer.py +1 -0
  8. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/services/amqp_service.py +34 -8
  9. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119/src/base_api_utils.egg-info}/PKG-INFO +1 -1
  10. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils.egg-info/SOURCES.txt +4 -0
  11. base_api_utils-1.1.119/tests/test_amqp_service.py +218 -0
  12. base_api_utils-1.1.119/tests/test_check_sponsor_user_permissions.py +188 -0
  13. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/tests/test_lazy_imports.py +52 -8
  14. base_api_utils-1.1.119/tests/test_oauth2_scope_required.py +171 -0
  15. base_api_utils-1.1.119/tests/test_timestamp_field.py +55 -0
  16. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/tests/test_v2_abstract_serializer.py +90 -0
  17. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/LICENSE +0 -0
  18. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/README.md +0 -0
  19. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/setup.cfg +0 -0
  20. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/commands/__init__.py +0 -0
  21. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/commands/failed_task_management_mixin.py +0 -0
  22. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/domain/__init__.py +0 -0
  23. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/domain/event_message.py +0 -0
  24. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/domain/event_message_factory.py +0 -0
  25. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/domain/events.py +0 -0
  26. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/dtos/__init__.py +0 -0
  27. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/dtos/authz_user.py +0 -0
  28. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/fields/__init__.py +0 -0
  29. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/fields/email_list_field.py +0 -0
  30. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/filters/__init__.py +0 -0
  31. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/filters/base_filter.py +0 -0
  32. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/filters/custom_ordering_filter.py +0 -0
  33. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/filters/filter_utils_mixin.py +0 -0
  34. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/handlers/__init__.py +0 -0
  35. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/handlers/base_event_handler.py +0 -0
  36. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/handlers/sponsor_event_handler.py +0 -0
  37. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/handlers/sponsorship_addon_event_handler.py +0 -0
  38. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/handlers/sponsorship_event_handler.py +0 -0
  39. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/handlers/summit_event_handler.py +0 -0
  40. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/ioc/__init__.py +0 -0
  41. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/ioc/container.py +0 -0
  42. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/models/__init__.py +0 -0
  43. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/models/item_image.py +0 -0
  44. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/security/__init__.py +0 -0
  45. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/security/abstract_access_token_service.py +0 -0
  46. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/security/abstract_oauth2_api_client.py +0 -0
  47. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/security/access_token_service.py +0 -0
  48. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/security/group_required.py +0 -0
  49. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/security/oauth2_authentication.py +0 -0
  50. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/security/oauth2_client_factory.py +0 -0
  51. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/security/permission_event_handler.py +0 -0
  52. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/security/shared.py +0 -0
  53. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/security/user_permissions_service.py +0 -0
  54. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/serializers/__init__.py +0 -0
  55. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/serializers/base_model_serializer.py +0 -0
  56. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/serializers/email_list_char_field.py +0 -0
  57. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/serializers/nullable_slug_related_field.py +0 -0
  58. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/serializers/serializers_registry.py +0 -0
  59. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/serializers/task_result_serializer.py +0 -0
  60. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/serializers/v2/__init__.py +0 -0
  61. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/serializers/v2/base_model_serializer.py +0 -0
  62. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/serializers/v2/expands.py +0 -0
  63. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/serializers/v2/query_params.py +0 -0
  64. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/serializers/v2/spectacular.py +0 -0
  65. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/services/__init__.py +0 -0
  66. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/services/abstract_domain_events_service.py +0 -0
  67. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/services/abstract_event_dispatcher.py +0 -0
  68. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/services/abstract_images_service.py +0 -0
  69. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/services/abstract_sponsor_metadata_service.py +0 -0
  70. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/services/abstract_storage_service.py +0 -0
  71. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/services/abstract_summit_metadata_service.py +0 -0
  72. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/services/celery_failed.py +0 -0
  73. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/services/event_dispatcher.py +0 -0
  74. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/services/images_service.py +0 -0
  75. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/services/s3_service.py +0 -0
  76. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/services/strategies/__init__.py +0 -0
  77. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/services/strategies/abstract_task_runner_strategy.py +0 -0
  78. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/services/strategies/publish_domain_event_strategy.py +0 -0
  79. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/tests/__init__.py +0 -0
  80. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/tests/test_failed_celery_task_management.py +0 -0
  81. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/tests/test_task_status_endpoints.py +0 -0
  82. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/urls.py +0 -0
  83. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/__init__.py +0 -0
  84. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/async_helpers.py +0 -0
  85. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/circuit_breaker.py +0 -0
  86. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/config.py +0 -0
  87. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/custom_auto_schema.py +0 -0
  88. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/db_connection_helpers.py +0 -0
  89. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/debounce_helpers.py +0 -0
  90. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/enums.py +0 -0
  91. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/env_var_eval.py +0 -0
  92. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/exceptions.py +0 -0
  93. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/expand_queryset_optimization_mixin.py +0 -0
  94. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/external_errors.py +0 -0
  95. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/file_lock.py +0 -0
  96. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/fingerprint_helpers.py +0 -0
  97. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/http_helpers.py +0 -0
  98. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/inject_job.py +0 -0
  99. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/look_up_value_mixin.py +0 -0
  100. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/model_helpers.py +0 -0
  101. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/oauth2_custom_schema_base.py +0 -0
  102. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/pagination.py +0 -0
  103. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/rate_utils.py +0 -0
  104. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/read_write_serializer_mixin.py +0 -0
  105. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/serializers.py +0 -0
  106. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/service_utils.py +0 -0
  107. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/string.py +0 -0
  108. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/utils/tests.py +0 -0
  109. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/views/__init__.py +0 -0
  110. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/views/base_view.py +0 -0
  111. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/views/parent_children_crud.py +0 -0
  112. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/views/root_entity_crud.py +0 -0
  113. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils/views/tasks_result_view.py +0 -0
  114. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils.egg-info/dependency_links.txt +0 -0
  115. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils.egg-info/requires.txt +0 -0
  116. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/src/base_api_utils.egg-info/top_level.txt +0 -0
  117. {base_api_utils-1.1.117.dev4 → base_api_utils-1.1.119}/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.dev4
3
+ Version: 1.1.119
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.dev4"
10
+ version = "1.1.119"
11
11
  dependencies = [
12
12
  "boto3",
13
13
  "celery",
@@ -1,3 +1,6 @@
1
+ __all__ = ["private_tasks_admin_patterns"]
2
+
3
+
1
4
  def __getattr__(name: str):
2
5
  # Lazy import: loading private_tasks_admin_patterns eagerly chains through
3
6
  # views → tasks_result_view → @extend_schema → DEFAULT_SCHEMA_CLASS,
@@ -1,13 +1,12 @@
1
1
  import logging
2
2
  import os
3
3
 
4
- from django.core.exceptions import PermissionDenied
5
4
  from django.utils.functional import wraps
6
5
  from django.utils.translation import gettext_lazy as _
7
6
  from rest_framework.exceptions import ValidationError, AuthenticationFailed
8
7
 
9
8
  from . import AccessTokenService
10
- from .shared import get_path, is_admin
9
+ from .shared import get_path, is_admin, application_type_service
11
10
  from .user_permissions_service import UserPermissionsService
12
11
 
13
12
 
@@ -50,6 +49,10 @@ def check_sponsor_user_permissions():
50
49
  if is_admin(token_info):
51
50
  return func(view, *args, **kwargs)
52
51
 
52
+ if 'application_type' in token_info and token_info['application_type'] == application_type_service:
53
+ logger.debug('authz groups are not evaluated for service-type applications')
54
+ return func(view, *args, **kwargs)
55
+
53
56
  path = get_path(request)
54
57
  method = str(request.method).lower()
55
58
  logger.debug(f'check_sponsor_user_permissions::decorator - checking permissions for {method} {path}')
@@ -55,7 +55,7 @@ def oauth2_scope_required():
55
55
 
56
56
  if is_empty(required_scope):
57
57
  logger.warning('require scope is empty')
58
- raise PermissionDenied(_("required scope not present."))
58
+ raise PermissionDenied(_("required scope not present at endpoint configuration."))
59
59
 
60
60
  if 'scope' in token_info:
61
61
  current_scope = token_info['scope']
@@ -64,10 +64,17 @@ def oauth2_scope_required():
64
64
  # check origins
65
65
  # check scopes
66
66
 
67
- if len(set.intersection(set(required_scope.split()), set(current_scope.split()))):
67
+ required_scopes_set = set(required_scope.split())
68
+ current_scopes_set = set(current_scope.split())
69
+
70
+ if set.intersection(required_scopes_set, current_scopes_set):
68
71
  return func(view, token_info=token_info, *args, **kwargs)
69
72
 
70
- logger.warning('oauth2_scope_required::decorator token scopes not present')
71
- raise PermissionDenied(_("token scopes not present"))
73
+ missing_scopes = ' '.join(sorted(required_scopes_set - current_scopes_set))
74
+ logger.warning(f'oauth2_scope_required::decorator token scopes not present - missing: {missing_scopes}')
75
+ raise PermissionDenied(_(f"token scopes not present - missing: {missing_scopes}"))
76
+
77
+ logger.warning(f'oauth2_scope_required::decorator token has no scope - required: {required_scope}')
78
+ raise PermissionDenied(_(f"token scopes not present - required: {required_scope}"))
72
79
  return inner
73
80
  return decorator
@@ -19,4 +19,6 @@ class TimestampField(serializers.Field):
19
19
  raise serializers.ValidationError("Invalid timestamp format.")
20
20
 
21
21
  def to_representation(self, value):
22
+ if value is None:
23
+ return None
22
24
  return int(time.mktime(value.timetuple()))
@@ -123,6 +123,7 @@ class AbstractSerializer:
123
123
  ctx["relations_tree"] = {k: {} for k in dr}
124
124
 
125
125
  def _filter_local_fields(self, fields):
126
+ fields = dict(fields)
126
127
  ctx = self._own_context
127
128
  ft = ctx.get("fields_tree")
128
129
  if ft is None or (isinstance(ft, dict) and len(ft) == 0):
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: base-api-utils
3
- Version: 1.1.117.dev4
3
+ Version: 1.1.119
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
@@ -106,6 +106,10 @@ src/base_api_utils/views/base_view.py
106
106
  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
+ tests/test_amqp_service.py
110
+ tests/test_check_sponsor_user_permissions.py
109
111
  tests/test_circuit_breaker.py
110
112
  tests/test_lazy_imports.py
113
+ tests/test_oauth2_scope_required.py
114
+ tests/test_timestamp_field.py
111
115
  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,188 @@
1
+ import functools
2
+ import importlib.util
3
+ import sys
4
+ from contextlib import contextmanager
5
+ from pathlib import Path
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ import pytest
9
+
10
+
11
+ # ── Stub classes ──────────────────────────────────────────────────────────────
12
+
13
+ class _ValidationError(Exception):
14
+ pass
15
+
16
+
17
+ class _AuthenticationFailed(Exception):
18
+ pass
19
+
20
+
21
+ class _PermissionDenied(Exception):
22
+ pass
23
+
24
+
25
+ _mock_access_token_service = MagicMock()
26
+ _mock_access_token_service.validate_token_info = MagicMock()
27
+
28
+ _mock_get_path = MagicMock(return_value="/api/endpoint")
29
+ _mock_is_admin = MagicMock(return_value=False)
30
+ _APPLICATION_TYPE_SERVICE = 'SERVICE'
31
+
32
+ _mock_user_permissions_service = MagicMock()
33
+ _mock_user_permissions_service.has_permissions = MagicMock(return_value=True)
34
+
35
+ _stub_functional = MagicMock()
36
+ _stub_functional.wraps = functools.wraps
37
+
38
+ _STUBS = {
39
+ "django": MagicMock(),
40
+ "django.core": MagicMock(),
41
+ "django.core.exceptions": MagicMock(PermissionDenied=_PermissionDenied),
42
+ "django.utils": MagicMock(),
43
+ "django.utils.functional": _stub_functional,
44
+ "django.utils.translation": MagicMock(gettext_lazy=lambda s: s),
45
+ "rest_framework": MagicMock(),
46
+ "rest_framework.exceptions": MagicMock(
47
+ ValidationError=_ValidationError,
48
+ AuthenticationFailed=_AuthenticationFailed,
49
+ ),
50
+ "base_api_utils": MagicMock(),
51
+ "base_api_utils.security": MagicMock(AccessTokenService=_mock_access_token_service),
52
+ "base_api_utils.security.shared": MagicMock(
53
+ get_path=_mock_get_path,
54
+ is_admin=_mock_is_admin,
55
+ application_type_service=_APPLICATION_TYPE_SERVICE,
56
+ ),
57
+ "base_api_utils.security.user_permissions_service": MagicMock(
58
+ UserPermissionsService=_mock_user_permissions_service,
59
+ ),
60
+ }
61
+
62
+
63
+ @contextmanager
64
+ def _patched_modules():
65
+ saved = {name: sys.modules.get(name) for name in _STUBS}
66
+ sys.modules.update(_STUBS)
67
+ try:
68
+ yield
69
+ finally:
70
+ for name, orig in saved.items():
71
+ if orig is None:
72
+ sys.modules.pop(name, None)
73
+ else:
74
+ sys.modules[name] = orig
75
+
76
+
77
+ def _load_module():
78
+ src = (
79
+ Path(__file__).resolve().parent.parent
80
+ / "src" / "base_api_utils" / "security" / "check_sponsor_user_permissions.py"
81
+ )
82
+ with _patched_modules():
83
+ spec = importlib.util.spec_from_file_location(
84
+ "base_api_utils.security.check_sponsor_user_permissions_isolated", src
85
+ )
86
+ module = importlib.util.module_from_spec(spec)
87
+ spec.loader.exec_module(module)
88
+ return module
89
+
90
+
91
+ _mod = _load_module()
92
+ check_sponsor_user_permissions = _mod.check_sponsor_user_permissions
93
+
94
+
95
+ # ── Helpers ───────────────────────────────────────────────────────────────────
96
+
97
+ def _make_request(token_info, method="get"):
98
+ request = MagicMock()
99
+ request.method = method.upper()
100
+ request.auth = token_info
101
+ return request
102
+
103
+
104
+ def _make_view(token_info, sponsor_id=1, summit_id=2, method="get"):
105
+ view = MagicMock()
106
+ view.request = _make_request(token_info, method)
107
+ return view, {"sponsor_id": sponsor_id, "summit_id": summit_id}
108
+
109
+
110
+ def _make_decorated():
111
+ @check_sponsor_user_permissions()
112
+ def endpoint(view, token_info=None, **kwargs):
113
+ return "ok"
114
+
115
+ return endpoint
116
+
117
+
118
+ # ── Tests ─────────────────────────────────────────────────────────────────────
119
+
120
+ @pytest.mark.unit
121
+ class TestCheckSponsorUserPermissions:
122
+
123
+ def setup_method(self):
124
+ _mock_access_token_service.validate_token_info.reset_mock()
125
+ _mock_is_admin.reset_mock()
126
+ _mock_is_admin.return_value = False
127
+ _mock_user_permissions_service.has_permissions.reset_mock()
128
+ _mock_user_permissions_service.has_permissions.return_value = True
129
+
130
+ def test_service_type_application_bypasses_permission_check(self):
131
+ """SERVICE application_type skips UserPermissionsService entirely."""
132
+ token_info = {"application_type": "SERVICE"}
133
+ view, kwargs = _make_view(token_info)
134
+ decorated = _make_decorated()
135
+
136
+ with patch.dict("os.environ", {"ENV": ""}):
137
+ result = decorated(view, **kwargs)
138
+
139
+ assert result == "ok"
140
+ _mock_user_permissions_service.has_permissions.assert_not_called()
141
+
142
+ def test_non_service_application_type_proceeds_to_permission_check(self):
143
+ """A different application_type is not bypassed."""
144
+ token_info = {"application_type": "CONFIDENTIAL"}
145
+ view, kwargs = _make_view(token_info)
146
+ decorated = _make_decorated()
147
+
148
+ with patch.dict("os.environ", {"ENV": ""}):
149
+ result = decorated(view, **kwargs)
150
+
151
+ assert result == "ok"
152
+ _mock_user_permissions_service.has_permissions.assert_called_once()
153
+
154
+ def test_no_application_type_proceeds_to_permission_check(self):
155
+ """Token without application_type is not bypassed."""
156
+ token_info = {}
157
+ view, kwargs = _make_view(token_info)
158
+ decorated = _make_decorated()
159
+
160
+ with patch.dict("os.environ", {"ENV": ""}):
161
+ result = decorated(view, **kwargs)
162
+
163
+ assert result == "ok"
164
+ _mock_user_permissions_service.has_permissions.assert_called_once()
165
+
166
+ def test_service_type_bypass_takes_precedence_after_admin_check(self):
167
+ """Service bypass is checked after is_admin; both independently allow access."""
168
+ token_info = {"application_type": "SERVICE"}
169
+ view, kwargs = _make_view(token_info)
170
+ decorated = _make_decorated()
171
+ _mock_is_admin.return_value = False
172
+
173
+ with patch.dict("os.environ", {"ENV": ""}):
174
+ result = decorated(view, **kwargs)
175
+
176
+ assert result == "ok"
177
+ _mock_user_permissions_service.has_permissions.assert_not_called()
178
+
179
+ def test_permission_denied_raises_authentication_failed(self):
180
+ """Regular user with no access gets AuthenticationFailed."""
181
+ token_info = {}
182
+ view, kwargs = _make_view(token_info)
183
+ decorated = _make_decorated()
184
+ _mock_user_permissions_service.has_permissions.return_value = False
185
+
186
+ with patch.dict("os.environ", {"ENV": ""}):
187
+ with pytest.raises(_AuthenticationFailed):
188
+ decorated(view, **kwargs)
@@ -1,5 +1,7 @@
1
1
  import importlib.util
2
2
  import sys
3
+ import types
4
+ import unittest.mock
3
5
  from pathlib import Path
4
6
 
5
7
  import pytest
@@ -82,11 +84,53 @@ class TestBaseApiUtilsInitLazyImport:
82
84
  assert "base_api_utils.views.tasks_result_view" not in sys.modules
83
85
  assert "base_api_utils.urls" not in sys.modules
84
86
 
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
- )
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