mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-21 17:08:33 +00:00
Enhancement (QoL): support deselecting single items from "select all" (#14117)
This commit is contained in:
@@ -1647,11 +1647,23 @@ class DocumentSelectionSerializer(DocumentListSerializer):
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
excluded_documents = serializers.ListField(
|
||||
required=False,
|
||||
default=list,
|
||||
write_only=True,
|
||||
child=serializers.IntegerField(),
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs.get("all", False):
|
||||
attrs.setdefault("documents", [])
|
||||
return attrs
|
||||
|
||||
if attrs["excluded_documents"]:
|
||||
raise serializers.ValidationError(
|
||||
"excluded_documents is only supported when all is true.",
|
||||
)
|
||||
|
||||
if "documents" not in attrs:
|
||||
raise serializers.ValidationError(
|
||||
"documents is required unless all is true.",
|
||||
|
||||
@@ -520,6 +520,30 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(args[0], [self.doc1.id])
|
||||
self.assertEqual(len(kwargs), 0)
|
||||
|
||||
@mock.patch("documents.views.bulk_edit.delete")
|
||||
def test_delete_documents_endpoint_with_excluded_documents(self, m) -> None:
|
||||
self.setup_mock(m, "delete")
|
||||
response = self.client.post(
|
||||
"/api/documents/delete/",
|
||||
json.dumps(
|
||||
{
|
||||
"all": True,
|
||||
"excluded_documents": [
|
||||
self.doc2.id,
|
||||
self.doc3.id,
|
||||
self.doc4.id,
|
||||
self.doc5.id,
|
||||
],
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
self.assertEqual(args[0], [self.doc1.id])
|
||||
self.assertEqual(len(kwargs), 0)
|
||||
|
||||
@mock.patch("documents.views.bulk_edit.reprocess")
|
||||
def test_reprocess_documents_endpoint(self, m) -> None:
|
||||
self.setup_mock(m, "reprocess")
|
||||
@@ -691,6 +715,26 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(b"documents is required unless all is true", response.content)
|
||||
|
||||
def test_api_rejects_excluded_documents_unless_all_is_true(self) -> None:
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.id],
|
||||
"excluded_documents": [self.doc2.id],
|
||||
"method": "set_storage_path",
|
||||
"parameters": {"storage_path": self.sp1.id},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(
|
||||
b"excluded_documents is only supported when all is true",
|
||||
response.content,
|
||||
)
|
||||
|
||||
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||
def test_api_bulk_edit_with_all_true_resolves_documents_from_filters(
|
||||
self,
|
||||
@@ -717,6 +761,29 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(args[0], [self.doc2.id])
|
||||
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
||||
|
||||
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||
def test_api_bulk_edit_with_all_true_excludes_documents(self, m) -> None:
|
||||
self.setup_mock(m, "set_storage_path")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"all": True,
|
||||
"excluded_documents": [self.doc2.id, self.doc4.id],
|
||||
"method": "set_storage_path",
|
||||
"parameters": {"storage_path": self.sp1.id},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
self.assertCountEqual(args[0], [self.doc1.id, self.doc3.id, self.doc5.id])
|
||||
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
||||
|
||||
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||
def test_api_bulk_edit_with_all_true_resolves_owned_duplicates(self, m) -> None:
|
||||
self.setup_mock(m, "set_storage_path")
|
||||
@@ -1077,6 +1144,33 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
],
|
||||
)
|
||||
|
||||
def test_api_selection_data_with_excluded_documents(self) -> None:
|
||||
response = self.client.post(
|
||||
"/api/documents/selection_data/",
|
||||
json.dumps(
|
||||
{
|
||||
"all": True,
|
||||
"excluded_documents": [self.doc2.id],
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertCountEqual(
|
||||
response.data["selected_correspondents"],
|
||||
[
|
||||
{"id": self.c1.id, "document_count": 0},
|
||||
{"id": self.c2.id, "document_count": 1},
|
||||
],
|
||||
)
|
||||
self.assertCountEqual(
|
||||
response.data["selected_tags"],
|
||||
[
|
||||
{"id": self.t1.id, "document_count": 1},
|
||||
{"id": self.t2.id, "document_count": 2},
|
||||
],
|
||||
)
|
||||
|
||||
def test_api_selection_data_requires_view_permission(self) -> None:
|
||||
self.doc2.owner = self.user
|
||||
self.doc2.save()
|
||||
|
||||
+19
-5
@@ -194,7 +194,7 @@ from documents.serialisers import BulkEditSerializer
|
||||
from documents.serialisers import CorrespondentSerializer
|
||||
from documents.serialisers import CustomFieldSerializer
|
||||
from documents.serialisers import DeleteDocumentsSerializer
|
||||
from documents.serialisers import DocumentListSerializer
|
||||
from documents.serialisers import DocumentSelectionSerializer
|
||||
from documents.serialisers import DocumentSerializer
|
||||
from documents.serialisers import DocumentTypeSerializer
|
||||
from documents.serialisers import DocumentVersionLabelSerializer
|
||||
@@ -2933,6 +2933,10 @@ class DocumentSelectionMixin:
|
||||
)
|
||||
if search_filtered_ids is not None:
|
||||
filtered_documents = filtered_documents.filter(pk__in=search_filtered_ids)
|
||||
if validated_data.get("excluded_documents"):
|
||||
filtered_documents = filtered_documents.exclude(
|
||||
pk__in=validated_data["excluded_documents"],
|
||||
)
|
||||
return list(filtered_documents.values_list("pk", flat=True))
|
||||
|
||||
|
||||
@@ -3056,7 +3060,14 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
||||
parameters = {
|
||||
k: v
|
||||
for k, v in validated_data.items()
|
||||
if k not in {"documents", "all", "filters", "from_webui"}
|
||||
if k
|
||||
not in {
|
||||
"documents",
|
||||
"all",
|
||||
"filters",
|
||||
"excluded_documents",
|
||||
"from_webui",
|
||||
}
|
||||
}
|
||||
user = self.request.user
|
||||
from_webui = validated_data.get("from_webui", False)
|
||||
@@ -3557,16 +3568,19 @@ class PostDocumentView(GenericAPIView[Any]):
|
||||
},
|
||||
),
|
||||
)
|
||||
class SelectionDataView(GenericAPIView[Any]):
|
||||
class SelectionDataView(DocumentSelectionMixin, GenericAPIView[Any]):
|
||||
permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
|
||||
serializer_class = DocumentListSerializer
|
||||
serializer_class = DocumentSelectionSerializer
|
||||
parser_classes = (parsers.MultiPartParser, parsers.JSONParser)
|
||||
|
||||
def post(self, request, format=None):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
ids = serializer.validated_data.get("documents")
|
||||
ids = self._resolve_document_ids(
|
||||
user=request.user,
|
||||
validated_data=serializer.validated_data,
|
||||
)
|
||||
permitted_documents = Document.objects.filter(
|
||||
id__in=permitted_document_ids(request.user),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user