diff --git a/swh/deposit/api/collection_list.py b/swh/deposit/api/collection_list.py new file mode 100644 --- /dev/null +++ b/swh/deposit/api/collection_list.py @@ -0,0 +1,57 @@ +# Copyright (C) 2021 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from django.shortcuts import render +from rest_framework import status +from rest_framework.generics import ListAPIView + +from swh.deposit.api.common import APIBase +from swh.deposit.api.utils import DefaultPagination, DepositSerializer +from swh.deposit.models import Deposit + + +class CollectionListAPI(ListAPIView, APIBase): + """Deposit request class to list the user deposits. + + HTTP verbs supported: ``GET`` + + """ + + serializer_class = DepositSerializer + pagination_class = DefaultPagination + + def get(self, request, *args, **kwargs): + """List the user's collection if the user has access to said collection. + + """ + self.checks(request, kwargs["collection_name"]) + paginated_result = super().get(request, *args, **kwargs) + data = paginated_result.data + # Build pagination link headers + links = [] + for link_name in ["next", "previous"]: + link = data.get(link_name) + if link is None: + continue + links.append(f'<{link}>; rel="{link_name}"') + response = render( + request, + "deposit/collection_list.xml", + context={ + "count": data["count"], + "results": [dict(d) for d in data["results"]], + }, + content_type="application/xml", + status=status.HTTP_200_OK, + ) + response._headers["Link"] = ",".join(links) + return response + + def get_queryset(self): + """List the deposits for the authenticated user (pagination is handled by the + `pagination_class` class attribute). + + """ + return Deposit.objects.filter(client=self.request.user.id).order_by("id") diff --git a/swh/deposit/api/private/deposit_list.py b/swh/deposit/api/private/deposit_list.py --- a/swh/deposit/api/private/deposit_list.py +++ b/swh/deposit/api/private/deposit_list.py @@ -1,41 +1,15 @@ -# Copyright (C) 2018-2020 The Software Heritage developers +# Copyright (C) 2018-2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information -from rest_framework import serializers -from rest_framework.fields import _UnvalidatedField from rest_framework.generics import ListAPIView -from rest_framework.pagination import PageNumberPagination + +from swh.deposit.api.utils import DefaultPagination, DepositSerializer from . import APIPrivateView from ...models import Deposit -from ..converters import convert_status_detail - - -class DefaultPagination(PageNumberPagination): - page_size = 100 - page_size_query_param = "page_size" - - -class StatusDetailField(_UnvalidatedField): - """status_detail field is a dict, we want a simple message instead. - So, we reuse the convert_status_detail from deposit_status - endpoint to that effect. - - """ - - def to_representation(self, value): - return convert_status_detail(value) - - -class DepositSerializer(serializers.ModelSerializer): - status_detail = StatusDetailField() - - class Meta: - model = Deposit - fields = "__all__" class APIList(ListAPIView, APIPrivateView): @@ -55,7 +29,6 @@ # sql injection: A priori, nothing to worry about, django does it for # queryset # https://docs.djangoproject.com/en/3.0/topics/security/#sql-injection-protection # noqa - # https://docs.djangoproject.com/en/2.2/topics/security/#sql-injection-protection # noqa deposits = ( Deposit.objects.all() .exclude(external_id__startswith=exclude_like) diff --git a/swh/deposit/api/urls.py b/swh/deposit/api/urls.py --- a/swh/deposit/api/urls.py +++ b/swh/deposit/api/urls.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017-2020 The Software Heritage developers +# Copyright (C) 2017-2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information @@ -10,8 +10,18 @@ from django.conf.urls import url from django.shortcuts import render -from ..config import COL_IRI, CONT_FILE_IRI, EDIT_IRI, EM_IRI, SD_IRI, SE_IRI, STATE_IRI +from ..config import ( + COL_IRI, + COLLECTION_LIST, + CONT_FILE_IRI, + EDIT_IRI, + EM_IRI, + SD_IRI, + SE_IRI, + STATE_IRI, +) from .collection import CollectionAPI +from .collection_list import CollectionListAPI from .content import ContentAPI from .edit import EditAPI from .edit_media import EditMediaAPI @@ -73,4 +83,9 @@ ), # specification is not clear about # File-IRI, we assume it's the same as # the Cont-IRI one + url( + r"^(?P[^/]+)/list/$", + CollectionListAPI.as_view(), + name=COLLECTION_LIST, + ), ] diff --git a/swh/deposit/api/utils.py b/swh/deposit/api/utils.py new file mode 100644 --- /dev/null +++ b/swh/deposit/api/utils.py @@ -0,0 +1,35 @@ +# Copyright (C) 2018-2021 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from rest_framework import serializers +from rest_framework.fields import _UnvalidatedField +from rest_framework.pagination import PageNumberPagination + +from swh.deposit.api.converters import convert_status_detail +from swh.deposit.models import Deposit + + +class DefaultPagination(PageNumberPagination): + page_size = 100 + page_size_query_param = "page_size" + + +class StatusDetailField(_UnvalidatedField): + """status_detail field is a dict, we want a simple message instead. + So, we reuse the convert_status_detail from deposit_status + endpoint to that effect. + + """ + + def to_representation(self, value): + return convert_status_detail(value) + + +class DepositSerializer(serializers.ModelSerializer): + status_detail = StatusDetailField() + + class Meta: + model = Deposit + fields = "__all__" diff --git a/swh/deposit/config.py b/swh/deposit/config.py --- a/swh/deposit/config.py +++ b/swh/deposit/config.py @@ -22,6 +22,7 @@ SD_IRI = "servicedocument" COL_IRI = "upload" STATE_IRI = "state_iri" +COLLECTION_LIST = "collection-list" PRIVATE_GET_RAW_CONTENT = "private-download" PRIVATE_CHECK_DEPOSIT = "check-deposit" PRIVATE_PUT_DEPOSIT = "private-update" diff --git a/swh/deposit/templates/deposit/collection_list.xml b/swh/deposit/templates/deposit/collection_list.xml new file mode 100644 --- /dev/null +++ b/swh/deposit/templates/deposit/collection_list.xml @@ -0,0 +1,18 @@ + + {{ count }} + + {% for deposit in results %} + + {% for key, value in deposit.items %} + {% if value is not None %} + {{ value }} + {% endif %} + {% endfor %} + + {% endfor %} + + diff --git a/swh/deposit/tests/api/test_collection_list.py b/swh/deposit/tests/api/test_collection_list.py new file mode 100644 --- /dev/null +++ b/swh/deposit/tests/api/test_collection_list.py @@ -0,0 +1,117 @@ +# Copyright (C) 2017-2021 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from io import BytesIO + +from django.urls import reverse_lazy as reverse +from requests.utils import parse_header_links +from rest_framework import status + +from swh.deposit.config import ( + COLLECTION_LIST, + DEPOSIT_STATUS_DEPOSITED, + DEPOSIT_STATUS_PARTIAL, +) +from swh.deposit.models import DepositCollection +from swh.deposit.parsers import parse_xml + + +def test_deposit_collection_list_is_auth_protected(anonymous_client): + """Deposit list should require authentication + + """ + url = reverse(COLLECTION_LIST, args=("test",)) + response = anonymous_client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert b"protected by basic authentication" in response.content + + +def test_deposit_collection_list_collection_access_restricted_to_user_coll( + deposit_another_collection, deposit_user, authenticated_client +): + """Deposit list api should restrict access to user's collection + + """ + collection_id = authenticated_client.deposit_client.collections[0] + coll = DepositCollection.objects.get(pk=collection_id) + # authenticated_client has access to the "coll" collection + coll2 = deposit_another_collection + assert coll.name != coll2.name + # but does not have access to that coll2 collection + url = reverse(COLLECTION_LIST, args=(coll2.name,)) + response = authenticated_client.get(url) + # so it gets rejected access to the listing of that coll2 collection + assert response.status_code == status.HTTP_403_FORBIDDEN + msg = f"{deposit_user.username} cannot access collection {coll2.name}" + assert msg in response.content.decode("utf-8") + + +def test_deposit_collection_list_nominal( + partial_deposit, deposited_deposit, authenticated_client +): + """Deposit list api should return the user deposits in a paginated way + + """ + client_id = authenticated_client.deposit_client.id + assert partial_deposit.client.id == client_id + assert deposited_deposit.client.id == client_id + # Both deposit were deposited by the authenticated client + # so requesting the listing of the deposits, both should be listed + + deposit_id = str(partial_deposit.id) + deposit_id2 = str(deposited_deposit.id) + coll = partial_deposit.collection + # requesting the listing of the deposit for the user's collection + url = reverse(COLLECTION_LIST, args=(coll.name,)) + response = authenticated_client.get(f"{url}?page_size=1") + assert response.status_code == status.HTTP_200_OK + + data = parse_xml(BytesIO(response.content)) + assert ( + data["swh:count"] == "2" + ) # total result of 2 deposits if consuming all results + header_link = parse_header_links(response._headers["Link"]) + assert len(header_link) == 1 # only 1 next link + expected_next = f"{url}?page=2&page_size=1" + assert header_link[0]["url"].endswith(expected_next) + assert header_link[0]["rel"] == "next" + + # only one deposit in the response + deposit = data["swh:deposits"]["swh:deposit"] # dict as only 1 value (a-la js) + assert isinstance(deposit, dict) + assert deposit["swh:id"] == deposit_id + assert deposit["swh:status"] == DEPOSIT_STATUS_PARTIAL + + # then 2nd page + response2 = authenticated_client.get(expected_next) + + assert response2.status_code == status.HTTP_200_OK + data2 = parse_xml(BytesIO(response2.content)) + assert data2["swh:count"] == "2" # still total of 2 deposits across all results + + expected_previous = f"{url}?page_size=1" + header_link2 = parse_header_links(response2._headers["Link"]) + assert len(header_link2) == 1 # only 1 previous link + assert header_link2[0]["url"].endswith(expected_previous) + assert header_link2[0]["rel"] == "previous" + + # only 1 deposit in the response + deposit2 = data2["swh:deposits"]["swh:deposit"] # dict as only 1 value (a-la js) + assert isinstance(deposit2, dict) + assert deposit2["swh:id"] == deposit_id2 + assert deposit2["swh:status"] == DEPOSIT_STATUS_DEPOSITED + + # Retrieve every deposit in one query (no page_size parameter) + response3 = authenticated_client.get(url) + assert response3.status_code == status.HTTP_200_OK + data3 = parse_xml(BytesIO(response3.content)) + assert data3["swh:count"] == "2" # total result of 2 deposits across all results + deposits3 = data3["swh:deposits"]["swh:deposit"] # list here + assert isinstance(deposits3, list) + assert len(deposits3) == 2 + header_link3 = parse_header_links(response3._headers["Link"]) + assert header_link3 == [] # no pagination as all results received in one round + assert deposit in deposits3 + assert deposit2 in deposits3