Page MenuHomeSoftware Heritage

D5765.id.diff
No OneTemporary

D5765.id.diff

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<collection_name>[^/]+)/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 @@
+<entry xmlns="http://www.w3.org/2005/Atom"
+ xmlns:sword="http://purl.org/net/sword/terms/"
+ xmlns:dcterms="http://purl.org/dc/terms/"
+ xmlns:sd="https://www.softwareheritage.org/schema/2018/deposit"
+ >
+ <sd:count>{{ count }}</sd:count>
+ <sd:deposits>
+ {% for deposit in results %}
+ <sd:deposit>
+ {% for key, value in deposit.items %}
+ {% if value is not None %}
+ <sd:{{ key }}>{{ value }}</sd:{{ key }}>
+ {% endif %}
+ {% endfor %}
+ </sd:deposit>
+ {% endfor %}
+ </sd:deposits>
+</entry>
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

File Metadata

Mime Type
text/plain
Expires
Thu, Jan 30, 11:23 AM (1 w, 20 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3216285

Event Timeline