base-api-utils 1.0.6__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.0.6/LICENSE +21 -0
- base_api_utils-1.0.6/PKG-INFO +62 -0
- base_api_utils-1.0.6/README.md +44 -0
- base_api_utils-1.0.6/pyproject.toml +32 -0
- base_api_utils-1.0.6/setup.cfg +4 -0
- base_api_utils-1.0.6/src/base_api_utils/__init__.py +0 -0
- base_api_utils-1.0.6/src/base_api_utils/filters/__init__.py +1 -0
- base_api_utils-1.0.6/src/base_api_utils/filters/base_filter.py +115 -0
- base_api_utils-1.0.6/src/base_api_utils/security/__init__.py +3 -0
- base_api_utils-1.0.6/src/base_api_utils/security/abstract_access_token_service.py +11 -0
- base_api_utils-1.0.6/src/base_api_utils/security/abstract_oauth2_api_client.py +97 -0
- base_api_utils-1.0.6/src/base_api_utils/security/access_token_service.py +64 -0
- base_api_utils-1.0.6/src/base_api_utils/security/group_required.py +71 -0
- base_api_utils-1.0.6/src/base_api_utils/security/oauth2_authentication.py +35 -0
- base_api_utils-1.0.6/src/base_api_utils/security/oauth2_client_factory.py +42 -0
- base_api_utils-1.0.6/src/base_api_utils/security/oauth2_scope_required.py +75 -0
- base_api_utils-1.0.6/src/base_api_utils/security/shared.py +13 -0
- base_api_utils-1.0.6/src/base_api_utils/serializers/__init__.py +1 -0
- base_api_utils-1.0.6/src/base_api_utils/serializers/base_model_serializer.py +96 -0
- base_api_utils-1.0.6/src/base_api_utils/utils/__init__.py +5 -0
- base_api_utils-1.0.6/src/base_api_utils/utils/config.py +18 -0
- base_api_utils-1.0.6/src/base_api_utils/utils/exceptions.py +32 -0
- base_api_utils-1.0.6/src/base_api_utils/utils/file_lock.py +98 -0
- base_api_utils-1.0.6/src/base_api_utils/utils/pagination.py +43 -0
- base_api_utils-1.0.6/src/base_api_utils/utils/string.py +15 -0
- base_api_utils-1.0.6/src/base_api_utils.egg-info/PKG-INFO +62 -0
- base_api_utils-1.0.6/src/base_api_utils.egg-info/SOURCES.txt +28 -0
- base_api_utils-1.0.6/src/base_api_utils.egg-info/dependency_links.txt +1 -0
- base_api_utils-1.0.6/src/base_api_utils.egg-info/requires.txt +6 -0
- base_api_utils-1.0.6/src/base_api_utils.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 FNTECH
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: base-api-utils
|
|
3
|
+
Version: 1.0.6
|
|
4
|
+
Summary: Django Rest Framework micro services common utilities
|
|
5
|
+
Author-email: Román Gutierrez <roman@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: djangorestframework>=3.15.2
|
|
13
|
+
Requires-Dist: requests-oauthlib
|
|
14
|
+
Requires-Dist: requests
|
|
15
|
+
Requires-Dist: oauthlib
|
|
16
|
+
Requires-Dist: django_filters
|
|
17
|
+
Requires-Dist: django
|
|
18
|
+
|
|
19
|
+
# base-api-utils
|
|
20
|
+
DRF common utilities
|
|
21
|
+
|
|
22
|
+
## Virtual Env
|
|
23
|
+
|
|
24
|
+
````bash
|
|
25
|
+
$ python3 -m venv env
|
|
26
|
+
|
|
27
|
+
$ source env/bin/activate
|
|
28
|
+
````
|
|
29
|
+
|
|
30
|
+
## python setup
|
|
31
|
+
|
|
32
|
+
````bash
|
|
33
|
+
sudo add-apt-repository ppa:deadsnakes/ppa -y
|
|
34
|
+
sudo apt-get -y -f install python3.7 python3-pip python3.7-dev python3.7-venv libpython3.7-dev python3-setuptools
|
|
35
|
+
sudo -H pip3 --default-timeout=50 install --upgrade pip
|
|
36
|
+
sudo -H pip3 install virtualenv
|
|
37
|
+
````
|
|
38
|
+
|
|
39
|
+
## Install reqs
|
|
40
|
+
|
|
41
|
+
````
|
|
42
|
+
pip install -r requirements.txt
|
|
43
|
+
````
|
|
44
|
+
|
|
45
|
+
## Packaging
|
|
46
|
+
|
|
47
|
+
python3 -m pip install --upgrade build
|
|
48
|
+
python3 -m build
|
|
49
|
+
|
|
50
|
+
## Uploading ( Test Py Pi)
|
|
51
|
+
|
|
52
|
+
python3 -m pip install --upgrade twine
|
|
53
|
+
python3 -m twine upload --repository testpypi dist/*
|
|
54
|
+
|
|
55
|
+
## Publish to PyPI
|
|
56
|
+
twine upload dist/*
|
|
57
|
+
|
|
58
|
+
## Install locally
|
|
59
|
+
pip install -i https://test.pypi.org/simple/ base-api-utils --no-deps
|
|
60
|
+
|
|
61
|
+
## Install from GitHub
|
|
62
|
+
pip install git+https://github.com/fntechgit/base-api-utils
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
python3 -m pip install --upgrade build
|
|
30
|
+
python3 -m build
|
|
31
|
+
|
|
32
|
+
## Uploading ( Test Py Pi)
|
|
33
|
+
|
|
34
|
+
python3 -m pip install --upgrade twine
|
|
35
|
+
python3 -m twine upload --repository testpypi dist/*
|
|
36
|
+
|
|
37
|
+
## Publish to PyPI
|
|
38
|
+
twine upload dist/*
|
|
39
|
+
|
|
40
|
+
## Install locally
|
|
41
|
+
pip install -i https://test.pypi.org/simple/ base-api-utils --no-deps
|
|
42
|
+
|
|
43
|
+
## Install from GitHub
|
|
44
|
+
pip install git+https://github.com/fntechgit/base-api-utils
|
|
@@ -0,0 +1,32 @@
|
|
|
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.0.6"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"djangorestframework>=3.15.2",
|
|
13
|
+
"requests-oauthlib",
|
|
14
|
+
"requests",
|
|
15
|
+
"oauthlib",
|
|
16
|
+
"django_filters",
|
|
17
|
+
"django"
|
|
18
|
+
]
|
|
19
|
+
authors = [
|
|
20
|
+
{ name="Román Gutierrez", email="roman@tipit.net" },
|
|
21
|
+
]
|
|
22
|
+
description = "Django Rest Framework micro services common utilities"
|
|
23
|
+
readme = "README.md"
|
|
24
|
+
requires-python = ">=3.10"
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Operating System :: OS Independent",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/fntechgit/base-api-utils"
|
|
32
|
+
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .base_filter import BaseFilter
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from django.db.models import Q
|
|
4
|
+
from django_filters import rest_framework as filters
|
|
5
|
+
from rest_framework.exceptions import ValidationError
|
|
6
|
+
|
|
7
|
+
from utils import safe_str_to_number
|
|
8
|
+
|
|
9
|
+
class BaseFilter(filters.FilterSet):
|
|
10
|
+
filter_op = '='
|
|
11
|
+
filters_separator = ','
|
|
12
|
+
name_operator_separator = '__'
|
|
13
|
+
filter_param = 'filter'
|
|
14
|
+
filters_param = 'filter[]'
|
|
15
|
+
|
|
16
|
+
operator_map = {
|
|
17
|
+
'>=': "__gte",
|
|
18
|
+
'<=': "__lte",
|
|
19
|
+
'>': "__gt",
|
|
20
|
+
'<': "__lt",
|
|
21
|
+
'==': "",
|
|
22
|
+
'=@': "__icontains",
|
|
23
|
+
'@@': "__istartswith"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def __init__(self, data=None, queryset=None, *, request=None, prefix=None):
|
|
27
|
+
super().__init__(data=data, queryset=queryset, request=request, prefix=prefix)
|
|
28
|
+
self.split_pattern = '|'.join(map(re.escape, self.operator_map.keys()))
|
|
29
|
+
self.is_number_re = re.compile(r'^-?\d+(\.\d+)?([eE][-+]?\d+)?$')
|
|
30
|
+
|
|
31
|
+
def is_numeric(self, value):
|
|
32
|
+
return bool(self.is_number_re.match(value))
|
|
33
|
+
|
|
34
|
+
def parse_filter(self, filter_str):
|
|
35
|
+
try:
|
|
36
|
+
parts = re.split(f"({self.split_pattern})", filter_str)
|
|
37
|
+
field_name = parts[0]
|
|
38
|
+
operator = self.operator_map.get(parts[1].strip())
|
|
39
|
+
value = parts[2]
|
|
40
|
+
except ValueError:
|
|
41
|
+
raise ValidationError("Invalid filter format. Expected 'key=value'.")
|
|
42
|
+
return field_name.strip(), operator, value.strip()
|
|
43
|
+
|
|
44
|
+
def filter_field(self, queryset, filter_name, filter_op, filter_value):
|
|
45
|
+
"""
|
|
46
|
+
Generic method for filtering based on dynamic comparisons.
|
|
47
|
+
"""
|
|
48
|
+
if not filter_op is None:
|
|
49
|
+
value_to_filter = (
|
|
50
|
+
safe_str_to_number(filter_value)) \
|
|
51
|
+
if filter_op in ['__gte', '__lte', '__gt', '__lt', ''] and self.is_numeric(filter_value) \
|
|
52
|
+
else filter_value
|
|
53
|
+
|
|
54
|
+
return queryset.filter(**{f"{filter_name}{filter_op}": value_to_filter})
|
|
55
|
+
|
|
56
|
+
return queryset
|
|
57
|
+
|
|
58
|
+
def apply_or_filters(self, queryset, or_filters):
|
|
59
|
+
or_filter_subquery = Q()
|
|
60
|
+
for or_filter in or_filters:
|
|
61
|
+
filter_name, filter_op, filter_value = self.parse_filter(or_filter)
|
|
62
|
+
or_filter_subquery |= Q(**{f"{filter_name}{filter_op}": filter_value})
|
|
63
|
+
queryset = queryset.filter(or_filter_subquery)
|
|
64
|
+
return queryset
|
|
65
|
+
|
|
66
|
+
def filter_queryset(self, queryset):
|
|
67
|
+
"""
|
|
68
|
+
Overrides the `filter_queryset` method to apply filters dynamically.
|
|
69
|
+
Invoked when Django applies filters to the query.
|
|
70
|
+
|
|
71
|
+
Examples of possible filters:
|
|
72
|
+
- filter[]=field1=@value1,field2>value2 (OR filter)
|
|
73
|
+
|
|
74
|
+
- filter=field1=@value1,field2>value2 (OR filter)
|
|
75
|
+
|
|
76
|
+
- filter[]=field1=@value1&filter[]=field2>value2 (AND filter)
|
|
77
|
+
|
|
78
|
+
- filter==field1@@value
|
|
79
|
+
|
|
80
|
+
- filter[]=field1=@value1,field2=@value2&filter[]=field3=@value3 (OR then AND filter)
|
|
81
|
+
Generates: ...WHERE (field1 LIKE '%value1%' OR field2 LIKE '%value2%') AND field3 LIKE '%value3%'
|
|
82
|
+
"""
|
|
83
|
+
and_filter_key = 'and'
|
|
84
|
+
or_filter_key = 'or'
|
|
85
|
+
|
|
86
|
+
filter_without_brackets = self.data.get(self.filter_param, None)
|
|
87
|
+
filter_with_brackets = self.data.getlist(self.filters_param, [])
|
|
88
|
+
|
|
89
|
+
filter_category = {
|
|
90
|
+
or_filter_key: filter_without_brackets.split(self.filters_separator) if filter_without_brackets else [],
|
|
91
|
+
and_filter_key: []
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for item in filter_with_brackets:
|
|
95
|
+
if "," in item:
|
|
96
|
+
filter_category[or_filter_key].extend(item.split(self.filters_separator))
|
|
97
|
+
else:
|
|
98
|
+
filter_category[and_filter_key].append(item)
|
|
99
|
+
|
|
100
|
+
# Apply dynamic filters
|
|
101
|
+
if filter_category[or_filter_key]:
|
|
102
|
+
queryset = self.apply_or_filters(queryset, filter_category[or_filter_key])
|
|
103
|
+
|
|
104
|
+
if not filter_category[and_filter_key]:
|
|
105
|
+
return queryset
|
|
106
|
+
|
|
107
|
+
# Get filters defined in the FilterSet
|
|
108
|
+
active_filters = self.get_filters()
|
|
109
|
+
|
|
110
|
+
for and_filter in filter_category[and_filter_key]:
|
|
111
|
+
filter_name, filter_op, filter_value = self.parse_filter(and_filter)
|
|
112
|
+
if filter_name in active_filters:
|
|
113
|
+
queryset = self.filter_field(queryset, filter_name, filter_op, filter_value)
|
|
114
|
+
|
|
115
|
+
return queryset
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from abc import abstractmethod
|
|
3
|
+
|
|
4
|
+
from django.core.cache import cache
|
|
5
|
+
from requests_oauthlib import OAuth2Session
|
|
6
|
+
from requests.exceptions import RequestException
|
|
7
|
+
from rest_framework.status import HTTP_404_NOT_FOUND, HTTP_401_UNAUTHORIZED
|
|
8
|
+
|
|
9
|
+
from security.oauth2_client_factory import OAuth2ClientFactory
|
|
10
|
+
from utils import config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AbstractOAuth2APIClient:
|
|
14
|
+
MAX_RETRIES = 3
|
|
15
|
+
SKEW_TIME = 120
|
|
16
|
+
ACCESS_TOKEN_CACHE_KEY_TEMPLATE = "{}_OAUTH2_ACCESS_TOKEN"
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def get_app_name(self) -> str:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
def get_idp_config(self) -> dict:
|
|
23
|
+
return {
|
|
24
|
+
"authorization_endpoint": f'{config('OAUTH2.IDP.BASE_URL')}/{config('OAUTH2.IDP.AUTHORIZATION_ENDPOINT')}',
|
|
25
|
+
"token_endpoint": f'{config('OAUTH2.IDP.BASE_URL')}/{config('OAUTH2.IDP.TOKEN_ENDPOINT')}',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def get_app_config(self) -> dict:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
def get_idp_client(self) -> OAuth2Session:
|
|
33
|
+
return OAuth2ClientFactory.build(
|
|
34
|
+
self.get_idp_config(),
|
|
35
|
+
self.get_app_config()
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def get_access_token_cache_key(self) -> str:
|
|
39
|
+
return self.ACCESS_TOKEN_CACHE_KEY_TEMPLATE.format(self.get_app_name())
|
|
40
|
+
|
|
41
|
+
def invoke_with_retry(self, callback):
|
|
42
|
+
retries = 0
|
|
43
|
+
while retries < self.MAX_RETRIES:
|
|
44
|
+
try:
|
|
45
|
+
return callback(self)
|
|
46
|
+
except RequestException as ex:
|
|
47
|
+
logging.warning("Retrying due to RequestException...")
|
|
48
|
+
retries += 1
|
|
49
|
+
if retries >= self.MAX_RETRIES:
|
|
50
|
+
if ex.response and ex.response.status_code == HTTP_404_NOT_FOUND:
|
|
51
|
+
return None
|
|
52
|
+
raise
|
|
53
|
+
self.clean_access_token()
|
|
54
|
+
logging.warning(f"Retry attempt {retries}: {ex}")
|
|
55
|
+
except Exception as ex:
|
|
56
|
+
self.clean_access_token()
|
|
57
|
+
logging.error(f"Unhandled exception: {ex}")
|
|
58
|
+
raise
|
|
59
|
+
|
|
60
|
+
def get_access_token(self) -> str:
|
|
61
|
+
logging.debug("AbstractOAuth2Api::get_access_token")
|
|
62
|
+
cache_key = self.get_access_token_cache_key()
|
|
63
|
+
token = cache.get(cache_key)
|
|
64
|
+
|
|
65
|
+
if not token:
|
|
66
|
+
try:
|
|
67
|
+
logging.debug("AbstractOAuth2Api::get_access_token - Access token not found in cache, requesting new token...")
|
|
68
|
+
client = self.get_idp_client()
|
|
69
|
+
app_config = self.get_app_config()
|
|
70
|
+
client_secret = app_config.get('client_secret', '')
|
|
71
|
+
|
|
72
|
+
token_response = client.fetch_token(
|
|
73
|
+
token_url=f'{config('OAUTH2.IDP.BASE_URL')}/{config('OAUTH2.IDP.TOKEN_ENDPOINT')}',
|
|
74
|
+
client_secret=client_secret
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
token = token_response["access_token"]
|
|
78
|
+
expires_in = token_response.get("expires_in", 3600)
|
|
79
|
+
ttl = max(0, expires_in - self.SKEW_TIME)
|
|
80
|
+
|
|
81
|
+
if ttl > 0:
|
|
82
|
+
cache.set(cache_key, token, ttl)
|
|
83
|
+
|
|
84
|
+
logging.debug(f"AbstractOAuth2Api::get_access_token - New access token obtained: {token}, expires in {ttl} seconds.")
|
|
85
|
+
except RequestException as ex:
|
|
86
|
+
logging.warning(f"RequestException: {ex}")
|
|
87
|
+
if ex.response and ex.response.status_code == HTTP_401_UNAUTHORIZED:
|
|
88
|
+
cache.delete(cache_key)
|
|
89
|
+
raise
|
|
90
|
+
|
|
91
|
+
logging.debug(f"AbstractOAuth2Api::Returning cached access token: {token}")
|
|
92
|
+
return token
|
|
93
|
+
|
|
94
|
+
def clean_access_token(self):
|
|
95
|
+
cache_key = self.get_access_token_cache_key()
|
|
96
|
+
cache.delete(cache_key)
|
|
97
|
+
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
from django.contrib.auth.models import AnonymousUser
|
|
6
|
+
from django.core.cache import cache
|
|
7
|
+
|
|
8
|
+
from .abstract_access_token_service import AbstractAccessTokenService
|
|
9
|
+
from utils import config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AccessTokenService(AbstractAccessTokenService):
|
|
13
|
+
|
|
14
|
+
def validate(self, access_token:str):
|
|
15
|
+
"""
|
|
16
|
+
Authenticate the request, given the access token.
|
|
17
|
+
"""
|
|
18
|
+
logging.getLogger('oauth2').debug('AccessTokenService::validate trying to get {access_token} from cache ...'.format(access_token=access_token))
|
|
19
|
+
# try get access_token from DB and check if not expired
|
|
20
|
+
cached_token_info = cache.get(access_token)
|
|
21
|
+
|
|
22
|
+
if cached_token_info is None:
|
|
23
|
+
try:
|
|
24
|
+
logging.getLogger('oauth2').debug(
|
|
25
|
+
'AccessTokenService::validate {access_token} is not present on cache, trying to validate from instrospection endpoint'.format(access_token=access_token))
|
|
26
|
+
response = requests.post(
|
|
27
|
+
'{base_url}/{endpoint}'.format
|
|
28
|
+
(
|
|
29
|
+
base_url=config('OAUTH2.IDP.BASE_URL', None),
|
|
30
|
+
endpoint=config('OAUTH2.IDP.INTROSPECTION_ENDPOINT', None)
|
|
31
|
+
),
|
|
32
|
+
auth=(config('OAUTH2.CLIENT.ID', None), config('OAUTH2.CLIENT.SECRET', None),),
|
|
33
|
+
params={'token': access_token},
|
|
34
|
+
verify=False if config('DEBUG', False) else True,
|
|
35
|
+
allow_redirects=False
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if response.status_code == requests.codes.ok:
|
|
39
|
+
cached_token_info = response.json()
|
|
40
|
+
lifetime = config('OAUTH2.CLIENT.ACCESS_TOKEN_CACHE_LIFETIME', cached_token_info['expires_in'])
|
|
41
|
+
logging.getLogger('oauth2').debug(
|
|
42
|
+
'AccessTokenService::validate {access_token} storing on cache with lifetime {lifetime}'.format(
|
|
43
|
+
access_token=access_token, lifetime=lifetime))
|
|
44
|
+
cache.set(access_token, cached_token_info, timeout=int(lifetime))
|
|
45
|
+
logging.getLogger('oauth2').warning(
|
|
46
|
+
'http code {code} http content {content}'.format(code=response.status_code,
|
|
47
|
+
content=response.content))
|
|
48
|
+
return AnonymousUser, cached_token_info
|
|
49
|
+
|
|
50
|
+
logging.getLogger('oauth2').warning(
|
|
51
|
+
'AccessTokenService::validate http code {code} http content {content}'.format(code=response.status_code,
|
|
52
|
+
content=response.content))
|
|
53
|
+
return None
|
|
54
|
+
except requests.exceptions.RequestException as e:
|
|
55
|
+
logging.getLogger('oauth2').error(e)
|
|
56
|
+
return None
|
|
57
|
+
except:
|
|
58
|
+
logging.getLogger('oauth2').error(sys.exc_info())
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
logging.getLogger('oauth2').debug(
|
|
62
|
+
'AccessTokenService::validate {access_token} cache hit'.format(
|
|
63
|
+
access_token=access_token))
|
|
64
|
+
return AnonymousUser, cached_token_info
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from django.core.exceptions import PermissionDenied
|
|
6
|
+
from django.utils.functional import wraps
|
|
7
|
+
from django.utils.translation import gettext_lazy as _
|
|
8
|
+
|
|
9
|
+
from security.shared import get_path
|
|
10
|
+
from utils import config, is_empty
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def group_required():
|
|
14
|
+
"""
|
|
15
|
+
Decorator to make a view only accept particular groups:
|
|
16
|
+
"""
|
|
17
|
+
def decorator(func):
|
|
18
|
+
@wraps(func)
|
|
19
|
+
def inner(view, *args, **kwargs):
|
|
20
|
+
|
|
21
|
+
request = view.request
|
|
22
|
+
token_info = request.auth
|
|
23
|
+
|
|
24
|
+
# shortcircuit
|
|
25
|
+
if os.getenv("ENV") == 'test':
|
|
26
|
+
return func(view, *args, **kwargs)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger('oauth2')
|
|
29
|
+
|
|
30
|
+
path = get_path(request)
|
|
31
|
+
method = str(request.method).lower()
|
|
32
|
+
|
|
33
|
+
logger.debug(f'endpoint {method} {path}')
|
|
34
|
+
|
|
35
|
+
endpoints = config('OAUTH2.CLIENT.ENDPOINTS')
|
|
36
|
+
|
|
37
|
+
if token_info is None:
|
|
38
|
+
logger.warning('group_required::decorator token info not present')
|
|
39
|
+
raise PermissionDenied(_("token info not present."))
|
|
40
|
+
|
|
41
|
+
logger.debug(f'group_required::decorator token_info {json.dumps(token_info)}')
|
|
42
|
+
|
|
43
|
+
endpoint = endpoints[path] if path in endpoints else None
|
|
44
|
+
if not endpoint:
|
|
45
|
+
logger.warning('group_required::decorator endpoint info not present')
|
|
46
|
+
raise PermissionDenied(_("endpoint info not present."))
|
|
47
|
+
|
|
48
|
+
endpoint = endpoint[method] if method in endpoint else None
|
|
49
|
+
if not endpoint:
|
|
50
|
+
logger.warning('endpoint info not present')
|
|
51
|
+
raise PermissionDenied(_("endpoint info not present."))
|
|
52
|
+
|
|
53
|
+
required_groups = endpoint['groups'] if 'groups' in endpoint else None
|
|
54
|
+
|
|
55
|
+
if is_empty(required_groups):
|
|
56
|
+
return func(view, *args, **kwargs)
|
|
57
|
+
|
|
58
|
+
if 'user_groups' in token_info:
|
|
59
|
+
current_groups = token_info['user_groups']
|
|
60
|
+
logger.debug(f'current group {current_groups} required groups {required_groups}')
|
|
61
|
+
|
|
62
|
+
req_groups = set(required_groups.split())
|
|
63
|
+
user_groups = set([item['slug'] for item in current_groups])
|
|
64
|
+
|
|
65
|
+
if any(group in req_groups for group in user_groups):
|
|
66
|
+
return func(view, *args, **kwargs)
|
|
67
|
+
|
|
68
|
+
logger.warning('group_required::decorator token groups not present')
|
|
69
|
+
raise PermissionDenied(_("token groups not present"))
|
|
70
|
+
return inner
|
|
71
|
+
return decorator
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from rest_framework import exceptions
|
|
4
|
+
from rest_framework.authentication import get_authorization_header, BaseAuthentication
|
|
5
|
+
|
|
6
|
+
from security.access_token_service import AccessTokenService
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OAuth2Authentication(BaseAuthentication):
|
|
10
|
+
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self.service = AccessTokenService()
|
|
13
|
+
|
|
14
|
+
def authenticate(self, request):
|
|
15
|
+
|
|
16
|
+
auth = get_authorization_header(request).split()
|
|
17
|
+
|
|
18
|
+
if len(auth) == 1:
|
|
19
|
+
msg = 'Invalid bearer header. No credentials provided.'
|
|
20
|
+
raise exceptions.AuthenticationFailed(msg)
|
|
21
|
+
elif len(auth) > 2:
|
|
22
|
+
msg = 'Invalid bearer header. Token string should not contain spaces.'
|
|
23
|
+
raise exceptions.AuthenticationFailed(msg)
|
|
24
|
+
|
|
25
|
+
if auth and auth[0].lower() == b'bearer':
|
|
26
|
+
access_token = auth[1]
|
|
27
|
+
elif 'access_token' in request.POST:
|
|
28
|
+
access_token = request.POST['access_token']
|
|
29
|
+
elif 'access_token' in request.GET:
|
|
30
|
+
access_token = request.GET['access_token']
|
|
31
|
+
else:
|
|
32
|
+
return None
|
|
33
|
+
logging.getLogger('oauth2').warning(
|
|
34
|
+
'OAuth2Authentication::authenticate access_token {access_token}'.format(access_token=access_token))
|
|
35
|
+
return self.service.validate(access_token)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from oauthlib.oauth2 import BackendApplicationClient
|
|
4
|
+
from requests_oauthlib import OAuth2Session
|
|
5
|
+
from requests.adapters import HTTPAdapter
|
|
6
|
+
from urllib3 import Retry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OAuth2ClientFactory:
|
|
10
|
+
@staticmethod
|
|
11
|
+
def build(idp_config: dict, app_config: dict) -> OAuth2Session:
|
|
12
|
+
client_id = app_config.get('client_id', '')
|
|
13
|
+
client_secret = app_config.get('client_secret', '')
|
|
14
|
+
scopes = app_config.get('scopes', '').split()
|
|
15
|
+
|
|
16
|
+
client = BackendApplicationClient(client_id=client_id, scope=scopes)
|
|
17
|
+
|
|
18
|
+
# OAuth2 endpoint urls
|
|
19
|
+
authorization_endpoint = idp_config.get('authorization_endpoint', '')
|
|
20
|
+
token_endpoint = idp_config.get('token_endpoint', '')
|
|
21
|
+
|
|
22
|
+
session = OAuth2Session(client=client)
|
|
23
|
+
|
|
24
|
+
# Retries policy
|
|
25
|
+
retries = Retry(
|
|
26
|
+
total=3,
|
|
27
|
+
backoff_factor=1,
|
|
28
|
+
status_forcelist=[429, 500, 502, 503, 504]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Retries HTTP adapter
|
|
32
|
+
http_adapter = HTTPAdapter(max_retries=retries)
|
|
33
|
+
session.mount('https://', http_adapter)
|
|
34
|
+
session.mount('http://', http_adapter)
|
|
35
|
+
|
|
36
|
+
# Auth and endpoints settings
|
|
37
|
+
session.auth = (client_id, client_secret)
|
|
38
|
+
session.authorization_url = authorization_endpoint
|
|
39
|
+
session.token_url = token_endpoint
|
|
40
|
+
|
|
41
|
+
logging.info("OAuth2ClientFactory - OAuth2Session created successfully with client_id: %s", client_id)
|
|
42
|
+
return session
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from django.core.exceptions import PermissionDenied
|
|
6
|
+
from django.utils.functional import wraps
|
|
7
|
+
from django.utils.translation import gettext_lazy as _
|
|
8
|
+
|
|
9
|
+
from security.shared import get_path
|
|
10
|
+
from utils import config, is_empty
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def oauth2_scope_required():
|
|
14
|
+
"""
|
|
15
|
+
Decorator to make a view only accept particular scopes:
|
|
16
|
+
"""
|
|
17
|
+
def decorator(func):
|
|
18
|
+
@wraps(func)
|
|
19
|
+
def inner(view, *args, **kwargs):
|
|
20
|
+
|
|
21
|
+
request = view.request
|
|
22
|
+
token_info = request.auth
|
|
23
|
+
|
|
24
|
+
# shortcircuit
|
|
25
|
+
if os.getenv("ENV") == 'test':
|
|
26
|
+
return func(view, token_info = {}, *args, **kwargs)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger('oauth2')
|
|
29
|
+
|
|
30
|
+
path = get_path(request)
|
|
31
|
+
method = str(request.method).lower()
|
|
32
|
+
|
|
33
|
+
logger.debug('endpoint {method} {path}'.format(method=method, path=path))
|
|
34
|
+
|
|
35
|
+
endpoints = config('OAUTH2.CLIENT.ENDPOINTS')
|
|
36
|
+
|
|
37
|
+
if token_info is None:
|
|
38
|
+
logging.getLogger('oauth2').warning(
|
|
39
|
+
'oauth2_scope_required::decorator token info not present')
|
|
40
|
+
raise PermissionDenied(_("token info not present."))
|
|
41
|
+
|
|
42
|
+
logger.debug('oauth2_scope_required::decorator token_info {}'.format(
|
|
43
|
+
json.dumps(token_info)
|
|
44
|
+
))
|
|
45
|
+
|
|
46
|
+
endpoint = endpoints[path] if path in endpoints else None
|
|
47
|
+
if not endpoint:
|
|
48
|
+
logger.warning('oauth2_scope_required::decorator endpoint info not present')
|
|
49
|
+
raise PermissionDenied(_("endpoint info not present."))
|
|
50
|
+
|
|
51
|
+
endpoint = endpoint[method] if method in endpoint else None
|
|
52
|
+
if not endpoint:
|
|
53
|
+
logger.warning('endpoint info not present')
|
|
54
|
+
raise PermissionDenied(_("endpoint info not present."))
|
|
55
|
+
|
|
56
|
+
required_scope = endpoint['scopes'] if 'scopes' in endpoint else None
|
|
57
|
+
|
|
58
|
+
if is_empty(required_scope):
|
|
59
|
+
logger.warning('require scope is empty')
|
|
60
|
+
raise PermissionDenied(_("required scope not present."))
|
|
61
|
+
|
|
62
|
+
if 'scope' in token_info:
|
|
63
|
+
current_scope = token_info['scope']
|
|
64
|
+
|
|
65
|
+
logger.debug(f'current scope {current_scope} required scope {required_scope}')
|
|
66
|
+
# check origins
|
|
67
|
+
# check scopes
|
|
68
|
+
|
|
69
|
+
if len(set.intersection(set(required_scope.split()), set(current_scope.split()))):
|
|
70
|
+
return func(view, token_info=token_info, *args, **kwargs)
|
|
71
|
+
|
|
72
|
+
logger.warning('oauth2_scope_required::decorator token scopes not present')
|
|
73
|
+
raise PermissionDenied(_("token scopes not present"))
|
|
74
|
+
return inner
|
|
75
|
+
return decorator
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from django.contrib.admindocs.views import simplify_regex
|
|
4
|
+
|
|
5
|
+
_PATH_PARAMETER_COMPONENT_RE = re.compile(
|
|
6
|
+
r'<(?:(?P<converter>[^>:]+):)?(?P<parameter>\w+)>'
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
def get_path(request) -> str:
|
|
10
|
+
path = simplify_regex(request.resolver_match.route)
|
|
11
|
+
# Strip Django 2.0 convertors as they are incompatible with uritemplate format
|
|
12
|
+
path = re.sub(_PATH_PARAMETER_COMPONENT_RE, r'{\g<parameter>}', path)
|
|
13
|
+
return path.replace('{pk}', '{id}')
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .base_model_serializer import BaseModelSerializer
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from rest_framework import serializers
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BaseModelSerializer(serializers.ModelSerializer):
|
|
5
|
+
PARENT_FIELD_KEY = 'parent_field'
|
|
6
|
+
|
|
7
|
+
def __init__(self, *args, **kwargs):
|
|
8
|
+
context = kwargs.get('context', {})
|
|
9
|
+
request = context.get('request', None)
|
|
10
|
+
fields = []
|
|
11
|
+
relations = []
|
|
12
|
+
|
|
13
|
+
if request:
|
|
14
|
+
fields = request.query_params.get('fields', '').split(',')
|
|
15
|
+
relations = request.query_params.get('relations', '').split(',')
|
|
16
|
+
|
|
17
|
+
kwargs.pop('expand', None)
|
|
18
|
+
kwargs.pop('fields', None)
|
|
19
|
+
kwargs.pop('relations', None)
|
|
20
|
+
kwargs.pop('params', None)
|
|
21
|
+
|
|
22
|
+
super().__init__(*args, **kwargs)
|
|
23
|
+
|
|
24
|
+
fields_requested = set(fields) if fields else set()
|
|
25
|
+
relations_requested = set(relations) if relations else set()
|
|
26
|
+
|
|
27
|
+
allowed_fields = set(self.get_allowed_fields())
|
|
28
|
+
allowed_relations = set(self.get_allowed_relations())
|
|
29
|
+
fields_requested &= allowed_fields
|
|
30
|
+
relations_requested &= allowed_relations
|
|
31
|
+
|
|
32
|
+
if fields_requested or relations_requested:
|
|
33
|
+
# due to how the DRF serialization mechanism works, both fields and relations are treated the same
|
|
34
|
+
allowed = fields_requested.union(relations_requested)
|
|
35
|
+
|
|
36
|
+
# gets the last key of each requested field (dot notation) to search in the list of fields in the current serializer
|
|
37
|
+
allowed = {item.split('.')[-1] for item in allowed}
|
|
38
|
+
|
|
39
|
+
for field_name in list(self.fields):
|
|
40
|
+
if field_name not in allowed:
|
|
41
|
+
# if the field wasn't specified in request it is removed from the list of fields to serialize
|
|
42
|
+
self.fields.pop(field_name)
|
|
43
|
+
|
|
44
|
+
def get_allowed_fields(self):
|
|
45
|
+
# required to know if the serializer was called explicitly from the view or
|
|
46
|
+
# if it is a delegation from a parent serializer (parent collection field)
|
|
47
|
+
# - view explicit call -> field_name
|
|
48
|
+
# - parent serializer delegation -> parent_field_name.field_name
|
|
49
|
+
parent_field = self.context.get(self.PARENT_FIELD_KEY, None)
|
|
50
|
+
|
|
51
|
+
if parent_field:
|
|
52
|
+
return [f"{parent_field}.{field}" for field in self.fields.keys()]
|
|
53
|
+
|
|
54
|
+
return self.fields.keys()
|
|
55
|
+
|
|
56
|
+
def get_delegated_allowed_relations(self, allowed_relations):
|
|
57
|
+
# required to know if the serializer was called explicitly from the view or
|
|
58
|
+
# if it is a delegation from a parent serializer (parent collection field)
|
|
59
|
+
# - view explicit call -> field_name
|
|
60
|
+
# - parent serializer delegation -> parent_field_name.field_name
|
|
61
|
+
parent_field = self.context.get(self.PARENT_FIELD_KEY, None)
|
|
62
|
+
|
|
63
|
+
if parent_field:
|
|
64
|
+
return [f"{parent_field}.{allowed_relation}" for allowed_relation in allowed_relations]
|
|
65
|
+
|
|
66
|
+
return allowed_relations
|
|
67
|
+
|
|
68
|
+
def get_allowed_relations(self):
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
def get_expand(self):
|
|
72
|
+
request = self.context.get('request', None)
|
|
73
|
+
str_expand = request.query_params.get('expand', '') if request else None
|
|
74
|
+
if not str_expand:
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
expand = str_expand.split(',')
|
|
78
|
+
parent_field = self.context.get(self.PARENT_FIELD_KEY, None)
|
|
79
|
+
|
|
80
|
+
if parent_field:
|
|
81
|
+
# current serializer related expand
|
|
82
|
+
pattern = f'{parent_field}.'
|
|
83
|
+
return [
|
|
84
|
+
field.split(pattern)[1]
|
|
85
|
+
for field in expand
|
|
86
|
+
if pattern in field
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
return expand
|
|
90
|
+
|
|
91
|
+
def get_validation_detail(self, code = 0):
|
|
92
|
+
errors = [f'{field}: {str(detail)}'
|
|
93
|
+
for field, details in self.errors.items()
|
|
94
|
+
for detail in details
|
|
95
|
+
]
|
|
96
|
+
return {'message': 'Validation Failed', 'errors': errors, 'code': code}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def config(name: str, default=None):
|
|
5
|
+
"""
|
|
6
|
+
Helper function to get a Django setting by name. If setting doesn't exists
|
|
7
|
+
it will return a default.
|
|
8
|
+
:param name: Name of setting
|
|
9
|
+
:type name: str
|
|
10
|
+
:param default: Value if setting is unfound
|
|
11
|
+
:returns: Setting's value
|
|
12
|
+
"""
|
|
13
|
+
vars = name.split('.')
|
|
14
|
+
entry = None
|
|
15
|
+
for v in vars:
|
|
16
|
+
entry = getattr(settings, v, default) if entry is None else (entry[v] if v in entry else None)
|
|
17
|
+
|
|
18
|
+
return entry
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import traceback
|
|
3
|
+
|
|
4
|
+
from rest_framework.response import Response
|
|
5
|
+
from rest_framework.views import exception_handler
|
|
6
|
+
from rest_framework import status
|
|
7
|
+
from rest_framework.exceptions import ValidationError, NotFound, ErrorDetail
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def custom_exception_handler(e, context):
|
|
11
|
+
if isinstance(e, NotFound):
|
|
12
|
+
return Response({'message': e.__str__()}, status=status.HTTP_404_NOT_FOUND)
|
|
13
|
+
|
|
14
|
+
if isinstance(e, ValidationError):
|
|
15
|
+
errors = [str(error) for error in e.detail]
|
|
16
|
+
return Response({'message': 'Validation Failed', 'errors': errors, 'code': 0},
|
|
17
|
+
status=status.HTTP_412_PRECONDITION_FAILED)
|
|
18
|
+
|
|
19
|
+
response = exception_handler(e, context)
|
|
20
|
+
|
|
21
|
+
if response is not None:
|
|
22
|
+
return response
|
|
23
|
+
|
|
24
|
+
logging.getLogger('api').error(e)
|
|
25
|
+
logging.getLogger('api').error(traceback.format_exc())
|
|
26
|
+
return Response({'message': 'server error'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class S3Exception(Exception):
|
|
30
|
+
def __init__(self, message):
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import errno
|
|
4
|
+
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.core.files import locks
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FileLock:
|
|
10
|
+
|
|
11
|
+
class LockFailedException(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
def __init__(self, cron_class, silent, *args, **kwargs):
|
|
15
|
+
"""
|
|
16
|
+
This method inits the class.
|
|
17
|
+
You should take care of getting all
|
|
18
|
+
nessesary thing from input parameters here
|
|
19
|
+
Base class processes
|
|
20
|
+
* self.job_name
|
|
21
|
+
* self.job_code
|
|
22
|
+
* self.parallel
|
|
23
|
+
* self.silent
|
|
24
|
+
for you. The rest is backend-specific.
|
|
25
|
+
"""
|
|
26
|
+
self.job_name = cron_class.code
|
|
27
|
+
self.job_code = cron_class.code
|
|
28
|
+
self.parallel = getattr(cron_class, 'ALLOW_PARALLEL_RUNS', False)
|
|
29
|
+
self.silent = silent
|
|
30
|
+
|
|
31
|
+
def lock(self):
|
|
32
|
+
try:
|
|
33
|
+
lock_name = self.get_lock_name()
|
|
34
|
+
# need loop to avoid races on file unlinking
|
|
35
|
+
while True:
|
|
36
|
+
f = open(lock_name, 'wb+', 0)
|
|
37
|
+
locks.lock(f, locks.LOCK_EX | locks.LOCK_NB)
|
|
38
|
+
# Here is the Race:
|
|
39
|
+
# Previous process "A" is still running. Process "B" opens
|
|
40
|
+
# the file and then the process "A" finishes and deletes it.
|
|
41
|
+
# "B" locks the deleted file (by fd it already have) and runs,
|
|
42
|
+
# then the next process "C" creates _new_ file and locks it
|
|
43
|
+
# successfully while "B" is still running.
|
|
44
|
+
# We just need to check that "B" didn't lock a deleted file
|
|
45
|
+
# to avoid any problems. If process "C" have locked
|
|
46
|
+
# a new file wile "B" stats it then ok, let "B" quit and "C"
|
|
47
|
+
# run. We can still meet an attacker that permanently
|
|
48
|
+
# creates and deletes our file but we can't avoid problems
|
|
49
|
+
# in that case.
|
|
50
|
+
if os.path.isfile(lock_name):
|
|
51
|
+
st1 = os.fstat(f.fileno())
|
|
52
|
+
st2 = os.stat(lock_name)
|
|
53
|
+
if st1.st_ino == st2.st_ino:
|
|
54
|
+
f.write(bytes(str(os.getpid()).encode('utf-8')))
|
|
55
|
+
self.lockfile = f
|
|
56
|
+
return True
|
|
57
|
+
# else:
|
|
58
|
+
# retry. Don't unlink, next process might already use it.
|
|
59
|
+
f.close()
|
|
60
|
+
|
|
61
|
+
except IOError as e:
|
|
62
|
+
if e.errno in (errno.EACCES, errno.EAGAIN):
|
|
63
|
+
return False
|
|
64
|
+
else:
|
|
65
|
+
e = sys.exc_info()[1]
|
|
66
|
+
raise e
|
|
67
|
+
# TODO: perhaps on windows I need to catch different exception type
|
|
68
|
+
|
|
69
|
+
def release(self):
|
|
70
|
+
f = self.lockfile
|
|
71
|
+
# unlink before release lock to avoid race
|
|
72
|
+
# see comment in self.lock for description
|
|
73
|
+
os.unlink(f.name)
|
|
74
|
+
f.close()
|
|
75
|
+
|
|
76
|
+
def get_lock_name(self):
|
|
77
|
+
default_path = '/tmp'
|
|
78
|
+
path = getattr(settings, 'DJANGO_CRON_LOCKFILE_PATH', default_path)
|
|
79
|
+
if not os.path.isdir(path):
|
|
80
|
+
# let it die if failed, can't run further anyway
|
|
81
|
+
os.makedirs(path)
|
|
82
|
+
|
|
83
|
+
filename = self.job_name + '.lock'
|
|
84
|
+
return os.path.join(path, filename)
|
|
85
|
+
|
|
86
|
+
def lock_failed_message(self):
|
|
87
|
+
return "%s: lock found. Will try later." % self.job_name
|
|
88
|
+
|
|
89
|
+
def __enter__(self):
|
|
90
|
+
if self.parallel:
|
|
91
|
+
return
|
|
92
|
+
else:
|
|
93
|
+
if not self.lock():
|
|
94
|
+
raise self.LockFailedException(self.lock_failed_message())
|
|
95
|
+
|
|
96
|
+
def __exit__(self, type, value, traceback):
|
|
97
|
+
if not self.parallel:
|
|
98
|
+
self.release()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from rest_framework.pagination import PageNumberPagination
|
|
2
|
+
from rest_framework.response import Response
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LargeResultsSetPagination(PageNumberPagination):
|
|
7
|
+
page_size = 10
|
|
8
|
+
page_size_query_param = 'per_page'
|
|
9
|
+
max_page_size = 100
|
|
10
|
+
|
|
11
|
+
def get_paginated_response(self, data):
|
|
12
|
+
return Response(OrderedDict([
|
|
13
|
+
('total', self.page.paginator.count),
|
|
14
|
+
('per_page', self.page.paginator.per_page),
|
|
15
|
+
('current_page', self.page.number),
|
|
16
|
+
('last_page', self.page.paginator.num_pages),
|
|
17
|
+
('data', data)
|
|
18
|
+
]))
|
|
19
|
+
|
|
20
|
+
def get_paginated_response_schema(self, schema):
|
|
21
|
+
return {
|
|
22
|
+
'type': 'object',
|
|
23
|
+
'properties': {
|
|
24
|
+
'total': {
|
|
25
|
+
'type': 'integer',
|
|
26
|
+
'example': 123,
|
|
27
|
+
},
|
|
28
|
+
'per_page': {
|
|
29
|
+
'type': 'integer',
|
|
30
|
+
'example': 123,
|
|
31
|
+
},
|
|
32
|
+
'current_page': {
|
|
33
|
+
'type': 'integer',
|
|
34
|
+
'example': 123,
|
|
35
|
+
},
|
|
36
|
+
'last_page': {
|
|
37
|
+
'type': 'integer',
|
|
38
|
+
'example': 123,
|
|
39
|
+
},
|
|
40
|
+
'data': schema,
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
def is_empty(str_val: str) -> bool:
|
|
2
|
+
return not str_val or not (str_val and str_val.strip())
|
|
3
|
+
|
|
4
|
+
def safe_str_to_number(value):
|
|
5
|
+
try:
|
|
6
|
+
return int(value)
|
|
7
|
+
except ValueError:
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
return float(value)
|
|
12
|
+
except ValueError:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
raise ValueError(f"'{value}' is not a valid number.")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: base-api-utils
|
|
3
|
+
Version: 1.0.6
|
|
4
|
+
Summary: Django Rest Framework micro services common utilities
|
|
5
|
+
Author-email: Román Gutierrez <roman@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: djangorestframework>=3.15.2
|
|
13
|
+
Requires-Dist: requests-oauthlib
|
|
14
|
+
Requires-Dist: requests
|
|
15
|
+
Requires-Dist: oauthlib
|
|
16
|
+
Requires-Dist: django_filters
|
|
17
|
+
Requires-Dist: django
|
|
18
|
+
|
|
19
|
+
# base-api-utils
|
|
20
|
+
DRF common utilities
|
|
21
|
+
|
|
22
|
+
## Virtual Env
|
|
23
|
+
|
|
24
|
+
````bash
|
|
25
|
+
$ python3 -m venv env
|
|
26
|
+
|
|
27
|
+
$ source env/bin/activate
|
|
28
|
+
````
|
|
29
|
+
|
|
30
|
+
## python setup
|
|
31
|
+
|
|
32
|
+
````bash
|
|
33
|
+
sudo add-apt-repository ppa:deadsnakes/ppa -y
|
|
34
|
+
sudo apt-get -y -f install python3.7 python3-pip python3.7-dev python3.7-venv libpython3.7-dev python3-setuptools
|
|
35
|
+
sudo -H pip3 --default-timeout=50 install --upgrade pip
|
|
36
|
+
sudo -H pip3 install virtualenv
|
|
37
|
+
````
|
|
38
|
+
|
|
39
|
+
## Install reqs
|
|
40
|
+
|
|
41
|
+
````
|
|
42
|
+
pip install -r requirements.txt
|
|
43
|
+
````
|
|
44
|
+
|
|
45
|
+
## Packaging
|
|
46
|
+
|
|
47
|
+
python3 -m pip install --upgrade build
|
|
48
|
+
python3 -m build
|
|
49
|
+
|
|
50
|
+
## Uploading ( Test Py Pi)
|
|
51
|
+
|
|
52
|
+
python3 -m pip install --upgrade twine
|
|
53
|
+
python3 -m twine upload --repository testpypi dist/*
|
|
54
|
+
|
|
55
|
+
## Publish to PyPI
|
|
56
|
+
twine upload dist/*
|
|
57
|
+
|
|
58
|
+
## Install locally
|
|
59
|
+
pip install -i https://test.pypi.org/simple/ base-api-utils --no-deps
|
|
60
|
+
|
|
61
|
+
## Install from GitHub
|
|
62
|
+
pip install git+https://github.com/fntechgit/base-api-utils
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/base_api_utils/__init__.py
|
|
5
|
+
src/base_api_utils.egg-info/PKG-INFO
|
|
6
|
+
src/base_api_utils.egg-info/SOURCES.txt
|
|
7
|
+
src/base_api_utils.egg-info/dependency_links.txt
|
|
8
|
+
src/base_api_utils.egg-info/requires.txt
|
|
9
|
+
src/base_api_utils.egg-info/top_level.txt
|
|
10
|
+
src/base_api_utils/filters/__init__.py
|
|
11
|
+
src/base_api_utils/filters/base_filter.py
|
|
12
|
+
src/base_api_utils/security/__init__.py
|
|
13
|
+
src/base_api_utils/security/abstract_access_token_service.py
|
|
14
|
+
src/base_api_utils/security/abstract_oauth2_api_client.py
|
|
15
|
+
src/base_api_utils/security/access_token_service.py
|
|
16
|
+
src/base_api_utils/security/group_required.py
|
|
17
|
+
src/base_api_utils/security/oauth2_authentication.py
|
|
18
|
+
src/base_api_utils/security/oauth2_client_factory.py
|
|
19
|
+
src/base_api_utils/security/oauth2_scope_required.py
|
|
20
|
+
src/base_api_utils/security/shared.py
|
|
21
|
+
src/base_api_utils/serializers/__init__.py
|
|
22
|
+
src/base_api_utils/serializers/base_model_serializer.py
|
|
23
|
+
src/base_api_utils/utils/__init__.py
|
|
24
|
+
src/base_api_utils/utils/config.py
|
|
25
|
+
src/base_api_utils/utils/exceptions.py
|
|
26
|
+
src/base_api_utils/utils/file_lock.py
|
|
27
|
+
src/base_api_utils/utils/pagination.py
|
|
28
|
+
src/base_api_utils/utils/string.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
base_api_utils
|