cardo-python-utils 0.5.dev12__tar.gz → 0.5.dev14__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.
- {cardo_python_utils-0.5.dev12/cardo_python_utils.egg-info → cardo_python_utils-0.5.dev14}/PKG-INFO +1 -1
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14/cardo_python_utils.egg-info}/PKG-INFO +1 -1
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/pyproject.toml +1 -1
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/choices.py +4 -3
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/django/keycloak/api/ninja.py +82 -15
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/django/keycloak/api/utils.py +48 -14
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/django/keycloak/service.py +89 -1
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/LICENSE +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/MANIFEST.in +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/README.rst +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/cardo_python_utils.egg-info/SOURCES.txt +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/cardo_python_utils.egg-info/dependency_links.txt +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/cardo_python_utils.egg-info/requires.txt +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/cardo_python_utils.egg-info/top_level.txt +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/__init__.py +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/data_structures.py +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/db.py +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/django/keycloak/__init__.py +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/django/keycloak/admin/__init__.py +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/django/keycloak/admin/auth.py +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/django/keycloak/admin/user_group.py +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/django/keycloak/admin/user_groups_changelist.html +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/django/keycloak/api/__init__.py +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/django/keycloak/api/drf.py +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/django/keycloak/models/__init__.py +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/django/keycloak/models/user_group.py +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/django/utils.py +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/esma_choices.py +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/exceptions.py +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/imports.py +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/math.py +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/text.py +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/time.py +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/types_hinting.py +0 -0
- {cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/setup.cfg +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cardo-python-utils"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.dev14"
|
|
8
8
|
description = "Python library enhanced with a wide range of functions for different scenarios."
|
|
9
9
|
readme = "README.rst"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -9,6 +9,7 @@ It adds some useful methods to the Enum class, such as:
|
|
|
9
9
|
|
|
10
10
|
from abc import ABC, abstractmethod
|
|
11
11
|
from enum import EnumMeta, Enum
|
|
12
|
+
from typing import Union
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class IChoice(ABC):
|
|
@@ -41,7 +42,7 @@ class ChoiceEnumMeta(EnumMeta, IChoice, ABC):
|
|
|
41
42
|
|
|
42
43
|
return new_cls
|
|
43
44
|
|
|
44
|
-
def __contains__(cls, item: int
|
|
45
|
+
def __contains__(cls, item: Union[int, str]) -> bool:
|
|
45
46
|
if isinstance(item, int):
|
|
46
47
|
member_values = [v.value[0] for v in cls.__members__.values()]
|
|
47
48
|
elif isinstance(item, str):
|
|
@@ -75,12 +76,12 @@ class ChoiceEnum(Enum, metaclass=ChoiceEnumMeta):
|
|
|
75
76
|
return {elm.value[0]: elm.value[1] for elm in cls}
|
|
76
77
|
|
|
77
78
|
@classmethod
|
|
78
|
-
def get_by_value(cls, value: str
|
|
79
|
+
def get_by_value(cls, value: Union[str, int]):
|
|
79
80
|
value_index = 0 if isinstance(value, int) else 1
|
|
80
81
|
return next((v for v in cls.__members__.values() if v.value[value_index] == value), None)
|
|
81
82
|
|
|
82
83
|
@classmethod
|
|
83
|
-
def list_as(cls, item_type) -> list[int
|
|
84
|
+
def list_as(cls, item_type) -> list[Union[int, str]]:
|
|
84
85
|
if item_type not in [int, str]:
|
|
85
86
|
raise TypeError('Invalid item type')
|
|
86
87
|
return list(map(item_type, cls))
|
|
@@ -1,27 +1,35 @@
|
|
|
1
|
-
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Literal, Optional, Union
|
|
2
3
|
|
|
3
4
|
from jwt.exceptions import InvalidTokenError
|
|
4
5
|
|
|
5
6
|
from django.conf import settings
|
|
7
|
+
from django.http import HttpRequest
|
|
6
8
|
from ninja.security import HttpBearer
|
|
7
9
|
from ninja.errors import AuthenticationError, HttpError
|
|
8
10
|
|
|
9
|
-
from .utils import
|
|
11
|
+
from .utils import (
|
|
12
|
+
acreate_or_update_user,
|
|
13
|
+
create_or_update_user,
|
|
14
|
+
decode_jwt,
|
|
15
|
+
TokenPayload,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger()
|
|
10
19
|
|
|
11
20
|
|
|
12
21
|
class AuthBearer(HttpBearer):
|
|
13
|
-
def
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
raise AuthenticationError(f"Invalid token: {str(e)}") from e
|
|
22
|
+
def __call__(self, request: HttpRequest):
|
|
23
|
+
token = self._get_token(request)
|
|
24
|
+
if not token:
|
|
25
|
+
return None
|
|
18
26
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
return self.authenticate(request, token)
|
|
28
|
+
|
|
29
|
+
def authenticate(self, request: HttpRequest, token: str) -> TokenPayload:
|
|
30
|
+
payload = self._decode_token(token)
|
|
31
|
+
|
|
32
|
+
username = self._get_username(payload)
|
|
25
33
|
|
|
26
34
|
user = create_or_update_user(username, payload)
|
|
27
35
|
|
|
@@ -32,6 +40,38 @@ class AuthBearer(HttpBearer):
|
|
|
32
40
|
# The return value is stored in request.auth
|
|
33
41
|
return payload
|
|
34
42
|
|
|
43
|
+
def _get_token(self, request: HttpRequest) -> Optional[str]:
|
|
44
|
+
"""
|
|
45
|
+
This part of the token validation is similar to what
|
|
46
|
+
django-ninja is doing in HttpBearer.__call__
|
|
47
|
+
"""
|
|
48
|
+
headers = request.headers
|
|
49
|
+
auth_value = headers.get(self.header)
|
|
50
|
+
if not auth_value:
|
|
51
|
+
return None
|
|
52
|
+
parts = auth_value.split(" ")
|
|
53
|
+
|
|
54
|
+
if parts[0].lower() != self.openapi_scheme:
|
|
55
|
+
if settings.DEBUG:
|
|
56
|
+
logger.error(f"Unexpected auth - '{auth_value}'")
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
return " ".join(parts[1:])
|
|
60
|
+
|
|
61
|
+
def _decode_token(self, token: str) -> TokenPayload:
|
|
62
|
+
try:
|
|
63
|
+
return decode_jwt(token)
|
|
64
|
+
except InvalidTokenError as e:
|
|
65
|
+
raise AuthenticationError(f"Invalid token: {str(e)}") from e
|
|
66
|
+
|
|
67
|
+
def _get_username(self, payload: TokenPayload) -> str:
|
|
68
|
+
try:
|
|
69
|
+
return payload["preferred_username"]
|
|
70
|
+
except KeyError as e:
|
|
71
|
+
raise AuthenticationError(
|
|
72
|
+
"Invalid token: 'preferred_username' claim not present."
|
|
73
|
+
) from e
|
|
74
|
+
|
|
35
75
|
def _verify_scopes(self, request, token_payload):
|
|
36
76
|
allowed_scopes = self._get_view_allowed_scopes(request)
|
|
37
77
|
|
|
@@ -53,7 +93,7 @@ class AuthBearer(HttpBearer):
|
|
|
53
93
|
|
|
54
94
|
if scopes is None:
|
|
55
95
|
raise Exception(
|
|
56
|
-
f"No allowed_scopes defined on the view {view_function.__name__}."
|
|
96
|
+
f"No allowed_scopes defined on the view {view_function.__name__}. "
|
|
57
97
|
"Add the decorator @allowed_scopes([...]) or @allowed_scopes('*') to the view."
|
|
58
98
|
)
|
|
59
99
|
|
|
@@ -71,7 +111,34 @@ class AuthBearer(HttpBearer):
|
|
|
71
111
|
)
|
|
72
112
|
|
|
73
113
|
|
|
74
|
-
|
|
114
|
+
class AuthBearerAsync(AuthBearer):
|
|
115
|
+
"""
|
|
116
|
+
Same as AuthBearer, but with async __call__ and authenticate methods.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
async def __call__(self, request: HttpRequest):
|
|
120
|
+
token = self._get_token(request)
|
|
121
|
+
if not token:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
return await self.authenticate(request, token)
|
|
125
|
+
|
|
126
|
+
async def authenticate(self, request: HttpRequest, token: str) -> TokenPayload:
|
|
127
|
+
payload = self._decode_token(token)
|
|
128
|
+
|
|
129
|
+
username = self._get_username(payload)
|
|
130
|
+
|
|
131
|
+
user = await acreate_or_update_user(username, payload)
|
|
132
|
+
|
|
133
|
+
self._verify_scopes(request, payload)
|
|
134
|
+
|
|
135
|
+
request.user = user
|
|
136
|
+
|
|
137
|
+
# The return value is stored in request.auth
|
|
138
|
+
return payload
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def allowed_scopes(scopes: Union[list[str], Literal["*"]]):
|
|
75
142
|
"""
|
|
76
143
|
A decorator that attaches a list of required scopes to a view function
|
|
77
144
|
in the attribute `_allowed_scopes`.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import TypedDict
|
|
1
|
+
from typing import TypedDict, Union
|
|
2
2
|
|
|
3
3
|
from django.conf import settings
|
|
4
4
|
from django.contrib.auth import get_user_model
|
|
@@ -6,13 +6,14 @@ from jwt import decode, PyJWKClient
|
|
|
6
6
|
|
|
7
7
|
jwks_client = PyJWKClient(getattr(settings, "JWKS_URL", ""))
|
|
8
8
|
|
|
9
|
+
User = get_user_model()
|
|
9
10
|
|
|
10
11
|
class TokenPayload(TypedDict, total=False):
|
|
11
12
|
exp: int
|
|
12
13
|
iat: int
|
|
13
14
|
jti: str
|
|
14
15
|
iss: str
|
|
15
|
-
aud: str
|
|
16
|
+
aud: Union[str, list[str]]
|
|
16
17
|
typ: str
|
|
17
18
|
azp: str
|
|
18
19
|
sid: str
|
|
@@ -43,35 +44,68 @@ def decode_jwt(token: str) -> TokenPayload:
|
|
|
43
44
|
)
|
|
44
45
|
|
|
45
46
|
|
|
46
|
-
def
|
|
47
|
+
def get_user_data_from_payload(payload: TokenPayload) -> dict:
|
|
47
48
|
"""
|
|
48
|
-
|
|
49
|
+
Extract user data from the JWT payload.
|
|
49
50
|
"""
|
|
50
|
-
user_model = get_user_model()
|
|
51
51
|
user_data = {
|
|
52
52
|
"first_name": payload.get("given_name") or "",
|
|
53
53
|
"last_name": payload.get("family_name") or "",
|
|
54
54
|
"email": payload.get("email") or "",
|
|
55
55
|
"is_staff": payload.get("is_staff", False),
|
|
56
56
|
}
|
|
57
|
-
|
|
57
|
+
|
|
58
|
+
if hasattr(User, "is_demo"):
|
|
58
59
|
user_data["is_demo"] = payload.get("is_demo", False)
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
return user_data
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def update_user_from_user_data(user, user_data: dict) -> bool:
|
|
65
|
+
update_needed = False
|
|
66
|
+
|
|
67
|
+
for field, value in user_data.items():
|
|
68
|
+
if getattr(user, field) != value:
|
|
69
|
+
setattr(user, field, value)
|
|
70
|
+
update_needed = True
|
|
63
71
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
72
|
+
return update_needed
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def create_or_update_user(username: str, payload: TokenPayload):
|
|
76
|
+
"""
|
|
77
|
+
Create or update a user based on the JWT payload.
|
|
78
|
+
"""
|
|
79
|
+
user_data = get_user_data_from_payload(payload)
|
|
80
|
+
|
|
81
|
+
user = User.objects.filter(username=username).first()
|
|
82
|
+
if user:
|
|
83
|
+
update_needed = update_user_from_user_data(user, user_data)
|
|
68
84
|
|
|
69
85
|
if update_needed:
|
|
70
86
|
user.save(update_fields=list(user_data.keys()))
|
|
71
87
|
|
|
72
88
|
return user
|
|
73
89
|
else:
|
|
74
|
-
return
|
|
90
|
+
return User.objects.create(
|
|
91
|
+
username=username,
|
|
92
|
+
**user_data,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def acreate_or_update_user(username: str, payload: TokenPayload):
|
|
97
|
+
user_data = get_user_data_from_payload(payload)
|
|
98
|
+
|
|
99
|
+
user = await User.objects.filter(username=username).afirst()
|
|
100
|
+
if user:
|
|
101
|
+
update_needed = update_user_from_user_data(user, user_data)
|
|
102
|
+
|
|
103
|
+
if update_needed:
|
|
104
|
+
await user.asave(update_fields=list(user_data.keys()))
|
|
105
|
+
|
|
106
|
+
return user
|
|
107
|
+
else:
|
|
108
|
+
return await User.objects.acreate(
|
|
75
109
|
username=username,
|
|
76
110
|
**user_data,
|
|
77
111
|
)
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from django.apps import apps
|
|
2
2
|
from django.conf import settings
|
|
3
|
+
from django.db import models
|
|
3
4
|
from keycloak import KeycloakAdmin
|
|
4
5
|
from keycloak import KeycloakOpenIDConnection
|
|
5
6
|
from keycloak.exceptions import KeycloakGetError
|
|
6
7
|
|
|
8
|
+
|
|
7
9
|
def _get_user_group_model():
|
|
8
10
|
"""
|
|
9
11
|
Dynamically get the UserGroup model.
|
|
@@ -76,7 +78,7 @@ class KeycloakService:
|
|
|
76
78
|
return KeycloakAdmin(connection=keycloak_connection)
|
|
77
79
|
|
|
78
80
|
def _process_group_recursively(
|
|
79
|
-
self, group, existing_groups_by_id, reported_group_ids
|
|
81
|
+
self, group: dict, existing_groups_by_id: dict[str, models.Model], reported_group_ids: set[str]
|
|
80
82
|
):
|
|
81
83
|
group_id = str(group["id"])
|
|
82
84
|
reported_group_ids.add(group_id)
|
|
@@ -100,6 +102,74 @@ class KeycloakService:
|
|
|
100
102
|
)
|
|
101
103
|
|
|
102
104
|
|
|
105
|
+
class KeycloakServiceAsync(KeycloakService):
|
|
106
|
+
"""
|
|
107
|
+
Async version of KeycloakService.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
async def sync_user_groups(self, raise_exceptions: bool = False):
|
|
111
|
+
print("Syncing user groups from Keycloak...")
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
groups = self._keycloak_admin.get_groups(full_hierarchy=True)
|
|
115
|
+
except KeycloakGetError as e:
|
|
116
|
+
print(f"Failed to fetch groups from Keycloak: {str(e)}")
|
|
117
|
+
if raise_exceptions:
|
|
118
|
+
raise e
|
|
119
|
+
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
# Process existing and new groups
|
|
123
|
+
existing_groups = self._user_group_model.objects.all()
|
|
124
|
+
existing_groups_by_id = {
|
|
125
|
+
str(group.id): group async for group in existing_groups
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
reported_group_ids = set()
|
|
129
|
+
for group in groups:
|
|
130
|
+
await self._process_group_recursively(
|
|
131
|
+
group, existing_groups_by_id, reported_group_ids
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Identify deleted groups
|
|
135
|
+
deleted_groups = self._user_group_model.objects.exclude(
|
|
136
|
+
id__in=reported_group_ids
|
|
137
|
+
)
|
|
138
|
+
if await deleted_groups.aexists():
|
|
139
|
+
paths = [
|
|
140
|
+
path async for path in deleted_groups.values_list("path", flat=True)
|
|
141
|
+
]
|
|
142
|
+
print(f"Deleting groups no longer present in Keycloak: {paths}")
|
|
143
|
+
|
|
144
|
+
await deleted_groups.adelete()
|
|
145
|
+
|
|
146
|
+
async def _process_group_recursively(
|
|
147
|
+
self, group: dict, existing_groups_by_id: dict[str, models.Model], reported_group_ids: set[str]
|
|
148
|
+
):
|
|
149
|
+
group_id = str(group["id"])
|
|
150
|
+
reported_group_ids.add(group_id)
|
|
151
|
+
|
|
152
|
+
if group_id in existing_groups_by_id:
|
|
153
|
+
existing_group = existing_groups_by_id[group_id]
|
|
154
|
+
if existing_group.path != group["path"]:
|
|
155
|
+
print(
|
|
156
|
+
f"Updating group path from {existing_group.path} to {group['path']}..."
|
|
157
|
+
)
|
|
158
|
+
existing_group.path = group["path"]
|
|
159
|
+
await existing_group.asave()
|
|
160
|
+
else:
|
|
161
|
+
print(f"Creating new group with path {group['path']}...")
|
|
162
|
+
await self._user_group_model.objects.acreate(
|
|
163
|
+
id=group_id, path=group["path"]
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if subgroups := group.get("subGroups"):
|
|
167
|
+
for subgroup in subgroups:
|
|
168
|
+
await self._process_group_recursively(
|
|
169
|
+
subgroup, existing_groups_by_id, reported_group_ids
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
103
173
|
class AuthServiceBase:
|
|
104
174
|
@staticmethod
|
|
105
175
|
def _get_all_level_paths(path: str) -> list[str]:
|
|
@@ -128,3 +198,21 @@ class AuthServiceBase:
|
|
|
128
198
|
user_groups = UserGroup.objects.filter(path__in=all_group_paths)
|
|
129
199
|
|
|
130
200
|
return user_groups
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class AuthServiceBaseAsync(AuthServiceBase):
|
|
204
|
+
@classmethod
|
|
205
|
+
async def _get_user_groups_from_paths(cls, group_paths: list[str]):
|
|
206
|
+
all_group_paths = set()
|
|
207
|
+
for path in group_paths:
|
|
208
|
+
all_group_paths.update(cls._get_all_level_paths(path))
|
|
209
|
+
|
|
210
|
+
UserGroup = _get_user_group_model()
|
|
211
|
+
user_groups = UserGroup.objects.filter(path__in=all_group_paths)
|
|
212
|
+
|
|
213
|
+
# If a group is missing/has been renamed in Keycloak, sync the groups
|
|
214
|
+
if await user_groups.acount() != len(all_group_paths):
|
|
215
|
+
await KeycloakServiceAsync().sync_user_groups()
|
|
216
|
+
user_groups = UserGroup.objects.filter(path__in=all_group_paths)
|
|
217
|
+
|
|
218
|
+
return user_groups
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cardo_python_utils-0.5.dev12 → cardo_python_utils-0.5.dev14}/python_utils/data_structures.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|