base-api-utils 1.1.117.dev3__tar.gz → 1.1.118__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.
- {base_api_utils-1.1.117.dev3/src/base_api_utils.egg-info → base_api_utils-1.1.118}/PKG-INFO +1 -1
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/pyproject.toml +1 -1
- base_api_utils-1.1.118/src/base_api_utils/__init__.py +14 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/security/check_sponsor_user_permissions.py +5 -2
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/security/oauth2_scope_required.py +11 -4
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/serializers/v2/abstract_serializer.py +11 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/services/amqp_service.py +34 -8
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118/src/base_api_utils.egg-info}/PKG-INFO +1 -1
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils.egg-info/SOURCES.txt +3 -0
- base_api_utils-1.1.118/tests/test_amqp_service.py +218 -0
- base_api_utils-1.1.118/tests/test_check_sponsor_user_permissions.py +188 -0
- base_api_utils-1.1.118/tests/test_lazy_imports.py +136 -0
- base_api_utils-1.1.118/tests/test_oauth2_scope_required.py +171 -0
- base_api_utils-1.1.118/tests/test_v2_abstract_serializer.py +290 -0
- base_api_utils-1.1.117.dev3/src/base_api_utils/__init__.py +0 -1
- base_api_utils-1.1.117.dev3/tests/test_lazy_imports.py +0 -58
- base_api_utils-1.1.117.dev3/tests/test_v2_abstract_serializer.py +0 -142
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/LICENSE +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/README.md +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/setup.cfg +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/commands/__init__.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/commands/failed_task_management_mixin.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/domain/__init__.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/domain/event_message.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/domain/event_message_factory.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/domain/events.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/dtos/__init__.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/dtos/authz_user.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/fields/__init__.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/fields/email_list_field.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/filters/__init__.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/filters/base_filter.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/filters/custom_ordering_filter.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/filters/filter_utils_mixin.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/handlers/__init__.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/handlers/base_event_handler.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/handlers/sponsor_event_handler.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/handlers/sponsorship_addon_event_handler.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/handlers/sponsorship_event_handler.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/handlers/summit_event_handler.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/ioc/__init__.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/ioc/container.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/models/__init__.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/models/item_image.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/security/__init__.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/security/abstract_access_token_service.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/security/abstract_oauth2_api_client.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/security/access_token_service.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/security/group_required.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/security/oauth2_authentication.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/security/oauth2_client_factory.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/security/permission_event_handler.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/security/shared.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/security/user_permissions_service.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/serializers/__init__.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/serializers/base_model_serializer.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/serializers/email_list_char_field.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/serializers/nullable_slug_related_field.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/serializers/serializers_registry.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/serializers/task_result_serializer.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/serializers/timestamp_field.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/serializers/v2/__init__.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/serializers/v2/base_model_serializer.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/serializers/v2/expands.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/serializers/v2/query_params.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/serializers/v2/spectacular.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/services/__init__.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/services/abstract_domain_events_service.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/services/abstract_event_dispatcher.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/services/abstract_images_service.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/services/abstract_sponsor_metadata_service.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/services/abstract_storage_service.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/services/abstract_summit_metadata_service.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/services/celery_failed.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/services/event_dispatcher.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/services/images_service.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/services/s3_service.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/services/strategies/__init__.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/services/strategies/abstract_task_runner_strategy.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/services/strategies/publish_domain_event_strategy.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/tests/__init__.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/tests/test_failed_celery_task_management.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/tests/test_task_status_endpoints.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/urls.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/__init__.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/async_helpers.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/circuit_breaker.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/config.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/custom_auto_schema.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/db_connection_helpers.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/debounce_helpers.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/enums.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/env_var_eval.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/exceptions.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/expand_queryset_optimization_mixin.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/external_errors.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/file_lock.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/fingerprint_helpers.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/http_helpers.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/inject_job.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/look_up_value_mixin.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/model_helpers.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/oauth2_custom_schema_base.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/pagination.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/rate_utils.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/read_write_serializer_mixin.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/serializers.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/service_utils.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/string.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/utils/tests.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/views/__init__.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/views/base_view.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/views/parent_children_crud.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/views/root_entity_crud.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/views/tasks_result_view.py +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils.egg-info/dependency_links.txt +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils.egg-info/requires.txt +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils.egg-info/top_level.txt +0 -0
- {base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/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.
|
|
3
|
+
Version: 1.1.118
|
|
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
|
|
@@ -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}")
|
|
@@ -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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
@@ -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):
|
|
@@ -133,6 +134,16 @@ class AbstractSerializer:
|
|
|
133
134
|
# here and let the deeper constraints apply at child serializers.
|
|
134
135
|
has_explicit_leaf = any(not subtree for subtree in ft.values())
|
|
135
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)
|
|
136
147
|
return fields
|
|
137
148
|
keep = (
|
|
138
149
|
set(ft.keys())
|
{base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils/services/amqp_service.py
RENAMED
|
@@ -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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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.
|
|
3
|
+
Version: 1.1.118
|
|
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
|
{base_api_utils-1.1.117.dev3 → base_api_utils-1.1.118}/src/base_api_utils.egg-info/SOURCES.txt
RENAMED
|
@@ -106,6 +106,9 @@ 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
|
|
111
114
|
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)
|