Skip to content

Commit 5ccfd49

Browse files
author
Niels Verbeek
committed
feat(copy): add api endpoint to copy a document
1 parent 879bbdd commit 5ccfd49

File tree

8 files changed

+296
-28
lines changed

8 files changed

+296
-28
lines changed

alexandria/core/api.py

+48
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,62 @@
11
import logging
2+
from os.path import splitext
23

34
from django.conf import settings
45
from django.core.files import File
6+
from django.utils.translation import gettext as _
57

68
from alexandria.core import models
79
from alexandria.core.validations import validate_file
810

911
log = logging.getLogger(__name__)
1012

1113

14+
def copy_document(
15+
document: models.Document, user: str, group: str, category: models.Category
16+
):
17+
"""
18+
Copy a document and all its original files to a new document.
19+
20+
This function eases the copying of documents by automatically setting important fields.
21+
Uses `create_file` to copy the original document files.
22+
"""
23+
24+
basename, ext = splitext(document.title)
25+
copy_suffix = _("(copy)")
26+
document_title = f"{basename} {copy_suffix}{ext}"
27+
new_document = models.Document.objects.create(
28+
title=document_title,
29+
description=document.description,
30+
metainfo=document.metainfo,
31+
category=category,
32+
created_by_user=user,
33+
created_by_group=group,
34+
modified_by_user=user,
35+
modified_by_group=group,
36+
)
37+
38+
# Copying only the originals - create_file() will create the thumbnails
39+
document_files = models.File.objects.filter(
40+
document=document, variant=models.File.Variant.ORIGINAL.value
41+
).order_by("created_at")
42+
new_files = []
43+
for document_file in document_files:
44+
new_files.append(
45+
create_file(
46+
name=document_file.name,
47+
document=new_document,
48+
content=document_file.content,
49+
mime_type=document_file.mime_type,
50+
size=document_file.size,
51+
user=document_file.created_by_user,
52+
group=document_file.created_by_group,
53+
metainfo=document_file.metainfo,
54+
)
55+
)
56+
57+
return new_document
58+
59+
1260
def create_document_file(
1361
user: str,
1462
group: str,

alexandria/core/serializers.py

+14-9
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from django.db.transaction import atomic
66
from django.template.defaultfilters import slugify
77
from django.utils import translation
8-
from django.utils.module_loading import import_string
98
from generic_permissions.validation import ValidatorMixin
109
from generic_permissions.visibilities import (
1110
VisibilityResourceRelatedField,
@@ -15,6 +14,8 @@
1514
from rest_framework_json_api.relations import SerializerMethodResourceRelatedField
1615
from rest_framework_json_api.views import Serializer
1716

17+
from alexandria.core.utils import get_user_and_group_from_request
18+
1819
from . import models
1920

2021
log = logging.getLogger(__name__)
@@ -27,15 +28,10 @@ class BaseSerializer(
2728

2829
created_at = serializers.DateTimeField(read_only=True)
2930

30-
def get_user_and_group(self):
31-
return import_string(settings.ALEXANDRIA_GET_USER_AND_GROUP_FUNCTION)(
32-
self.context.get("request")
33-
)
34-
3531
def is_valid(self, *args, **kwargs):
3632
# Prime data so the validators are called (and default values filled
3733
# if client didn't pass them.)
38-
user, group = self.get_user_and_group()
34+
user, group = get_user_and_group_from_request(self.context.get("request"))
3935
self.initial_data.setdefault("created_by_group", group)
4036
self.initial_data.setdefault("modified_by_group", group)
4137
self.initial_data.setdefault("created_by_user", user)
@@ -45,7 +41,7 @@ def is_valid(self, *args, **kwargs):
4541
def validate(self, *args, **kwargs):
4642
validated_data = super().validate(*args, **kwargs)
4743

48-
user, group = self.get_user_and_group()
44+
user, group = get_user_and_group_from_request(self.context.get("request"))
4945
validated_data["modified_by_user"] = user
5046
validated_data["modified_by_group"] = group
5147

@@ -324,7 +320,7 @@ def get_webdav_url(self, instance):
324320
request = self.context.get("request")
325321
host = request.get_host() if request else "localhost"
326322
scheme = request.scheme if request else "http"
327-
user, group = self.get_user_and_group()
323+
user, group = get_user_and_group_from_request(self.context.get("request"))
328324
return instance.get_latest_original().get_webdav_url(
329325
user, group, f"{scheme}://{host}"
330326
)
@@ -364,3 +360,12 @@ class Meta:
364360
"name",
365361
)
366362
read_only_fields = fields
363+
364+
365+
class CopyRequestSerializer(Serializer):
366+
category = serializers.ResourceRelatedField(
367+
queryset=models.Category.objects,
368+
required=False,
369+
allow_null=True,
370+
allow_empty=True,
371+
)

alexandria/core/tests/test_api.py

+100
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import pytest
12
from django.core.files.uploadedfile import SimpleUploadedFile
3+
from rest_framework.exceptions import ValidationError
24

35
from alexandria.core import api
46
from alexandria.core.factories import FileData
7+
from alexandria.core.models import File
58

69

710
def test_create_document_file(db, category):
@@ -21,3 +24,100 @@ def test_create_document_file(db, category):
2124
)
2225
assert doc.title == "Bar.pdf"
2326
assert file.name == "Mee.pdf"
27+
28+
29+
@pytest.mark.parametrize(
30+
"same_category",
31+
[
32+
True,
33+
False,
34+
],
35+
)
36+
def test_copy_document_api(db, category, category_factory, same_category):
37+
# initial document with one file
38+
input_doc, first_file = api.create_document_file(
39+
"Foo",
40+
"Baz",
41+
category,
42+
"Bar.pdf",
43+
"Mee.pdf",
44+
SimpleUploadedFile(
45+
name="test.png",
46+
content=FileData.png,
47+
content_type="png",
48+
),
49+
"image/png",
50+
1,
51+
)
52+
53+
# add an extra file to the document
54+
extra_file = api.create_file(
55+
input_doc,
56+
"Foo2",
57+
"Baz2",
58+
"Mee2.pdf",
59+
SimpleUploadedFile(
60+
name="test2.jpg",
61+
content=FileData.png,
62+
content_type="jpg",
63+
),
64+
"image/jpeg",
65+
2,
66+
)
67+
68+
to_category = category if same_category else category_factory()
69+
copied_doc = api.copy_document(input_doc, "CopyUser", "CopyGroup", to_category)
70+
files = copied_doc.files.order_by("variant", "created_at")
71+
72+
assert copied_doc.title == "Bar (copy).pdf"
73+
assert copied_doc.category.pk == to_category.pk
74+
# document copy will have the user/group of the user who copied it
75+
assert copied_doc.created_by_user == "CopyUser"
76+
assert copied_doc.created_by_group == "CopyGroup"
77+
78+
# 2 copied files + 2 new thumbnails
79+
assert len(files) == 4
80+
81+
# original 1
82+
assert first_file.pk != files[0].pk
83+
assert files[0].document.pk == copied_doc.pk
84+
assert files[0].variant == File.Variant.ORIGINAL
85+
assert files[0].name == "Mee.pdf"
86+
assert files[0].mime_type == "image/png"
87+
assert files[0].size == 1
88+
# files will retain the user/group of the original document
89+
assert files[0].created_by_user == "Foo"
90+
assert files[0].created_by_group == "Baz"
91+
92+
# new thumbnail for first file
93+
assert files[2].document.pk == copied_doc.pk
94+
assert files[2].variant == File.Variant.THUMBNAIL
95+
96+
# original 2
97+
assert extra_file.pk != files[1].pk
98+
assert files[1].document.pk == copied_doc.pk
99+
assert files[1].variant == File.Variant.ORIGINAL
100+
assert files[1].name == "Mee2.pdf"
101+
assert files[1].mime_type == "image/jpeg"
102+
assert files[1].size == 2
103+
# files will retain the user/group of the original document
104+
assert files[1].created_by_user == "Foo2"
105+
assert files[1].created_by_group == "Baz2"
106+
107+
# new thumbnail for extra file
108+
assert files[3].document.pk == copied_doc.pk
109+
assert files[3].variant == File.Variant.THUMBNAIL
110+
111+
112+
def test_presigning_api(db, file):
113+
_, expires, signature = api.make_signature_components(
114+
file.pk, "testserver", download_path="/foo"
115+
)
116+
117+
api.verify_signed_components(
118+
file.pk, "testserver", signature, expires, download_path="/foo"
119+
)
120+
with pytest.raises(ValidationError):
121+
api.verify_signed_components(
122+
file.pk, "testserver", "incorrect-signature", expires, download_path="/foo"
123+
)

alexandria/core/tests/test_views.py

+73
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,79 @@ def test_move_document_to_new_category(
396396
assert response.status_code == HTTP_200_OK
397397

398398

399+
@pytest.mark.parametrize(
400+
"to_category,expected_status",
401+
[
402+
("same", HTTP_201_CREATED),
403+
("new", HTTP_201_CREATED),
404+
("null", HTTP_201_CREATED),
405+
("not_defined", HTTP_201_CREATED),
406+
("non_existent", HTTP_400_BAD_REQUEST),
407+
("not_allowed", HTTP_400_BAD_REQUEST),
408+
],
409+
)
410+
def test_copy_document(
411+
admin_client,
412+
category_factory,
413+
file_factory,
414+
document_factory,
415+
to_category,
416+
expected_status,
417+
):
418+
category_not_allowed = category_factory.create(allowed_mime_types=["plain/text"])
419+
category = category_factory()
420+
document = document_factory(category=category)
421+
file_factory.create(document=document, name="Image.jpeg", mime_type="image/jpeg")
422+
temp_category = category_factory()
423+
temp_category_pk = temp_category.pk
424+
temp_category.delete()
425+
426+
if to_category == "non_existent":
427+
target_category_pk = temp_category_pk
428+
elif to_category == "new":
429+
target_category_pk = category_factory().pk
430+
elif to_category == "not_allowed":
431+
target_category_pk = category_not_allowed.pk
432+
else:
433+
target_category_pk = document.category.pk
434+
435+
data = {
436+
"data": {
437+
"type": "documents",
438+
"id": document.pk,
439+
"relationships": {
440+
"category": {
441+
"data": {
442+
"id": target_category_pk,
443+
"type": "categories",
444+
}
445+
}
446+
},
447+
}
448+
}
449+
450+
if to_category == "not_defined":
451+
data["data"]["relationships"] = {}
452+
elif to_category == "null":
453+
data["data"]["relationships"]["category"] = None
454+
455+
url = reverse("document-copy", args=[document.pk])
456+
response = admin_client.post(url, data=data)
457+
458+
assert response.status_code == expected_status
459+
if to_category == "not_allowed":
460+
assert (
461+
response.json()["errors"][0]["detail"]
462+
== f"File type image/jpeg is not allowed in category {category_not_allowed.pk}."
463+
)
464+
465+
if expected_status == HTTP_201_CREATED:
466+
assert (
467+
response.json()["data"]["relationships"]["category"]["data"]["id"]
468+
== target_category_pk
469+
)
470+
471+
399472
@pytest.mark.parametrize(
400473
"presigned, expected_status",
401474
[(True, HTTP_200_OK), (False, HTTP_403_FORBIDDEN)],

alexandria/core/utils.py

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from django.conf import settings
2+
from django.utils.module_loading import import_string
3+
4+
5+
def get_user_and_group_from_request(request):
6+
"""Return a 2-tuple of `user`, `group` from the given request."""
7+
getter_fn = import_string(settings.ALEXANDRIA_GET_USER_AND_GROUP_FUNCTION)
8+
return getter_fn(request)

alexandria/core/views.py

+31-5
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from django.core.exceptions import ValidationError as DjangoCoreValidationError
1111
from django.core.files.base import ContentFile
1212
from django.http import FileResponse
13-
from django.utils.module_loading import import_string
1413
from django.utils.translation import gettext as _
1514
from django_presigned_url.presign_urls import verify_presigned_request
1615
from generic_permissions.permissions import AllowAny, PermissionViewMixin
@@ -40,8 +39,10 @@
4039
RelatedMixin,
4140
)
4241

42+
from alexandria.core.utils import get_user_and_group_from_request
43+
4344
from . import models, serializers
44-
from .api import create_document_file
45+
from .api import copy_document, create_document_file
4546
from .filters import (
4647
CategoryFilterSet,
4748
DocumentFilterSet,
@@ -115,6 +116,33 @@ def update(self, request, *args, **kwargs):
115116

116117
return response
117118

119+
@action(
120+
methods=["post"],
121+
detail=True,
122+
url_path="copy",
123+
)
124+
def copy(self, request, pk=None):
125+
document = self.get_object()
126+
user, group = get_user_and_group_from_request(request)
127+
128+
copy_request_serializer = serializers.CopyRequestSerializer(data=request.data)
129+
copy_request_serializer.is_valid(raise_exception=True)
130+
category = (
131+
copy_request_serializer.validated_data.get("category", False)
132+
or document.category
133+
)
134+
135+
copied_document = copy_document(
136+
document=document,
137+
category=category,
138+
user=user,
139+
group=group,
140+
)
141+
142+
serializer = self.get_serializer(copied_document)
143+
headers = self.get_success_headers(serializer.data)
144+
return Response(serializer.data, status=HTTP_201_CREATED, headers=headers)
145+
118146
@action(methods=["post"], detail=True)
119147
def convert(self, request, pk=None):
120148
if not settings.ALEXANDRIA_ENABLE_PDF_CONVERSION:
@@ -135,9 +163,7 @@ def convert(self, request, pk=None):
135163

136164
response.raise_for_status()
137165

138-
user, group = import_string(settings.ALEXANDRIA_GET_USER_AND_GROUP_FUNCTION)(
139-
request
140-
)
166+
user, group = get_user_and_group_from_request(request)
141167

142168
file_name = f"{splitext(file.name)[0]}.pdf"
143169
document_title = f"{splitext(document.title)[0]}.pdf"

0 commit comments

Comments
 (0)