diff --git a/arkindex/documents/api/elements.py b/arkindex/documents/api/elements.py index 6afaefe15ccc5dc46bc4028441bf0d6cd162da5d..b953253741c0a84d159795b954d965944ddad25c 100644 --- a/arkindex/documents/api/elements.py +++ b/arkindex/documents/api/elements.py @@ -59,6 +59,7 @@ from arkindex.documents.serializers.elements import ( ElementTypeSerializer, MetaDataBulkSerializer, MetaDataUpdateSerializer, + SelectionMoveSerializer, ) from arkindex.documents.serializers.light import CorpusAllowedMetaDataSerializer, ElementTypeLightSerializer from arkindex.documents.serializers.ml import ElementTranscriptionSerializer @@ -1853,3 +1854,12 @@ class ElementMove(CreateAPIView): move_element(source=source, destination=destination, user_id=self.request.user.id) return Response(serializer.data, status=status.HTTP_200_OK) + + +@extend_schema(operation_id='MoveSelection', tags=['elements']) +class SelectionMove(SelectionMixin, CorpusACLMixin, CreateAPIView): + """ + Move selected elements from a given corpus to a new destination folder + """ + serializer_class = SelectionMoveSerializer + permission_classes = (IsVerified,) diff --git a/arkindex/documents/serializers/elements.py b/arkindex/documents/serializers/elements.py index 9b4a106f991ae54498e872af5b02a1890d097ed8..8b7000e33cb2da2cdfbe943c2983706b946e3be5 100644 --- a/arkindex/documents/serializers/elements.py +++ b/arkindex/documents/serializers/elements.py @@ -3,6 +3,7 @@ import uuid from collections import defaultdict from uuid import UUID +from django.conf import settings from django.contrib.gis.geos import LinearRing from django.core.exceptions import ValidationError as DjangoValidationError from django.core.validators import URLValidator @@ -34,7 +35,10 @@ from arkindex.documents.serializers.light import ( from arkindex.documents.serializers.ml import ClassificationSerializer from arkindex.images.models import Image from arkindex.images.serializers import ZoneSerializer +from arkindex.project.fields import Array +from arkindex.project.mixins import SelectionMixin from arkindex.project.serializer_fields import LinearRingField +from arkindex.project.triggers import move_selection from arkindex.users.models import Role from arkindex.users.utils import get_max_level @@ -927,3 +931,52 @@ class ElementDestinationSerializer(serializers.Serializer): raise ValidationError({'destination': [ "'{}' is a child of element '{}'".format(destination.id, source.id) ]}) + + +class SelectionMoveSerializer(serializers.Serializer, SelectionMixin): + corpus_id = serializers.PrimaryKeyRelatedField(queryset=Corpus.objects.none()) + destination = serializers.PrimaryKeyRelatedField(queryset=Element.objects.none()) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.context.get('request'): + # Do not raise Error in order to create OpenAPI schema + return + corpora = Corpus.objects.writable(self.context['request'].user) + self.fields['corpus_id'].queryset = corpora + self.fields['destination'].queryset = Element.objects.filter(corpus__in=corpora).select_related('corpus') + + def validate(self, data): + data = super().validate(data) + corpus = data.get('corpus_id') + destination = data.get('destination') + + if not settings.ARKINDEX_FEATURES['selection']: + raise ValidationError('Selection is not available on this instance.') + + if not self.context['request'].user.selected_elements.filter(corpus=corpus).exists(): + raise ValidationError({'corpus_id': ['No elements from the specified corpus have been selected.']}) + + if destination.corpus.id != corpus.id: + raise ValidationError({'destination': ['An element cannot be moved to a destination from another corpus.']}) + selected_elements = self.context['request'].user.selected_elements.filter(corpus=corpus.id) + if selected_elements.filter(id=destination.id).exists(): + raise ValidationError({'destination': ['An element cannot be moved into itself.']}) + + # Assert destination is not a source's direct ancestor already + if ElementPath.objects.filter(element__corpus_id=corpus.id, element__selections__user_id=self.context['request'].user.id, path__last=destination.id).exists(): + raise ValidationError({'destination': [ + "'{}' is already a direct parent of one or more selected elements.".format(destination.id) + ]}) + # Assert destination is not a source's descendant + if destination.paths.filter(path__overlap=Array(selected_elements.values_list('id', flat=True))).exists(): + raise ValidationError({'destination': [ + "'{}' is a child of one or more selected elements.".format(destination.id) + ]}) + + return data + + def save(self): + corpus = self.validated_data['corpus_id'] + destination = self.validated_data['destination'] + move_selection(corpus_id=corpus.id, destination=destination, user_id=self.context['request'].user.id) diff --git a/arkindex/documents/tasks.py b/arkindex/documents/tasks.py index f1016c64bf8bf6a4e5505cddc734fcfa41787d98..0f51e50970a0c55b10b4c24309cf6042c1602840 100644 --- a/arkindex/documents/tasks.py +++ b/arkindex/documents/tasks.py @@ -1,5 +1,6 @@ import logging from typing import Optional +from uuid import UUID from django.conf import settings from django.db.models import Q @@ -199,3 +200,16 @@ def move_element(source: Element, destination: Element) -> None: for parent in parents: parent.remove_child(source) source.add_parent(destination) + + +@job('high', timeout=settings.RQ_TIMEOUTS['move_element']) +def move_selection(corpus_id: UUID, destination: Element) -> None: + rq_job = get_current_job() + assert rq_job is not None, 'This task can only be run in a RQ job context.' + assert rq_job.user_id is not None, 'This task requires a user ID to be defined on the RQ job.' + + queryset = Element.objects.filter(selections__user_id=rq_job.user_id, corpus_id=corpus_id) + total = queryset.count() + for i, item in enumerate(queryset): + rq_job.set_progress(i / total) + move_element(source=item, destination=destination) diff --git a/arkindex/documents/tests/tasks/test_move_selection.py b/arkindex/documents/tests/tasks/test_move_selection.py new file mode 100644 index 0000000000000000000000000000000000000000..46c56aaf39c9229387612ed6beffcb7d11aeae79 --- /dev/null +++ b/arkindex/documents/tests/tasks/test_move_selection.py @@ -0,0 +1,53 @@ +from unittest.mock import call, patch + +from arkindex.documents.tasks import move_selection +from arkindex.project.tests import FixtureTestCase +from arkindex.users.models import User + + +class TestMoveSelection(FixtureTestCase): + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.user2 = User.objects.create(email='user2@test.test', display_name='User 2', verified_email=True) + cls.destination = cls.corpus.elements.get(name='Volume 2') + cls.page_1 = cls.corpus.elements.get(name='Volume 1, page 1r') + cls.page_2 = cls.corpus.elements.get(name='Volume 1, page 2r') + cls.page_3 = cls.corpus.elements.get(name='Volume 1, page 1v') + + def test_no_rq_job(self): + with self.assertRaises(AssertionError) as ctx: + move_selection(corpus_id=self.corpus.id, destination=self.destination) + self.assertEqual(str(ctx.exception), 'This task can only be run in a RQ job context.') + + @patch('arkindex.documents.tasks.get_current_job') + def test_no_user_id(self, job_mock): + job_mock.return_value.user_id = None + with self.assertRaises(AssertionError) as ctx: + move_selection(corpus_id=self.corpus.id, destination=self.destination) + self.assertEqual(str(ctx.exception), 'This task requires a user ID to be defined on the RQ job.') + + @patch('arkindex.documents.tasks.get_current_job') + @patch('arkindex.documents.tasks.move_element') + def test_move_selected_elements(self, move_element_mock, job_mock): + job_mock.return_value.user_id = self.user.id + + self.user.selected_elements.set([self.page_1, self.page_2]) + # Another user's selection should not be used + self.user2.selected_elements.set([self.page_3]) + + move_selection(corpus_id=self.corpus.id, destination=self.destination) + + self.assertEqual(job_mock.call_count, 1) + self.assertEqual(job_mock().set_progress.call_count, 2) + self.assertListEqual(job_mock().set_progress.call_args_list, [ + call(.0), + call(.5), + ]) + + self.assertEqual(move_element_mock.call_count, 2) + self.assertCountEqual(move_element_mock.call_args_list, [ + call(source=self.page_1, destination=self.destination), + call(source=self.page_2, destination=self.destination) + ]) diff --git a/arkindex/documents/tests/test_move_selection.py b/arkindex/documents/tests/test_move_selection.py new file mode 100644 index 0000000000000000000000000000000000000000..daa93cc90dbfdafc6ca0f3fb1072bfa35cef349b --- /dev/null +++ b/arkindex/documents/tests/test_move_selection.py @@ -0,0 +1,147 @@ +from unittest.mock import call, patch + +from django.test import override_settings +from django.urls import reverse +from rest_framework import status + +from arkindex.documents.models import Corpus +from arkindex.project.tests import FixtureAPITestCase +from arkindex.users.models import Role + + +class TestMoveSelection(FixtureAPITestCase): + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.destination = cls.corpus.elements.get(name='Volume 2') + cls.page = cls.corpus.elements.get(name='Volume 1, page 1r') + + @override_settings(ARKINDEX_FEATURES={'selection': True}) + def test_move_selection_requires_login(self): + with self.assertNumQueries(0): + response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(self.corpus.id), 'destination': str(self.destination.id)}, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + @override_settings(ARKINDEX_FEATURES={'selection': True}) + def test_move_selection_requires_verified(self): + self.user.verified_email = False + self.user.save() + self.client.force_login(self.user) + with self.assertNumQueries(2): + response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(self.corpus.id), 'destination': str(self.destination.id)}, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + @override_settings(ARKINDEX_FEATURES={'selection': False}) + def test_move_selection_disabled(self): + self.client.force_login(self.user) + with self.assertNumQueries(6): + response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(self.corpus.id), 'destination': str(self.destination.id)}, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertDictEqual( + response.json(), {'non_field_errors': ['Selection is not available on this instance.']} + ) + + @override_settings(ARKINDEX_FEATURES={'selection': True}) + def test_move_selection_wrong_acl(self): + private_corpus = Corpus.objects.create(name='private', public=False) + private_element = private_corpus.elements.create( + type=private_corpus.types.create(slug='folder'), + ) + + self.client.force_login(self.user) + with self.assertNumQueries(6): + response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(private_corpus.id), 'destination': str(private_element.id)}, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertDictEqual( + response.json(), + { + 'corpus_id': [f'Invalid pk "{private_corpus.id}" - object does not exist.'], + 'destination': [f'Invalid pk "{private_element.id}" - object does not exist.'] + } + ) + + @override_settings(ARKINDEX_FEATURES={'selection': True}) + def test_move_selection_wrong_destination(self): + self.client.force_login(self.user) + self.user.selected_elements.add(self.page) + with self.assertNumQueries(6): + response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(self.corpus.id), 'destination': '12341234-1234-1234-1234-123412341234'}, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertDictEqual( + response.json(), + {'destination': ['Invalid pk "12341234-1234-1234-1234-123412341234" - object does not exist.']} + ) + + @override_settings(ARKINDEX_FEATURES={'selection': True}) + def test_move_selection_same_destination(self): + self.client.force_login(self.user) + self.user.selected_elements.add(self.page) + with self.assertNumQueries(8): + response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(self.corpus.id), 'destination': str(self.page.id)}, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertDictEqual( + response.json(), + {'destination': ['An element cannot be moved into itself.']} + ) + + @override_settings(ARKINDEX_FEATURES={'selection': True}) + def test_move_element_different_corpus(self): + corpus2 = Corpus.objects.create(name='new') + corpus2.memberships.create(user=self.user, level=Role.Contributor.value) + destination = corpus2.elements.create(type=corpus2.types.create(slug='folder')) + self.client.force_login(self.user) + self.user.selected_elements.add(self.page) + with self.assertNumQueries(6): + response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(self.corpus.id), 'destination': str(destination.id)}, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertDictEqual( + response.json(), + {'destination': ['An element cannot be moved to a destination from another corpus.']} + ) + + @override_settings(ARKINDEX_FEATURES={'selection': True}) + def test_move_selection_destination_is_direct_parent(self): + destination = self.corpus.elements.get(name='Volume 1') + self.client.force_login(self.user) + self.user.selected_elements.add(self.page) + with self.assertNumQueries(9): + response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(self.corpus.id), 'destination': str(destination.id)}, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertDictEqual( + response.json(), + {'destination': [f"'{destination.id}' is already a direct parent of one or more selected elements."]} + ) + + @override_settings(ARKINDEX_FEATURES={'selection': True}) + def test_move_selection_destination_is_child(self): + target = self.corpus.elements.get(name='Volume 1') + destination_id = self.page.id + self.client.force_login(self.user) + self.user.selected_elements.add(target) + with self.assertNumQueries(10): + response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(self.corpus.id), 'destination': str(destination_id)}, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertDictEqual( + response.json(), + {'destination': [f"'{destination_id}' is a child of one or more selected elements."]} + ) + + @patch('arkindex.project.triggers.documents_tasks.move_selection.delay') + @override_settings(ARKINDEX_FEATURES={'selection': True}) + def test_move_selection(self, delay_mock): + self.client.force_login(self.user) + self.user.selected_elements.add(self.page) + another_page = self.corpus.elements.get(name='Volume 1, page 1v') + self.user.selected_elements.add(another_page) + with self.assertNumQueries(10): + response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(self.corpus.id), 'destination': str(self.destination.id)}, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + self.assertEqual(delay_mock.call_count, 1) + self.assertEqual(delay_mock.call_args, call( + corpus_id=self.corpus.id, + destination=self.destination, + user_id=self.user.id, + description=f"Moving selected elements from corpus {self.corpus.id} to element {self.destination.name}" + )) diff --git a/arkindex/project/api_v1.py b/arkindex/project/api_v1.py index 7a9b35e74f08661dfba27b3a3dd65a1e22788390..4d87bb571fa0fc628c1fe8e0a80466189c61d2d9 100644 --- a/arkindex/project/api_v1.py +++ b/arkindex/project/api_v1.py @@ -56,6 +56,7 @@ from arkindex.documents.api.elements import ( ElementTypeUpdate, ManageSelection, MetadataEdit, + SelectionMove, WorkerResultsDestroy, ) from arkindex.documents.api.entities import ( @@ -120,6 +121,7 @@ api = [ path('elements/<uuid:pk>/parents/', ElementParents.as_view(), name='elements-parents'), path('elements/<uuid:pk>/children/', ElementChildren.as_view(), name='elements-children'), path('elements/selection/', ManageSelection.as_view(), name='elements-selection'), + path('elements/selection/move/', SelectionMove.as_view(), name='move-selection'), path('elements/create/', ElementsCreate.as_view(), name='elements-create'), path('elements/type/', ElementTypeCreate.as_view(), name='element-type-create'), path('elements/type/<uuid:pk>/', ElementTypeUpdate.as_view(), name='element-type'), diff --git a/arkindex/project/fields.py b/arkindex/project/fields.py index 29591f30cbdba56ccef13190f286a83e0f18f702..8df42630e8749caeec850a39ee4c99d0279656cd 100644 --- a/arkindex/project/fields.py +++ b/arkindex/project/fields.py @@ -53,6 +53,12 @@ class ArrayField(fields.ArrayField): return LastItemTransformFactory(self.base_field) +class Array(Func): + function = 'array' + arity = 1 + output_field = fields.ArrayField + + class StripSlashURLField(URLField): """ A custom URLField that strips slashes from the end of all URLs. diff --git a/arkindex/project/triggers.py b/arkindex/project/triggers.py index 112aadca64f1a315b9e2cf6fdf99a61605e6ab1c..9884f748b84bdd4c798e5bec9e53a434da90e15b 100644 --- a/arkindex/project/triggers.py +++ b/arkindex/project/triggers.py @@ -96,6 +96,19 @@ def move_element(source: Element, destination: Element, user_id: Optional[int] = ) +def move_selection(corpus_id: UUID, destination: Element, user_id: int) -> None: + """ + Move selected elements (and all of their children) to a destination Element. + Delete all those elements' previous parents. + """ + documents_tasks.move_selection.delay( + corpus_id=corpus_id, + destination=destination, + user_id=user_id, + description=f"Moving selected elements from corpus {corpus_id} to element {destination.name}" + ) + + def initialize_activity(process: DataImport): """ Initialize activity on every process elements for worker versions that are part of its workflow