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,35 @@ +# 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 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"]) + return super().get(request, *args, **kwargs) + + def get_queryset(self): + """List the deposits for the authenticated user (Pagination will be dealt with the + `pagination_class` attribute class). + + """ + 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,10 @@ ), # specification is not clear about # File-IRI, we assume it's the same as # the Cont-IRI one + # specification is not clear about + url( + r"^(?P[^/]+)/list/$", # FIXME: Merge within the collection url + 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/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,92 @@ +# 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 django.urls import reverse_lazy as reverse +from rest_framework import status + +from swh.deposit.config import ( + COLLECTION_LIST, + DEPOSIT_STATUS_DEPOSITED, + DEPOSIT_STATUS_PARTIAL, +) +from swh.deposit.models import DepositCollection + + +def test_deposit_collection_list_is_auth_protected(anonymous_client): + """Deposit list api is authentication protected + + """ + 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 do not let access to other 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( + 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 = partial_deposit.id + deposit_id2 = 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 = response.json() + assert data["count"] == 2 # total result of 2 deposits if consuming all results + expected_next = f"{url}?page=2&page_size=1" + assert data["next"].endswith(expected_next) is True + assert data["previous"] is None + assert len(data["results"]) == 1 # page of 1 deposit in that response + deposit = data["results"][0] + assert deposit["id"] == deposit_id + assert deposit["status"] == DEPOSIT_STATUS_PARTIAL + + # then 2nd page + response2 = authenticated_client.get(expected_next) + + assert response2.status_code == status.HTTP_200_OK + data2 = response2.json() + + assert data2["count"] == 2 # still 2 deposits + assert data2["next"] is None + + expected_previous = f"{url}?page_size=1" + assert data2["previous"].endswith(expected_previous) is True + assert len(data2["results"]) == 1 # page of size 1 + + deposit2 = data2["results"][0] + assert deposit2["id"] == deposit_id2 + assert deposit2["status"] == DEPOSIT_STATUS_DEPOSITED