base-api-utils 1.1.51__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 (81) hide show
  1. base_api_utils-1.1.51/LICENSE +13 -0
  2. base_api_utils-1.1.51/PKG-INFO +95 -0
  3. base_api_utils-1.1.51/README.md +69 -0
  4. base_api_utils-1.1.51/pyproject.toml +40 -0
  5. base_api_utils-1.1.51/setup.cfg +4 -0
  6. base_api_utils-1.1.51/src/base_api_utils/__init__.py +1 -0
  7. base_api_utils-1.1.51/src/base_api_utils/domain/__init__.py +3 -0
  8. base_api_utils-1.1.51/src/base_api_utils/domain/event_message.py +28 -0
  9. base_api_utils-1.1.51/src/base_api_utils/domain/event_message_factory.py +34 -0
  10. base_api_utils-1.1.51/src/base_api_utils/domain/events.py +10 -0
  11. base_api_utils-1.1.51/src/base_api_utils/dtos/__init__.py +1 -0
  12. base_api_utils-1.1.51/src/base_api_utils/dtos/authz_user.py +26 -0
  13. base_api_utils-1.1.51/src/base_api_utils/fields/__init__.py +1 -0
  14. base_api_utils-1.1.51/src/base_api_utils/fields/email_list_field.py +82 -0
  15. base_api_utils-1.1.51/src/base_api_utils/filters/__init__.py +3 -0
  16. base_api_utils-1.1.51/src/base_api_utils/filters/base_filter.py +133 -0
  17. base_api_utils-1.1.51/src/base_api_utils/filters/custom_ordering_filter.py +16 -0
  18. base_api_utils-1.1.51/src/base_api_utils/filters/filter_utils_mixin.py +57 -0
  19. base_api_utils-1.1.51/src/base_api_utils/handlers/__init__.py +1 -0
  20. base_api_utils-1.1.51/src/base_api_utils/handlers/base_event_handler.py +14 -0
  21. base_api_utils-1.1.51/src/base_api_utils/ioc/__init__.py +1 -0
  22. base_api_utils-1.1.51/src/base_api_utils/ioc/container.py +14 -0
  23. base_api_utils-1.1.51/src/base_api_utils/models/__init__.py +1 -0
  24. base_api_utils-1.1.51/src/base_api_utils/models/item_image.py +13 -0
  25. base_api_utils-1.1.51/src/base_api_utils/security/__init__.py +11 -0
  26. base_api_utils-1.1.51/src/base_api_utils/security/abstract_access_token_service.py +11 -0
  27. base_api_utils-1.1.51/src/base_api_utils/security/abstract_oauth2_api_client.py +136 -0
  28. base_api_utils-1.1.51/src/base_api_utils/security/access_token_service.py +66 -0
  29. base_api_utils-1.1.51/src/base_api_utils/security/check_sponsor_user_permissions.py +75 -0
  30. base_api_utils-1.1.51/src/base_api_utils/security/group_required.py +79 -0
  31. base_api_utils-1.1.51/src/base_api_utils/security/oauth2_authentication.py +35 -0
  32. base_api_utils-1.1.51/src/base_api_utils/security/oauth2_client_factory.py +45 -0
  33. base_api_utils-1.1.51/src/base_api_utils/security/oauth2_scope_required.py +75 -0
  34. base_api_utils-1.1.51/src/base_api_utils/security/permission_event_handler.py +39 -0
  35. base_api_utils-1.1.51/src/base_api_utils/security/shared.py +50 -0
  36. base_api_utils-1.1.51/src/base_api_utils/security/user_permissions_service.py +151 -0
  37. base_api_utils-1.1.51/src/base_api_utils/serializers/__init__.py +5 -0
  38. base_api_utils-1.1.51/src/base_api_utils/serializers/base_model_serializer.py +194 -0
  39. base_api_utils-1.1.51/src/base_api_utils/serializers/email_list_char_field.py +45 -0
  40. base_api_utils-1.1.51/src/base_api_utils/serializers/nullable_slug_related_field.py +12 -0
  41. base_api_utils-1.1.51/src/base_api_utils/serializers/serializers_registry.py +28 -0
  42. base_api_utils-1.1.51/src/base_api_utils/serializers/task_result_serializer.py +7 -0
  43. base_api_utils-1.1.51/src/base_api_utils/serializers/timestamp_field.py +22 -0
  44. base_api_utils-1.1.51/src/base_api_utils/services/__init__.py +8 -0
  45. base_api_utils-1.1.51/src/base_api_utils/services/abstract_domain_events_service.py +15 -0
  46. base_api_utils-1.1.51/src/base_api_utils/services/abstract_event_dispatcher.py +10 -0
  47. base_api_utils-1.1.51/src/base_api_utils/services/abstract_images_service.py +18 -0
  48. base_api_utils-1.1.51/src/base_api_utils/services/abstract_storage_service.py +11 -0
  49. base_api_utils-1.1.51/src/base_api_utils/services/amqp_service.py +211 -0
  50. base_api_utils-1.1.51/src/base_api_utils/services/event_dispatcher.py +33 -0
  51. base_api_utils-1.1.51/src/base_api_utils/services/images_service.py +66 -0
  52. base_api_utils-1.1.51/src/base_api_utils/services/s3_service.py +57 -0
  53. base_api_utils-1.1.51/src/base_api_utils/services/strategies/__init__.py +2 -0
  54. base_api_utils-1.1.51/src/base_api_utils/services/strategies/abstract_task_runner_strategy.py +8 -0
  55. base_api_utils-1.1.51/src/base_api_utils/services/strategies/publish_domain_event_strategy.py +52 -0
  56. base_api_utils-1.1.51/src/base_api_utils/urls.py +8 -0
  57. base_api_utils-1.1.51/src/base_api_utils/utils/__init__.py +12 -0
  58. base_api_utils-1.1.51/src/base_api_utils/utils/async_helpers.py +43 -0
  59. base_api_utils-1.1.51/src/base_api_utils/utils/circuit_breaker.py +45 -0
  60. base_api_utils-1.1.51/src/base_api_utils/utils/config.py +20 -0
  61. base_api_utils-1.1.51/src/base_api_utils/utils/custom_auto_schema.py +38 -0
  62. base_api_utils-1.1.51/src/base_api_utils/utils/db_connection_helpers.py +26 -0
  63. base_api_utils-1.1.51/src/base_api_utils/utils/enums.py +16 -0
  64. base_api_utils-1.1.51/src/base_api_utils/utils/exceptions.py +94 -0
  65. base_api_utils-1.1.51/src/base_api_utils/utils/external_errors.py +71 -0
  66. base_api_utils-1.1.51/src/base_api_utils/utils/file_lock.py +98 -0
  67. base_api_utils-1.1.51/src/base_api_utils/utils/oauth2_custom_schema_base.py +30 -0
  68. base_api_utils-1.1.51/src/base_api_utils/utils/pagination.py +43 -0
  69. base_api_utils-1.1.51/src/base_api_utils/utils/rate_utils.py +30 -0
  70. base_api_utils-1.1.51/src/base_api_utils/utils/read_write_serializer_mixin.py +134 -0
  71. base_api_utils-1.1.51/src/base_api_utils/utils/string.py +77 -0
  72. base_api_utils-1.1.51/src/base_api_utils/views/__init__.py +4 -0
  73. base_api_utils-1.1.51/src/base_api_utils/views/base_view.py +42 -0
  74. base_api_utils-1.1.51/src/base_api_utils/views/parent_children_crud.py +97 -0
  75. base_api_utils-1.1.51/src/base_api_utils/views/root_entity_crud.py +85 -0
  76. base_api_utils-1.1.51/src/base_api_utils/views/tasks_result_view.py +36 -0
  77. base_api_utils-1.1.51/src/base_api_utils.egg-info/PKG-INFO +95 -0
  78. base_api_utils-1.1.51/src/base_api_utils.egg-info/SOURCES.txt +79 -0
  79. base_api_utils-1.1.51/src/base_api_utils.egg-info/dependency_links.txt +1 -0
  80. base_api_utils-1.1.51/src/base_api_utils.egg-info/requires.txt +13 -0
  81. base_api_utils-1.1.51/src/base_api_utils.egg-info/top_level.txt +1 -0
