django-database-task 0.3.0__tar.gz → 0.3.1__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.
- {django_database_task-0.3.0/django_database_task.egg-info → django_database_task-0.3.1}/PKG-INFO +1 -1
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/__init__.py +1 -1
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/views.py +72 -29
- {django_database_task-0.3.0 → django_database_task-0.3.1/django_database_task.egg-info}/PKG-INFO +1 -1
- {django_database_task-0.3.0 → django_database_task-0.3.1}/pyproject.toml +1 -1
- {django_database_task-0.3.0 → django_database_task-0.3.1}/tests/test_views.py +168 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/LICENSE +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/MANIFEST.in +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/README.md +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/admin.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/apps.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/backends.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/cloudtasks/__init__.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/cloudtasks/auth.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/cloudtasks/backend.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/cloudtasks/detection.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/executor.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/locale/ja/LC_MESSAGES/django.mo +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/locale/ja/LC_MESSAGES/django.po +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/management/__init__.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/management/commands/__init__.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/management/commands/purge_completed_database_tasks.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/management/commands/run_database_tasks.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/migrations/0001_initial.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/migrations/__init__.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/models.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/shutdown.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/urls.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task.egg-info/SOURCES.txt +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task.egg-info/dependency_links.txt +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task.egg-info/requires.txt +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task.egg-info/top_level.txt +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/setup.cfg +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/tests/test_admin.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/tests/test_backend.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/tests/test_commands.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/tests/test_executor.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/tests/test_models.py +0 -0
- {django_database_task-0.3.0 → django_database_task-0.3.1}/tests/test_shutdown.py +0 -0
{django_database_task-0.3.0/django_database_task.egg-info → django_database_task-0.3.1}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-database-task
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A database-backed task queue backend for Django's built-in task framework
|
|
5
5
|
Author-email: Shinya Okano <tokibito@gmail.com>
|
|
6
6
|
Maintainer-email: Shinya Okano <tokibito@gmail.com>
|
|
@@ -39,13 +39,78 @@ def get_backend(backend_name="default"):
|
|
|
39
39
|
"""Get a task backend by name."""
|
|
40
40
|
if backend_name == "default":
|
|
41
41
|
return default_task_backend
|
|
42
|
-
from django.tasks import
|
|
42
|
+
from django.tasks import task_backends
|
|
43
43
|
|
|
44
|
-
return
|
|
44
|
+
return task_backends[backend_name]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BackendAuthMixin:
|
|
48
|
+
"""
|
|
49
|
+
Apply the authentication handler the task backend provides, if any.
|
|
50
|
+
|
|
51
|
+
The backend decides how requests are authenticated by returning a
|
|
52
|
+
handler from get_auth_handler() (see
|
|
53
|
+
DatabaseTaskBackend.get_auth_handler). A handler takes the request and
|
|
54
|
+
returns None to accept it, or a response to reject it.
|
|
55
|
+
|
|
56
|
+
The check runs in dispatch(), before the method is dispatched, so every
|
|
57
|
+
endpoint and every HTTP method is covered. Backends that provide no
|
|
58
|
+
handler are unaffected.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def get_backend_name(self, request, *args, **kwargs):
|
|
62
|
+
"""
|
|
63
|
+
Name of the backend that authenticates this request.
|
|
64
|
+
|
|
65
|
+
Views that read backend_name from a request body override this.
|
|
66
|
+
"""
|
|
67
|
+
return request.GET.get("backend_name", "default")
|
|
68
|
+
|
|
69
|
+
def get_auth_error_response(self, request, *args, **kwargs):
|
|
70
|
+
"""Return a response if authentication fails, None if it passes."""
|
|
71
|
+
backend_name = self.get_backend_name(request, *args, **kwargs)
|
|
72
|
+
try:
|
|
73
|
+
backend = get_backend(backend_name)
|
|
74
|
+
except Exception:
|
|
75
|
+
# An unusable backend name must not skip authentication.
|
|
76
|
+
return JsonResponse({"error": "Invalid backend name"}, status=400)
|
|
77
|
+
|
|
78
|
+
get_auth_handler = getattr(backend, "get_auth_handler", None)
|
|
79
|
+
if get_auth_handler is None:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
auth_handler = get_auth_handler()
|
|
83
|
+
if auth_handler is None:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
return auth_handler(request)
|
|
87
|
+
|
|
88
|
+
def dispatch(self, request, *args, **kwargs):
|
|
89
|
+
error_response = self.get_auth_error_response(request, *args, **kwargs)
|
|
90
|
+
if error_response:
|
|
91
|
+
return error_response
|
|
92
|
+
return super().dispatch(request, *args, **kwargs)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class JSONBodyBackendAuthMixin(BackendAuthMixin):
|
|
96
|
+
"""BackendAuthMixin for views that read backend_name from a JSON body."""
|
|
97
|
+
|
|
98
|
+
def get_backend_name(self, request, *args, **kwargs):
|
|
99
|
+
try:
|
|
100
|
+
data = json.loads(request.body) if request.body else {}
|
|
101
|
+
except json.JSONDecodeError:
|
|
102
|
+
# Let the view report the malformed body.
|
|
103
|
+
return "default"
|
|
104
|
+
|
|
105
|
+
if not isinstance(data, dict):
|
|
106
|
+
return "default"
|
|
107
|
+
|
|
108
|
+
backend_name = data.get("backend_name", "default")
|
|
109
|
+
return backend_name if isinstance(backend_name, str) else "default"
|
|
45
110
|
|
|
46
111
|
|
|
47
112
|
@method_decorator(csrf_exempt, name="dispatch")
|
|
48
|
-
class RunTasksView(View):
|
|
113
|
+
class RunTasksView(JSONBodyBackendAuthMixin, View):
|
|
49
114
|
"""
|
|
50
115
|
Process pending tasks via HTTP POST.
|
|
51
116
|
|
|
@@ -132,7 +197,7 @@ class RunTasksView(View):
|
|
|
132
197
|
|
|
133
198
|
|
|
134
199
|
@method_decorator(csrf_exempt, name="dispatch")
|
|
135
|
-
class RunOneTaskView(View):
|
|
200
|
+
class RunOneTaskView(JSONBodyBackendAuthMixin, View):
|
|
136
201
|
"""
|
|
137
202
|
Process a single pending task via HTTP POST.
|
|
138
203
|
|
|
@@ -196,7 +261,7 @@ class RunOneTaskView(View):
|
|
|
196
261
|
)
|
|
197
262
|
|
|
198
263
|
|
|
199
|
-
class TaskStatusView(View):
|
|
264
|
+
class TaskStatusView(BackendAuthMixin, View):
|
|
200
265
|
"""
|
|
201
266
|
Get pending task count via HTTP GET.
|
|
202
267
|
|
|
@@ -225,7 +290,7 @@ class TaskStatusView(View):
|
|
|
225
290
|
|
|
226
291
|
|
|
227
292
|
@method_decorator(csrf_exempt, name="dispatch")
|
|
228
|
-
class ExecuteTaskView(View):
|
|
293
|
+
class ExecuteTaskView(BackendAuthMixin, View):
|
|
229
294
|
"""
|
|
230
295
|
Execute a specific task by ID via HTTP POST.
|
|
231
296
|
|
|
@@ -316,17 +381,6 @@ class ExecuteTaskView(View):
|
|
|
316
381
|
http_method_names = ["post"]
|
|
317
382
|
|
|
318
383
|
def post(self, request, task_id):
|
|
319
|
-
backend_name = request.GET.get("backend_name", "default")
|
|
320
|
-
|
|
321
|
-
# Get backend and check for auth handler
|
|
322
|
-
backend = get_backend(backend_name)
|
|
323
|
-
if hasattr(backend, "get_auth_handler"):
|
|
324
|
-
auth_handler = backend.get_auth_handler()
|
|
325
|
-
if auth_handler:
|
|
326
|
-
error_response = auth_handler(request)
|
|
327
|
-
if error_response:
|
|
328
|
-
return error_response
|
|
329
|
-
|
|
330
384
|
fail_on_error = request.GET.get("fail_on_error", "").lower() == "true"
|
|
331
385
|
allow_retry = request.GET.get("allow_retry", "").lower() == "true"
|
|
332
386
|
|
|
@@ -364,7 +418,7 @@ class ExecuteTaskView(View):
|
|
|
364
418
|
|
|
365
419
|
|
|
366
420
|
@method_decorator(csrf_exempt, name="dispatch")
|
|
367
|
-
class PurgeCompletedTasksView(View):
|
|
421
|
+
class PurgeCompletedTasksView(BackendAuthMixin, View):
|
|
368
422
|
"""
|
|
369
423
|
Delete completed tasks via HTTP GET or POST.
|
|
370
424
|
|
|
@@ -452,17 +506,6 @@ class PurgeCompletedTasksView(View):
|
|
|
452
506
|
|
|
453
507
|
def _purge(self, request):
|
|
454
508
|
"""Common purge logic for GET and POST."""
|
|
455
|
-
backend_name = request.GET.get("backend_name", "default")
|
|
456
|
-
|
|
457
|
-
# Get backend and check for auth handler
|
|
458
|
-
backend = get_backend(backend_name)
|
|
459
|
-
if hasattr(backend, "get_auth_handler"):
|
|
460
|
-
auth_handler = backend.get_auth_handler()
|
|
461
|
-
if auth_handler:
|
|
462
|
-
error_response = auth_handler(request)
|
|
463
|
-
if error_response:
|
|
464
|
-
return error_response
|
|
465
|
-
|
|
466
509
|
# Get parameters
|
|
467
510
|
params, error = self._get_params(request)
|
|
468
511
|
if error:
|
{django_database_task-0.3.0 → django_database_task-0.3.1/django_database_task.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-database-task
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A database-backed task queue backend for Django's built-in task framework
|
|
5
5
|
Author-email: Shinya Okano <tokibito@gmail.com>
|
|
6
6
|
Maintainer-email: Shinya Okano <tokibito@gmail.com>
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
import json
|
|
4
4
|
|
|
5
5
|
import pytest
|
|
6
|
+
from django.http import JsonResponse
|
|
6
7
|
from django.tasks.base import TaskResultStatus
|
|
7
8
|
from django.test import Client
|
|
8
9
|
from django.urls import reverse
|
|
9
10
|
from django.utils import timezone
|
|
10
11
|
|
|
12
|
+
from django_database_task.backends import DatabaseTaskBackend
|
|
11
13
|
from django_database_task.models import DatabaseTask
|
|
12
14
|
|
|
13
15
|
|
|
@@ -853,3 +855,169 @@ class TestPurgeCompletedTasksView:
|
|
|
853
855
|
|
|
854
856
|
assert response.status_code == 400
|
|
855
857
|
assert "No valid statuses" in response.json()["error"]
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
# All endpoints that must consult the backend's authentication handler,
|
|
861
|
+
# as (url name, HTTP method, reverse() args).
|
|
862
|
+
AUTHENTICATED_ENDPOINTS = [
|
|
863
|
+
("run_tasks", "post", []),
|
|
864
|
+
("run_one_task", "post", []),
|
|
865
|
+
("task_status", "get", []),
|
|
866
|
+
("execute_task", "post", ["3f2a9c11-0000-4000-8000-000000000000"]),
|
|
867
|
+
("purge_completed_tasks", "get", []),
|
|
868
|
+
("purge_completed_tasks", "post", []),
|
|
869
|
+
]
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
class AuthHandlerRecorder:
|
|
873
|
+
"""An authentication handler that records the requests it saw."""
|
|
874
|
+
|
|
875
|
+
def __init__(self, response=None):
|
|
876
|
+
self.requests = []
|
|
877
|
+
self.response = response
|
|
878
|
+
|
|
879
|
+
def __call__(self, request):
|
|
880
|
+
self.requests.append(request)
|
|
881
|
+
return self.response
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
@pytest.fixture
|
|
885
|
+
def rejecting_handler(monkeypatch):
|
|
886
|
+
"""Make the default backend reject every request with a 401."""
|
|
887
|
+
recorder = AuthHandlerRecorder(JsonResponse({"error": "Unauthorized"}, status=401))
|
|
888
|
+
monkeypatch.setattr(DatabaseTaskBackend, "get_auth_handler", lambda self: recorder)
|
|
889
|
+
return recorder
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
@pytest.fixture
|
|
893
|
+
def accepting_handler(monkeypatch):
|
|
894
|
+
"""Make the default backend accept every request."""
|
|
895
|
+
recorder = AuthHandlerRecorder(None)
|
|
896
|
+
monkeypatch.setattr(DatabaseTaskBackend, "get_auth_handler", lambda self: recorder)
|
|
897
|
+
return recorder
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _call(client, url_name, method, args):
|
|
901
|
+
url = reverse(f"django_database_task:{url_name}", args=args)
|
|
902
|
+
return getattr(client, method)(url, data="", content_type="application/json")
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
@pytest.mark.django_db
|
|
906
|
+
class TestBackendAuthentication:
|
|
907
|
+
"""Tests for the authentication handler the backend provides."""
|
|
908
|
+
|
|
909
|
+
def test_base_backend_provides_no_handler(self):
|
|
910
|
+
"""The base backend opts out of authentication."""
|
|
911
|
+
assert (
|
|
912
|
+
DatabaseTaskBackend(alias="default", params={}).get_auth_handler() is None
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
@pytest.mark.parametrize("url_name,method,args", AUTHENTICATED_ENDPOINTS)
|
|
916
|
+
def test_rejected_request_is_blocked(
|
|
917
|
+
self, client, rejecting_handler, url_name, method, args
|
|
918
|
+
):
|
|
919
|
+
"""Every endpoint returns the handler's response and stops there."""
|
|
920
|
+
response = _call(client, url_name, method, args)
|
|
921
|
+
|
|
922
|
+
assert response.status_code == 401
|
|
923
|
+
assert response.json() == {"error": "Unauthorized"}
|
|
924
|
+
assert len(rejecting_handler.requests) == 1
|
|
925
|
+
|
|
926
|
+
@pytest.mark.parametrize("url_name,method,args", AUTHENTICATED_ENDPOINTS)
|
|
927
|
+
def test_accepted_request_reaches_the_view(
|
|
928
|
+
self, client, accepting_handler, url_name, method, args
|
|
929
|
+
):
|
|
930
|
+
"""A handler returning None lets the request through."""
|
|
931
|
+
response = _call(client, url_name, method, args)
|
|
932
|
+
|
|
933
|
+
assert len(accepting_handler.requests) == 1
|
|
934
|
+
assert response.status_code != 401
|
|
935
|
+
|
|
936
|
+
@pytest.mark.parametrize("url_name,method,args", AUTHENTICATED_ENDPOINTS)
|
|
937
|
+
def test_endpoints_work_without_a_handler(self, client, url_name, method, args):
|
|
938
|
+
"""Backends without a handler are unaffected."""
|
|
939
|
+
response = _call(client, url_name, method, args)
|
|
940
|
+
|
|
941
|
+
assert response.status_code != 401
|
|
942
|
+
|
|
943
|
+
def test_rejected_request_does_not_run_tasks(self, client, rejecting_handler):
|
|
944
|
+
"""A blocked run request leaves the queued task alone."""
|
|
945
|
+
task = DatabaseTask.objects.create(
|
|
946
|
+
task_path="tests.test_executor.sample_task",
|
|
947
|
+
queue_name="default",
|
|
948
|
+
priority=0,
|
|
949
|
+
args_json=[],
|
|
950
|
+
kwargs_json={},
|
|
951
|
+
status=TaskResultStatus.READY,
|
|
952
|
+
enqueued_at=timezone.now(),
|
|
953
|
+
backend_name="default",
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
response = client.post(reverse("django_database_task:run_tasks"))
|
|
957
|
+
|
|
958
|
+
assert response.status_code == 401
|
|
959
|
+
task.refresh_from_db()
|
|
960
|
+
assert task.status == TaskResultStatus.READY
|
|
961
|
+
|
|
962
|
+
def test_rejected_request_does_not_purge_tasks(self, client, rejecting_handler):
|
|
963
|
+
"""A blocked purge request deletes nothing."""
|
|
964
|
+
DatabaseTask.objects.create(
|
|
965
|
+
task_path="tests.test_executor.sample_task",
|
|
966
|
+
queue_name="default",
|
|
967
|
+
priority=0,
|
|
968
|
+
args_json=[],
|
|
969
|
+
kwargs_json={},
|
|
970
|
+
status=TaskResultStatus.SUCCESSFUL,
|
|
971
|
+
enqueued_at=timezone.now(),
|
|
972
|
+
finished_at=timezone.now(),
|
|
973
|
+
backend_name="default",
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
response = client.post(reverse("django_database_task:purge_completed_tasks"))
|
|
977
|
+
|
|
978
|
+
assert response.status_code == 401
|
|
979
|
+
assert DatabaseTask.objects.count() == 1
|
|
980
|
+
|
|
981
|
+
def test_unusable_backend_name_does_not_skip_authentication(
|
|
982
|
+
self, client, rejecting_handler
|
|
983
|
+
):
|
|
984
|
+
"""An unresolvable backend is rejected instead of running unauthenticated."""
|
|
985
|
+
response = client.get(
|
|
986
|
+
reverse("django_database_task:task_status"),
|
|
987
|
+
{"backend_name": "does-not-exist"},
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
assert response.status_code == 400
|
|
991
|
+
assert response.json() == {"error": "Invalid backend name"}
|
|
992
|
+
assert rejecting_handler.requests == []
|
|
993
|
+
|
|
994
|
+
def test_backend_name_from_the_request_body_is_used(self, client, monkeypatch):
|
|
995
|
+
"""Views that read backend_name from a JSON body authenticate with it."""
|
|
996
|
+
seen = []
|
|
997
|
+
monkeypatch.setattr(
|
|
998
|
+
DatabaseTaskBackend,
|
|
999
|
+
"get_auth_handler",
|
|
1000
|
+
lambda self: lambda request: seen.append(request) or None,
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
response = client.post(
|
|
1004
|
+
reverse("django_database_task:run_tasks"),
|
|
1005
|
+
data=json.dumps({"backend_name": "default"}),
|
|
1006
|
+
content_type="application/json",
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
assert response.status_code == 200
|
|
1010
|
+
assert len(seen) == 1
|
|
1011
|
+
|
|
1012
|
+
def test_malformed_body_still_reports_a_bad_request(
|
|
1013
|
+
self, client, accepting_handler
|
|
1014
|
+
):
|
|
1015
|
+
"""A broken JSON body is reported by the view, not the auth check."""
|
|
1016
|
+
response = client.post(
|
|
1017
|
+
reverse("django_database_task:run_tasks"),
|
|
1018
|
+
data="{not json",
|
|
1019
|
+
content_type="application/json",
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
assert response.status_code == 400
|
|
1023
|
+
assert "Invalid JSON" in response.json()["error"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/cloudtasks/auth.py
RENAMED
|
File without changes
|
{django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/cloudtasks/backend.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
|
{django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task.egg-info/requires.txt
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
|