@@ -0,0 +1,13 @@
1
+ Copyright 2025 FNTECH
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: base-api-utils
3
+ Version: 1.1.51
4
+ Summary: Django Rest Framework micro services common utilities
5
+ Author-email: Román Gutierrez <roman@tipit.net>, Sebastian Marcet <sebastian@tipit.net>
6
+ Project-URL: Homepage, https://github.com/fntechgit/base-api-utils
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: boto3
13
+ Requires-Dist: celery
14
+ Requires-Dist: Django
15
+ Requires-Dist: django-celery-results
16
+ Requires-Dist: django_filter
17
+ Requires-Dist: djangorestframework>=3.15.2
18
+ Requires-Dist: drf-spectacular
19
+ Requires-Dist: injector
20
+ Requires-Dist: oauthlib
21
+ Requires-Dist: pika
22
+ Requires-Dist: pybreaker
23
+ Requires-Dist: requests
24
+ Requires-Dist: requests-oauthlib
25
+ Dynamic: license-file
26
+
27
+ # base-api-utils
28
+ DRF common utilities
29
+
30
+ ## Virtual Env
31
+
32
+ ````bash
33
+ $ python3 -m venv env
34
+
35
+ $ source env/bin/activate
36
+ ````
37
+
38
+ ## python setup
39
+
40
+ ````bash
41
+ sudo add-apt-repository ppa:deadsnakes/ppa -y
42
+ sudo apt-get -y -f install python3.7 python3-pip python3.7-dev python3.7-venv libpython3.7-dev python3-setuptools
43
+ sudo -H pip3 --default-timeout=50 install --upgrade pip
44
+ sudo -H pip3 install virtualenv
45
+ ````
46
+
47
+ ## Install reqs
48
+
49
+ ````
50
+ pip install -r requirements.txt
51
+ ````
52
+
53
+ ## Packaging
54
+
55
+ Create ~/.pypirc:
56
+
57
+ ```bash
58
+ [distutils]
59
+ index-servers =
60
+ pypi
61
+
62
+ [pypi]
63
+ username = __token__
64
+ password = pypi-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
65
+
66
+ ```
67
+
68
+ ````bash
69
+ python3 -m pip install --upgrade build
70
+ python3 -m build
71
+ ````
72
+ ## Uploading
73
+
74
+ ```bash
75
+ python3 -m pip install --upgrade twine
76
+ ```
77
+
78
+ ### Test Py Pi
79
+
80
+ ```bash
81
+ python3 -m twine upload --repository testpypi dist/*
82
+ ```
83
+
84
+ ## Production PyPi
85
+
86
+ ```bash
87
+ python3 -m twine upload dist/*
88
+ ```
89
+
90
+ ## Install from testPyPi.Org
91
+ pip install -i https://test.pypi.org/simple/ base-api-utils --no-deps
92
+
93
+ ## Install from GitHub
94
+
95
+ pip install git+https://github.com/fntechgit/base-api-utils
@@ -0,0 +1,69 @@
1
+ # base-api-utils
2
+ DRF common utilities
3
+
4
+ ## Virtual Env
5
+
6
+ ````bash
7
+ $ python3 -m venv env
8
+
9
+ $ source env/bin/activate
10
+ ````
11
+
12
+ ## python setup
13
+
14
+ ````bash
15
+ sudo add-apt-repository ppa:deadsnakes/ppa -y
16
+ sudo apt-get -y -f install python3.7 python3-pip python3.7-dev python3.7-venv libpython3.7-dev python3-setuptools
17
+ sudo -H pip3 --default-timeout=50 install --upgrade pip
18
+ sudo -H pip3 install virtualenv
19
+ ````
20
+
21
+ ## Install reqs
22
+
23
+ ````
24
+ pip install -r requirements.txt
25
+ ````
26
+
27
+ ## Packaging
28
+
29
+ Create ~/.pypirc:
30
+
31
+ ```bash
32
+ [distutils]
33
+ index-servers =
34
+ pypi
35
+
36
+ [pypi]
37
+ username = __token__
38
+ password = pypi-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
39
+
40
+ ```
41
+
42
+ ````bash
43
+ python3 -m pip install --upgrade build
44
+ python3 -m build
45
+ ````
46
+ ## Uploading
47
+
48
+ ```bash
49
+ python3 -m pip install --upgrade twine
50
+ ```
51
+
52
+ ### Test Py Pi
53
+
54
+ ```bash
55
+ python3 -m twine upload --repository testpypi dist/*
56
+ ```
57
+
58
+ ## Production PyPi
59
+
60
+ ```bash
61
+ python3 -m twine upload dist/*
62
+ ```
63
+
64
+ ## Install from testPyPi.Org
65
+ pip install -i https://test.pypi.org/simple/ base-api-utils --no-deps
66
+
67
+ ## Install from GitHub
68
+
69
+ pip install git+https://github.com/fntechgit/base-api-utils
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [tool.setuptools.packages.find]
6
+ where = ["src"]
7
+
8
+ [project]
9
+ name = "base-api-utils"
10
+ version = "1.1.51"
11
+ dependencies = [
12
+ "boto3",
13
+ "celery",
14
+ "Django",
15
+ "django-celery-results",
16
+ "django_filter",
17
+ "djangorestframework>=3.15.2",
18
+ "drf-spectacular",
19
+ "injector",
20
+ "oauthlib",
21
+ "pika",
22
+ "pybreaker",
23
+ "requests",
24
+ "requests-oauthlib"
25
+ ]
26
+ authors = [
27
+ { name="Román Gutierrez", email="roman@tipit.net" },
28
+ { name="Sebastian Marcet", email="sebastian@tipit.net" },
29
+ ]
30
+ description = "Django Rest Framework micro services common utilities"
31
+ readme = "README.md"
32
+ requires-python = ">=3.10"
33
+ classifiers = [
34
+ "Programming Language :: Python :: 3",
35
+ "Operating System :: OS Independent",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/fntechgit/base-api-utils"
40
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ from .urls import private_tasks_admin_patterns
@@ -0,0 +1,3 @@
1
+ from .event_message import EventMessage
2
+ from .event_message_factory import EventMessageFactory
3
+ from .events import DomainEvent
@@ -0,0 +1,28 @@
1
+ import json
2
+ from typing import Dict, Optional, Any
3
+
4
+
5
+ class EventMessage:
6
+ def __init__(self,
7
+ event_type: str,
8
+ payload: Dict[str, Any],
9
+ routing_key: str,
10
+ properties: Optional[Dict[str, Any]] = None) -> None:
11
+ self.event_type = event_type
12
+ self.payload = payload
13
+ self.routing_key = routing_key
14
+ self.properties = properties
15
+
16
+ @staticmethod
17
+ def from_rabbit(ch, method, properties, body):
18
+ try:
19
+ payload = json.loads(body) if isinstance(body, (str, bytes)) else body
20
+ except json.JSONDecodeError:
21
+ payload = {"raw_body": str(body)}
22
+
23
+ return EventMessage(
24
+ event_type=method.routing_key,
25
+ payload=payload,
26
+ routing_key=method.routing_key,
27
+ properties=vars(properties) if properties else None
28
+ )
@@ -0,0 +1,34 @@
1
+ from typing import Dict, Type
2
+
3
+ from . import EventMessage
4
+
5
+
6
+ class EventMessageFactory:
7
+ """Factory to create instances based on message type"""
8
+
9
+ _message_types: Dict[str, Type[EventMessage]] = {
10
+ "standard": EventMessage,
11
+ }
12
+
13
+ @classmethod
14
+ def register_type(cls, name: str, message_class: Type[EventMessage]):
15
+ cls._message_types[name] = message_class
16
+
17
+ @classmethod
18
+ def create(cls, message_type: str, ch, method, properties, body) -> EventMessage:
19
+ """
20
+ Creates an instance of the specified message type
21
+
22
+ Args:
23
+ message_type: Message type name
24
+ ch, method, properties, body: RabbitMQ params
25
+
26
+ Returns:
27
+ EventMessage instance or its subclass
28
+ """
29
+ if message_type is None:
30
+ message_class = EventMessage
31
+ else:
32
+ message_class = cls._message_types.get(message_type, EventMessage)
33
+
34
+ return message_class.from_rabbit(ch, method, properties, body)
@@ -0,0 +1,10 @@
1
+ from enum import Enum
2
+
3
+ class DomainEvent(Enum):
4
+ AUTH_ACCESS_RIGHT_ADDED = "auth_access_right_added"
5
+ AUTH_ACCESS_RIGHT_REMOVED = "auth_access_right_removed"
6
+ AUTH_USER_ADDED_TO_GROUP = "auth_user_added_to_group"
7
+ AUTH_USER_REMOVED_FROM_GROUP = "auth_user_removed_from_group"
8
+ AUTH_USER_ADDED_TO_SPONSOR_AND_SUMMIT = "auth_user_added_to_sponsor_and_summit"
9
+ AUTH_USER_REMOVED_FROM_SPONSOR_AND_SUMMIT = "auth_user_removed_from_sponsor_and_summit"
10
+ AUTH_USER_REMOVED_FROM_SUMMIT = "auth_user_removed_from_summit"
@@ -0,0 +1 @@
1
+ from .authz_user import AuthzUserDTO
@@ -0,0 +1,26 @@
1
+ from ..utils import config
2
+
3
+
4
+ class AuthzUserDTO:
5
+ def __init__(self, token_info=None):
6
+ if token_info is None:
7
+ token_info = {}
8
+ self.id = token_info.get('user_id', None)
9
+ self.first_name = token_info.get('user_first_name', None)
10
+ self.last_name = token_info.get('user_last_name', None)
11
+ self.email = token_info.get('user_email', None)
12
+ self.groups = token_info.get('user_groups', [])
13
+
14
+ def is_admin(self) -> bool:
15
+ admin_groups = config('OAUTH2.ADMIN_GROUPS', '').split()
16
+
17
+ for group in self.groups:
18
+ if group.get("slug") in admin_groups:
19
+ return True
20
+
21
+ return False
22
+
23
+
24
+
25
+
26
+
@@ -0,0 +1 @@
1
+ from .email_list_field import EmailListField
@@ -0,0 +1,82 @@
1
+ from django.db import models
2
+ from django.core.exceptions import ValidationError
3
+ from django.core.validators import validate_email
4
+
5
+ class EmailListField(models.CharField):
6
+ """
7
+ Custom field that accepts a list of emails or a string with comma-separated emails.
8
+ Stores them as a SEPARATOR-separated string in the database.
9
+ """
10
+
11
+ SEPARATOR = "," # Separator constant for email list
12
+
13
+ def __init__(self, *args, **kwargs):
14
+ # Set a default max_length if not specified
15
+ if 'max_length' not in kwargs:
16
+ kwargs['max_length'] = 1000
17
+ super().__init__(*args, **kwargs)
18
+
19
+ def deconstruct(self):
20
+ """Required for Django migrations"""
21
+ name, path, args, kwargs = super().deconstruct()
22
+ return name, path, args, kwargs
23
+
24
+ def to_python(self, value):
25
+ """Converts the value from the database to Python"""
26
+ if value is None:
27
+ return value
28
+
29
+ if isinstance(value, list):
30
+ # If it comes as a list, validate each email and convert to string
31
+ validated_emails = []
32
+ for email in value:
33
+ email = str(email).strip()
34
+ if email:
35
+ try:
36
+ validate_email(email)
37
+ validated_emails.append(email)
38
+ except ValidationError:
39
+ raise ValidationError(f"'{email}' is not a valid email")
40
+ return self.SEPARATOR.join(validated_emails)
41
+
42
+ # If it's already a string, return as is
43
+ return super().to_python(value)
44
+
45
+ def get_prep_value(self, value):
46
+ """Prepares the value to be saved to the database"""
47
+ if value is None:
48
+ return value
49
+
50
+ if isinstance(value, list):
51
+ # If it comes as a list, convert to string
52
+ validated_emails = []
53
+ for email in value:
54
+ email = str(email).strip()
55
+ if email:
56
+ validated_emails.append(email)
57
+ return self.SEPARATOR.join(validated_emails)
58
+
59
+ return super().get_prep_value(value)
60
+
61
+ def validate(self, value, model_instance):
62
+ """Custom field validation"""
63
+ # Call parent validation first
64
+ super().validate(value, model_instance)
65
+
66
+ if value:
67
+ # If the value is a list, it was already validated in to_python
68
+ if isinstance(value, str):
69
+ # Validate each email in the string
70
+ emails = [email.strip() for email in value.split(self.SEPARATOR)]
71
+ for email in emails:
72
+ if email: # Avoid empty strings
73
+ try:
74
+ validate_email(email)
75
+ except ValidationError:
76
+ raise ValidationError(f"'{email}' is not a valid email")
77
+
78
+ def get_emails_list(self, value):
79
+ """Helper method to get the list of emails from the stored value"""
80
+ if not value:
81
+ return []
82
+ return [email.strip() for email in value.split(self.SEPARATOR.strip()) if email.strip()]
@@ -0,0 +1,3 @@
1
+ from .base_filter import BaseFilter
2
+ from .custom_ordering_filter import CustomOrderingFilter
3
+ from .filter_utils_mixin import FilterUtilsMixin
@@ -0,0 +1,133 @@
1
+ from .filter_utils_mixin import FilterUtilsMixin
2
+ from ..utils import safe_str_to_number
3
+ from django.db.models import Q
4
+ from django_filters import rest_framework as filters
5
+
6
+
7
+ class BaseFilter(FilterUtilsMixin, filters.FilterSet):
8
+ def parse_list_values(self, value_str):
9
+ """
10
+ Parses a string with values separated by && and returns a list.
11
+ Automatically converts to numbers if possible.
12
+ """
13
+ if self.list_separator not in value_str:
14
+ return None
15
+
16
+ values = []
17
+ for val in value_str.split(self.list_separator):
18
+ val = val.strip()
19
+ if self.is_numeric(val):
20
+ values.append(safe_str_to_number(val))
21
+ elif self.is_boolean(val):
22
+ values.append(str(val).lower() == 'true')
23
+ else:
24
+ values.append(val)
25
+ return values
26
+
27
+ def filter_field(self, queryset, filter_name, filter_op, filter_value):
28
+ """
29
+ Generic method for filtering based on dynamic comparisons.
30
+ """
31
+ if filter_op is None:
32
+ return queryset
33
+
34
+ is_exclude_filter = filter_name.endswith('__not_in')
35
+
36
+ if is_exclude_filter:
37
+ # Remove the _not_in suffix to get the actual field name
38
+ base_field_name = filter_name[:-8] # Remove '__not_in'
39
+
40
+ # Parse the values in the list
41
+ list_values = self.parse_list_values(filter_value)
42
+
43
+ if list_values:
44
+ # Apply exclusion with __in
45
+ return queryset.exclude(**{f"{base_field_name}__in": list_values})
46
+ else:
47
+ # If not a list, apply simple exclusion
48
+ value_to_filter = (
49
+ safe_str_to_number(filter_value)
50
+ if self.is_numeric(filter_value)
51
+ else filter_value
52
+ )
53
+ return queryset.exclude(**{f"{base_field_name}{filter_op}": value_to_filter})
54
+
55
+ value_to_filter = (
56
+ safe_str_to_number(filter_value)) \
57
+ if filter_op in ['__gte', '__lte', '__gt', '__lt', ''] and self.is_numeric(filter_value) \
58
+ else filter_value
59
+
60
+ if self.is_boolean(value_to_filter):
61
+ value_to_filter = str(value_to_filter).lower() == 'true'
62
+
63
+ return queryset.filter(**{f"{filter_name}{filter_op}": value_to_filter})
64
+
65
+ def apply_or_filters(self, queryset, or_filters):
66
+ or_filter_subquery = Q()
67
+ active_filters = self.get_filters()
68
+ for or_filter in or_filters:
69
+ filter_name, filter_op, filter_value = self.parse_filter(or_filter)
70
+ if filter_name in active_filters:
71
+ filter_name = active_filters[filter_name].field_name
72
+ or_filter_subquery |= Q(**{f"{filter_name}{filter_op}": filter_value})
73
+ queryset = queryset.filter(or_filter_subquery)
74
+ return queryset
75
+
76
+ def filter_queryset(self, queryset):
77
+ """
78
+ Overrides the `filter_queryset` method to apply filters dynamically.
79
+ Invoked when Django applies filters to the query.
80
+
81
+ Examples of possible filters:
82
+ - filter[]=field1=@value1,field2>value2 (OR filter)
83
+
84
+ - filter=field1=@value1,field2>value2 (OR filter)
85
+
86
+ - filter[]=field1=@value1&filter[]=field2>value2 (AND filter)
87
+
88
+ - filter==field1@@value
89
+
90
+ - filter[]=field1_not_in==10&&20&&30 (Exclude list)
91
+
92
+ - filter[]=field1=@value1,field2=@value2&filter[]=field3=@value3 (OR then AND filter)
93
+ Generates: ...WHERE (field1 LIKE '%value1%' OR field2 LIKE '%value2%') AND field3 LIKE '%value3%'
94
+ """
95
+ and_filter_key = 'and'
96
+ or_filter_key = 'or'
97
+
98
+ filter_without_brackets = self.data.get(self.filter_param, None)
99
+ filter_with_brackets = self.data.getlist(self.filters_param, [])
100
+
101
+ filter_category = {
102
+ or_filter_key: filter_without_brackets.split(self.filters_separator) if filter_without_brackets else [],
103
+ and_filter_key: []
104
+ }
105
+
106
+ for item in filter_with_brackets:
107
+ if "," in item:
108
+ filter_category[or_filter_key].extend(item.split(self.filters_separator))
109
+ else:
110
+ filter_category[and_filter_key].append(item)
111
+
112
+ # Apply dynamic filters
113
+ if filter_category[or_filter_key]:
114
+ queryset = self.apply_or_filters(queryset, filter_category[or_filter_key])
115
+
116
+ if not filter_category[and_filter_key]:
117
+ return queryset
118
+
119
+ # Get filters defined in the FilterSet
120
+ active_filters = self.get_filters()
121
+
122
+ for and_filter in filter_category[and_filter_key]:
123
+ filter_name, filter_op, filter_value = self.parse_filter(and_filter)
124
+ if filter_name in active_filters:
125
+ filter_instance = active_filters[filter_name]
126
+
127
+ if filter_instance.method == 'epoch_to_datetime':
128
+ queryset = self.filter_by_epoch_timestamp(queryset, filter_instance.field_name, filter_op, filter_value)
129
+ continue
130
+
131
+ queryset = self.filter_field(queryset, filter_instance.field_name, filter_op, filter_value)
132
+
133
+ return queryset
@@ -0,0 +1,16 @@
1
+ from rest_framework.filters import OrderingFilter
2
+
3
+
4
+ class CustomOrderingFilter(OrderingFilter):
5
+ def get_ordering(self, request, queryset, view):
6
+ ordering = super().get_ordering(request, queryset, view)
7
+ if ordering:
8
+ ordering_map = getattr(view, 'ordering_fields', {})
9
+
10
+ mapped_ordering = []
11
+ for field in ordering:
12
+ desc = '-' if field.startswith('-') else ''
13
+ clean_field = field.lstrip('-')
14
+ mapped_ordering.append(desc + ordering_map.get(clean_field, clean_field))
15
+ return mapped_ordering
16
+ return ordering
@@ -0,0 +1,57 @@
1
+ import re
2
+ from datetime import datetime, timezone
3
+ from rest_framework.exceptions import ValidationError
4
+
5
+ class FilterUtilsMixin:
6
+ """Mixin with utilities for filtering and converting values."""
7
+
8
+ filter_op = '='
9
+ filters_separator = ','
10
+ name_operator_separator = '__'
11
+ filter_param = 'filter'
12
+ filters_param = 'filter[]'
13
+ list_separator = '&&'
14
+
15
+ operator_map = {
16
+ '>=': "__gte",
17
+ '<=': "__lte",
18
+ '>': "__gt",
19
+ '<': "__lt",
20
+ '==': "",
21
+ '=@': "__icontains",
22
+ '@@': "__istartswith"
23
+ }
24
+
25
+ def __init__(self, *args, **kwargs):
26
+ super().__init__(*args, **kwargs)
27
+ self.is_number_re = re.compile(r'^-?\d+(\.\d+)?([eE][-+]?\d+)?$')
28
+ self.split_pattern = '|'.join(map(re.escape, self.operator_map.keys()))
29
+
30
+ def is_numeric(self, value):
31
+ return bool(self.is_number_re.match(str(value)))
32
+
33
+ def is_boolean(self, value):
34
+ return str(value).lower() in ['true', 'false']
35
+
36
+ def epoch_to_datetime(self, value):
37
+ try:
38
+ return datetime.fromtimestamp(int(value), tz=timezone.utc)
39
+ except (ValueError, TypeError):
40
+ return None
41
+
42
+ def filter_by_epoch_timestamp(self, queryset, name, op, value):
43
+ dt = self.epoch_to_datetime(value)
44
+ if dt is not None:
45
+ return queryset.filter(**{f"{name}{op}": dt})
46
+ else:
47
+ return queryset.none()
48
+
49
+ def parse_filter(self, filter_str):
50
+ try:
51
+ parts = re.split(f"({self.split_pattern})", filter_str)
52
+ field_name = parts[0].strip()
53
+ operator = self.operator_map.get(parts[1].strip())
54
+ value = parts[2].strip()
55
+ except ValueError:
56
+ raise ValidationError("Invalid filter format. Expected 'key=value'.")
57
+ return field_name, operator, value
@@ -0,0 +1 @@
1
+ from .base_event_handler import BaseEventHandler
@@ -0,0 +1,14 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from ..domain import EventMessage
4
+
5
+
6
+ class BaseEventHandler(ABC):
7
+
8
+ @abstractmethod
9
+ def can_handle(self, event_type: str) -> bool:
10
+ pass
11
+
12
+ @abstractmethod
13
+ def handle(self, event: EventMessage) -> bool:
14
+ pass
@@ -0,0 +1 @@
1
+ from .container import create_container, get_container, set_container
@@ -0,0 +1,14 @@
1
+ from injector import Module, Injector
2
+ from typing import List, Optional
3
+
4
+ def create_container(modules: List[Module]) -> Injector:
5
+ return Injector(modules)
6
+
7
+ _container: Optional[Injector] = None
8
+
9
+ def get_container() -> Optional[Injector]:
10
+ return _container
11
+
12
+ def set_container(container: Injector):
13
+ global _container
14
+ _container = container
@@ -0,0 +1 @@
1
+ from .item_image import ItemImage