mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-13 13:17:59 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72ea38ab12 | ||
|
|
4421d4fe58 | ||
|
|
4d64632f70 | ||
|
|
26094bc863 | ||
|
|
9dbad4de09 |
@@ -1209,14 +1209,6 @@ left unassigned, preventing low-confidence guesses from being applied.
|
|||||||
|
|
||||||
Defaults to 0.6.
|
Defaults to 0.6.
|
||||||
|
|
||||||
#### [`PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS=<float>`](#PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS) {#PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS}
|
|
||||||
|
|
||||||
: Sets the timeout, in seconds, for regular expression matching. Increase this
|
|
||||||
value if date parsing or user-defined matching rules time out when processing
|
|
||||||
long documents, especially on slower hardware.
|
|
||||||
|
|
||||||
Defaults to 0.1 seconds.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
|
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
|
||||||
|
|
||||||
: Specifies which language Paperless should use when parsing dates from documents.
|
: Specifies which language Paperless should use when parsing dates from documents.
|
||||||
|
|||||||
@@ -657,16 +657,41 @@ class ViewDocumentsPermissions(BasePermission):
|
|||||||
return request.user.has_perms(self.perms_map.get(request.method, []))
|
return request.user.has_perms(self.perms_map.get(request.method, []))
|
||||||
|
|
||||||
|
|
||||||
|
class TrashPermissions(BasePermission):
|
||||||
|
"""Check the global document permission for each trash operation."""
|
||||||
|
|
||||||
|
perms_map = {
|
||||||
|
"OPTIONS": ["documents.view_document"],
|
||||||
|
"HEAD": ["documents.view_document"],
|
||||||
|
"GET": ["documents.view_document"],
|
||||||
|
"POST": ["documents.delete_document"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not request.user or not request.user.is_authenticated: # pragma: no cover
|
||||||
|
return False
|
||||||
|
|
||||||
|
return request.user.has_perms(self.perms_map.get(request.method, []))
|
||||||
|
|
||||||
|
|
||||||
class PaperlessNotePermissions(BasePermission):
|
class PaperlessNotePermissions(BasePermission):
|
||||||
"""
|
"""
|
||||||
Permissions class that checks for model permissions for Notes.
|
Permissions class that checks for model permissions for Notes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
perms_map = {
|
perms_map = {
|
||||||
"OPTIONS": ["documents.view_note"],
|
"OPTIONS": ["documents.view_note", "documents.view_document"],
|
||||||
"GET": ["documents.view_note"],
|
"GET": ["documents.view_note", "documents.view_document"],
|
||||||
"POST": ["documents.add_note"],
|
"POST": [
|
||||||
"DELETE": ["documents.delete_note"],
|
"documents.add_note",
|
||||||
|
"documents.view_document",
|
||||||
|
"documents.change_document",
|
||||||
|
],
|
||||||
|
"DELETE": [
|
||||||
|
"documents.delete_note",
|
||||||
|
"documents.view_document",
|
||||||
|
"documents.change_document",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
|
|||||||
@@ -1335,6 +1335,7 @@ class DocumentSerializer(
|
|||||||
"root_document",
|
"root_document",
|
||||||
"versions",
|
"versions",
|
||||||
)
|
)
|
||||||
|
read_only_fields = ("deleted_at",)
|
||||||
list_serializer_class = OwnedObjectListSerializer
|
list_serializer_class = OwnedObjectListSerializer
|
||||||
|
|
||||||
|
|
||||||
@@ -2839,10 +2840,14 @@ class ShareLinkSerializer(OwnedObjectSerializer):
|
|||||||
return super().create(validated_data)
|
return super().create(validated_data)
|
||||||
|
|
||||||
def validate_document(self, document):
|
def validate_document(self, document):
|
||||||
if self.user is not None and has_perms_owner_aware(
|
if (
|
||||||
self.user,
|
self.user is not None
|
||||||
"view_document",
|
and self.user.has_perm("documents.view_document")
|
||||||
document,
|
and has_perms_owner_aware(
|
||||||
|
self.user,
|
||||||
|
"view_document",
|
||||||
|
document,
|
||||||
|
)
|
||||||
):
|
):
|
||||||
return document
|
return document
|
||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
@@ -3603,6 +3608,8 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
|
|||||||
|
|
||||||
if "actions" in validated_data:
|
if "actions" in validated_data:
|
||||||
actions = validated_data.pop("actions")
|
actions = validated_data.pop("actions")
|
||||||
|
for action in actions:
|
||||||
|
action.pop("id", None)
|
||||||
|
|
||||||
instance = super().create(validated_data)
|
instance = super().create(validated_data)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import json
|
|||||||
import shutil
|
import shutil
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -326,6 +327,9 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
|
|||||||
|
|
||||||
def test_download_insufficient_permissions(self) -> None:
|
def test_download_insufficient_permissions(self) -> None:
|
||||||
user = User.objects.create_user(username="temp_user")
|
user = User.objects.create_user(username="temp_user")
|
||||||
|
user.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
self.doc2.owner = self.user
|
self.doc2.owner = self.user
|
||||||
|
|||||||
@@ -1084,6 +1084,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
self.client.force_authenticate(user=user1)
|
self.client.force_authenticate(user=user1)
|
||||||
|
|
||||||
|
assign_perm("view_document", user1, self.doc2)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/selection_data/",
|
"/api/documents/selection_data/",
|
||||||
json.dumps({"documents": [self.doc2.id]}),
|
json.dumps({"documents": [self.doc2.id]}),
|
||||||
@@ -1091,7 +1093,18 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
self.assertEqual(response.content, b"Insufficient permissions")
|
|
||||||
|
user1.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
user1 = User.objects.get(pk=user1.pk)
|
||||||
|
self.client.force_authenticate(user=user1)
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/selection_data/",
|
||||||
|
json.dumps({"documents": [self.doc2.id]}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
def test_set_permissions(self, m) -> None:
|
def test_set_permissions(self, m) -> None:
|
||||||
|
|||||||
@@ -3615,6 +3615,55 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
self.assertEqual(response.content, b"Insufficient permissions to delete notes")
|
self.assertEqual(response.content, b"Insufficient permissions to delete notes")
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
def test_notes_require_global_document_permissions(self) -> None:
|
||||||
|
user = User.objects.create_user(username="note_editor")
|
||||||
|
user.user_permissions.add(
|
||||||
|
*Permission.objects.filter(
|
||||||
|
codename__in=["view_note", "add_note", "delete_note"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="test",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
content="notes",
|
||||||
|
owner=user,
|
||||||
|
)
|
||||||
|
note = Note.objects.create(note="Existing", document=doc, user=user)
|
||||||
|
self.client.force_authenticate(user)
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/documents/{doc.pk}/notes/")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
user.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
user = User.objects.get(pk=user.pk)
|
||||||
|
self.client.force_authenticate(user)
|
||||||
|
response = self.client.get(f"/api/documents/{doc.pk}/notes/")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/documents/{doc.pk}/notes/",
|
||||||
|
data={"note": "New"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
user.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="change_document"),
|
||||||
|
)
|
||||||
|
user = User.objects.get(pk=user.pk)
|
||||||
|
self.client.force_authenticate(user)
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/documents/{doc.pk}/notes/",
|
||||||
|
data={"note": "New"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
response = self.client.delete(
|
||||||
|
f"/api/documents/{doc.pk}/notes/?id={note.pk}",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
def test_delete_note(self) -> None:
|
def test_delete_note(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -3981,6 +4030,21 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
|
|
||||||
assign_perm("view_document", user1, doc)
|
assign_perm("view_document", user1, doc)
|
||||||
|
|
||||||
|
create_resp = self.client.post(
|
||||||
|
"/api/share_links/",
|
||||||
|
data={
|
||||||
|
"document": doc.pk,
|
||||||
|
"file_version": "original",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(create_resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
user1.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
user1 = User.objects.get(pk=user1.pk)
|
||||||
|
self.client.force_authenticate(user1)
|
||||||
create_resp = self.client.post(
|
create_resp = self.client.post(
|
||||||
"/api/share_links/",
|
"/api/share_links/",
|
||||||
data={
|
data={
|
||||||
|
|||||||
@@ -457,6 +457,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
def test_test_storage_path_requires_document_view_permission(self) -> None:
|
def test_test_storage_path_requires_document_view_permission(self) -> None:
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = User.objects.create_user(username="owner")
|
||||||
unprivileged = User.objects.create_user(username="unprivileged")
|
unprivileged = User.objects.create_user(username="unprivileged")
|
||||||
|
unprivileged.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
document = Document.objects.create(
|
document = Document.objects.create(
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
@@ -488,6 +491,23 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
assign_perm("view_document", viewer, document)
|
assign_perm("view_document", viewer, document)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=viewer)
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.ENDPOINT}test/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"document": document.id,
|
||||||
|
"path": "path/{{ title }}",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
viewer.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
viewer = User.objects.get(pk=viewer.pk)
|
||||||
self.client.force_authenticate(user=viewer)
|
self.client.force_authenticate(user=viewer)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f"{self.ENDPOINT}test/",
|
f"{self.ENDPOINT}test/",
|
||||||
@@ -530,6 +550,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
password="password",
|
password="password",
|
||||||
email="owner@example.com",
|
email="owner@example.com",
|
||||||
)
|
)
|
||||||
|
owner.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
document = Document.objects.create(
|
document = Document.objects.create(
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
@@ -605,6 +628,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
checksum="123",
|
checksum="123",
|
||||||
)
|
)
|
||||||
assign_perm("view_document", viewer, document)
|
assign_perm("view_document", viewer, document)
|
||||||
|
viewer.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
|
||||||
self.client.force_authenticate(user=viewer)
|
self.client.force_authenticate(user=viewer)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -692,6 +718,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
document.tags.add(private_tag)
|
document.tags.add(private_tag)
|
||||||
assign_perm("view_document", viewer, document)
|
assign_perm("view_document", viewer, document)
|
||||||
|
viewer.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
|
||||||
self.client.force_authenticate(user=viewer)
|
self.client.force_authenticate(user=viewer)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -745,6 +774,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
value_int=42,
|
value_int=42,
|
||||||
)
|
)
|
||||||
assign_perm("view_document", viewer, document)
|
assign_perm("view_document", viewer, document)
|
||||||
|
viewer.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
|
||||||
self.client.force_authenticate(user=viewer)
|
self.client.force_authenticate(user=viewer)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(Document.global_objects.count(), 0)
|
self.assertEqual(Document.global_objects.count(), 0)
|
||||||
|
|
||||||
|
def test_trash_list_requires_global_document_view_permission(self) -> None:
|
||||||
|
user = User.objects.create_user(username="trash_owner")
|
||||||
|
document = Document.objects.create(title="Owned", owner=user)
|
||||||
|
document.delete()
|
||||||
|
self.client.force_authenticate(user)
|
||||||
|
|
||||||
|
response = self.client.get("/api/trash/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
def test_trash_api_empty_all(self) -> None:
|
def test_trash_api_empty_all(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -194,6 +194,48 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(Workflow.objects.count(), 2)
|
self.assertEqual(Workflow.objects.count(), 2)
|
||||||
|
|
||||||
|
def test_api_create_workflow_ignores_nested_action_id(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An existing workflow action
|
||||||
|
WHEN:
|
||||||
|
- API request to create a workflow includes that action's ID
|
||||||
|
THEN:
|
||||||
|
- A new action is created without changing the existing action
|
||||||
|
"""
|
||||||
|
original_title = self.action.assign_title
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "Workflow 2",
|
||||||
|
"order": 1,
|
||||||
|
"triggers": [
|
||||||
|
{
|
||||||
|
"sources": [DocumentSource.ApiUpload],
|
||||||
|
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
"filter_filename": "*",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": self.action.id,
|
||||||
|
"assign_title": "New Action Title",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
self.action.refresh_from_db()
|
||||||
|
self.assertEqual(self.action.assign_title, original_title)
|
||||||
|
new_action = Workflow.objects.get(name="Workflow 2").actions.get()
|
||||||
|
self.assertNotEqual(new_action.id, self.action.id)
|
||||||
|
self.assertEqual(new_action.assign_title, "New Action Title")
|
||||||
|
|
||||||
def test_api_create_workflow_nested(self) -> None:
|
def test_api_create_workflow_nested(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -309,6 +309,9 @@ class TestEmailDocumentPermissionBoundary:
|
|||||||
):
|
):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = User.objects.create_user(username="owner")
|
||||||
requester = User.objects.create_user(username="requester")
|
requester = User.objects.create_user(username="requester")
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
hidden = DocumentFactory(owner=owner)
|
hidden = DocumentFactory(owner=owner)
|
||||||
|
|
||||||
@@ -364,6 +367,27 @@ class TestBulkEditChangePermissionBoundary:
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestBulkDownloadPermissionChecksRootDocument:
|
class TestBulkDownloadPermissionChecksRootDocument:
|
||||||
|
def test_download_requires_global_view_permission(
|
||||||
|
self,
|
||||||
|
rest_api_client,
|
||||||
|
paperless_dirs,
|
||||||
|
_media_settings,
|
||||||
|
):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
requester = User.objects.create_user(username="requester")
|
||||||
|
root = DocumentFactory(owner=owner)
|
||||||
|
root.source_path.write_bytes(b"%PDF-1.4 test")
|
||||||
|
assign_perm("view_document", requester, root)
|
||||||
|
rest_api_client.force_authenticate(user=requester)
|
||||||
|
|
||||||
|
response = rest_api_client.post(
|
||||||
|
"/api/documents/bulk_download/",
|
||||||
|
{"documents": [root.pk]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||||
|
|
||||||
def test_permission_checked_on_root_not_on_version(
|
def test_permission_checked_on_root_not_on_version(
|
||||||
self,
|
self,
|
||||||
rest_api_client,
|
rest_api_client,
|
||||||
@@ -372,6 +396,9 @@ class TestBulkDownloadPermissionChecksRootDocument:
|
|||||||
):
|
):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = User.objects.create_user(username="owner")
|
||||||
requester = User.objects.create_user(username="requester")
|
requester = User.objects.create_user(username="requester")
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
root = DocumentFactory(owner=owner)
|
root = DocumentFactory(owner=owner)
|
||||||
# a version of root that the requester has NOT been individually granted
|
# a version of root that the requester has NOT been individually granted
|
||||||
@@ -396,6 +423,9 @@ class TestBulkDownloadPermissionChecksRootDocument:
|
|||||||
# `stranger` case) can't tell the two apart, since they're denied
|
# `stranger` case) can't tell the two apart, since they're denied
|
||||||
# either way.
|
# either way.
|
||||||
version_only_grantee = User.objects.create_user(username="version_only_grantee")
|
version_only_grantee = User.objects.create_user(username="version_only_grantee")
|
||||||
|
version_only_grantee.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
assign_perm("view_document", version_only_grantee, version)
|
assign_perm("view_document", version_only_grantee, version)
|
||||||
rest_api_client.force_authenticate(user=version_only_grantee)
|
rest_api_client.force_authenticate(user=version_only_grantee)
|
||||||
response = rest_api_client.post(
|
response = rest_api_client.post(
|
||||||
@@ -417,6 +447,9 @@ class TestTrashRestorePermissionBoundary:
|
|||||||
):
|
):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = User.objects.create_user(username="owner")
|
||||||
requester = User.objects.create_user(username="requester")
|
requester = User.objects.create_user(username="requester")
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="delete_document"),
|
||||||
|
)
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
assign_perm("view_document", requester, doc) # view only, NOT delete
|
assign_perm("view_document", requester, doc) # view only, NOT delete
|
||||||
@@ -435,6 +468,9 @@ class TestTrashRestorePermissionBoundary:
|
|||||||
):
|
):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = User.objects.create_user(username="owner")
|
||||||
requester = User.objects.create_user(username="requester")
|
requester = User.objects.create_user(username="requester")
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="delete_document"),
|
||||||
|
)
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
assign_perm("delete_document", requester, doc)
|
assign_perm("delete_document", requester, doc)
|
||||||
@@ -447,6 +483,22 @@ class TestTrashRestorePermissionBoundary:
|
|||||||
)
|
)
|
||||||
assert response.status_code == HTTPStatus.OK
|
assert response.status_code == HTTPStatus.OK
|
||||||
|
|
||||||
|
def test_restore_requires_global_delete_permission(self, rest_api_client):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
requester = User.objects.create_user(username="requester")
|
||||||
|
rest_api_client.force_authenticate(user=requester)
|
||||||
|
doc = DocumentFactory(owner=owner)
|
||||||
|
assign_perm("delete_document", requester, doc)
|
||||||
|
doc.delete()
|
||||||
|
|
||||||
|
response = rest_api_client.post(
|
||||||
|
"/api/trash/",
|
||||||
|
{"documents": [doc.pk], "action": "restore"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestTrashViewExcludesExplicitlyGrantedDocuments:
|
class TestTrashViewExcludesExplicitlyGrantedDocuments:
|
||||||
@@ -463,6 +515,9 @@ class TestTrashViewExcludesExplicitlyGrantedDocuments:
|
|||||||
def test_explicit_grant_does_not_leak_trashed_document(self, rest_api_client):
|
def test_explicit_grant_does_not_leak_trashed_document(self, rest_api_client):
|
||||||
owner = User.objects.create_user(username="trash_owner")
|
owner = User.objects.create_user(username="trash_owner")
|
||||||
grantee = User.objects.create_user(username="trash_grantee")
|
grantee = User.objects.create_user(username="trash_grantee")
|
||||||
|
grantee.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
doc.delete() # soft delete
|
doc.delete() # soft delete
|
||||||
assign_perm("view_document", grantee, doc)
|
assign_perm("view_document", grantee, doc)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
import regex
|
import regex
|
||||||
from django.conf import settings
|
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from documents.regex import safe_regex_finditer
|
from documents.regex import safe_regex_finditer
|
||||||
@@ -10,12 +9,6 @@ from documents.regex import safe_regex_sub
|
|||||||
from documents.regex import validate_regex_pattern
|
from documents.regex import validate_regex_pattern
|
||||||
|
|
||||||
|
|
||||||
def test_regex_timeout_uses_configured_setting() -> None:
|
|
||||||
from documents.regex import REGEX_TIMEOUT_SECONDS
|
|
||||||
|
|
||||||
assert REGEX_TIMEOUT_SECONDS == settings.MATCH_REGEX_TIMEOUT_SECONDS
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidateRegexPattern:
|
class TestValidateRegexPattern:
|
||||||
def test_valid_pattern(self) -> None:
|
def test_valid_pattern(self) -> None:
|
||||||
validate_regex_pattern(r"\d+")
|
validate_regex_pattern(r"\d+")
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ from pathlib import Path
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from guardian.shortcuts import assign_perm
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
@@ -48,6 +50,37 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
|||||||
delay_mock.assert_called_once()
|
delay_mock.assert_called_once()
|
||||||
self.assertEqual(delay_mock.call_args.kwargs["kwargs"]["bundle_id"], bundle.pk)
|
self.assertEqual(delay_mock.call_args.kwargs["kwargs"]["bundle_id"], bundle.pk)
|
||||||
|
|
||||||
|
@mock.patch("documents.views.build_share_link_bundle.apply_async")
|
||||||
|
def test_create_bundle_requires_global_document_view_permission(
|
||||||
|
self,
|
||||||
|
delay_mock,
|
||||||
|
) -> None:
|
||||||
|
owner = User.objects.create_user(username="document_owner")
|
||||||
|
requester = User.objects.create_user(username="bundle_creator")
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="add_sharelinkbundle"),
|
||||||
|
)
|
||||||
|
document = DocumentFactory.create(owner=owner)
|
||||||
|
assign_perm("view_document", requester, document)
|
||||||
|
self.client.force_authenticate(requester)
|
||||||
|
payload = {
|
||||||
|
"document_ids": [document.pk],
|
||||||
|
"file_version": ShareLink.FileVersion.ARCHIVE,
|
||||||
|
"expiration_days": 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(self.ENDPOINT, payload, format="json")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
requester = User.objects.get(pk=requester.pk)
|
||||||
|
self.client.force_authenticate(requester)
|
||||||
|
response = self.client.post(self.ENDPOINT, payload, format="json")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
delay_mock.assert_called_once()
|
||||||
|
|
||||||
def test_create_bundle_rejects_missing_documents(self) -> None:
|
def test_create_bundle_rejects_missing_documents(self) -> None:
|
||||||
payload = {
|
payload = {
|
||||||
"document_ids": [9999],
|
"document_ids": [9999],
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ class TestViews(DirectoriesMixin, TestCase):
|
|||||||
codename__contains="sharelink",
|
codename__contains="sharelink",
|
||||||
)
|
)
|
||||||
self.user.user_permissions.add(*sharelink_permissions)
|
self.user.user_permissions.add(*sharelink_permissions)
|
||||||
|
self.user.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
self.user.save()
|
self.user.save()
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
@@ -202,6 +205,9 @@ class TestViews(DirectoriesMixin, TestCase):
|
|||||||
codename__contains="sharelink",
|
codename__contains="sharelink",
|
||||||
)
|
)
|
||||||
self.user.user_permissions.add(*sharelink_permissions)
|
self.user.user_permissions.add(*sharelink_permissions)
|
||||||
|
self.user.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
create_response = self.client.post(
|
create_response = self.client.post(
|
||||||
|
|||||||
+11
-4
@@ -170,6 +170,7 @@ from documents.permissions import AcknowledgeTasksPermissions
|
|||||||
from documents.permissions import PaperlessAdminPermissions
|
from documents.permissions import PaperlessAdminPermissions
|
||||||
from documents.permissions import PaperlessNotePermissions
|
from documents.permissions import PaperlessNotePermissions
|
||||||
from documents.permissions import PaperlessObjectPermissions
|
from documents.permissions import PaperlessObjectPermissions
|
||||||
|
from documents.permissions import TrashPermissions
|
||||||
from documents.permissions import ViewDocumentsPermissions
|
from documents.permissions import ViewDocumentsPermissions
|
||||||
from documents.permissions import annotate_document_count_by_ids
|
from documents.permissions import annotate_document_count_by_ids
|
||||||
from documents.permissions import annotate_document_count_for_related_queryset
|
from documents.permissions import annotate_document_count_for_related_queryset
|
||||||
@@ -3519,7 +3520,7 @@ class PostDocumentView(GenericAPIView[Any]):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
class SelectionDataView(GenericAPIView[Any]):
|
class SelectionDataView(GenericAPIView[Any]):
|
||||||
permission_classes = (IsAuthenticated,)
|
permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
|
||||||
serializer_class = DocumentListSerializer
|
serializer_class = DocumentListSerializer
|
||||||
parser_classes = (parsers.MultiPartParser, parsers.JSONParser)
|
parser_classes = (parsers.MultiPartParser, parsers.JSONParser)
|
||||||
|
|
||||||
@@ -4010,7 +4011,7 @@ class StatisticsView(GenericAPIView[Any]):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]):
|
class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]):
|
||||||
permission_classes = (IsAuthenticated,)
|
permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
|
||||||
serializer_class = BulkDownloadSerializer
|
serializer_class = BulkDownloadSerializer
|
||||||
parser_classes = (parsers.JSONParser,)
|
parser_classes = (parsers.JSONParser,)
|
||||||
|
|
||||||
@@ -4109,7 +4110,7 @@ class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Storag
|
|||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
if self.action == "test":
|
if self.action == "test":
|
||||||
# Test action does not require object level permissions
|
# Test action does not require object level permissions
|
||||||
self.permission_classes = (IsAuthenticated,)
|
self.permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
|
||||||
return super().get_permissions()
|
return super().get_permissions()
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
def destroy(self, request, *args, **kwargs):
|
||||||
@@ -4676,6 +4677,12 @@ class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
|
|||||||
filterset_class = ShareLinkBundleFilterSet
|
filterset_class = ShareLinkBundleFilterSet
|
||||||
ordering_fields = ("created", "expiration", "status")
|
ordering_fields = ("created", "expiration", "status")
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
permissions = super().get_permissions()
|
||||||
|
if self.action == "create":
|
||||||
|
permissions.append(ViewDocumentsPermissions())
|
||||||
|
return permissions
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return (
|
return (
|
||||||
super()
|
super()
|
||||||
@@ -5494,7 +5501,7 @@ class SystemStatusView(PassUserMixin):
|
|||||||
|
|
||||||
|
|
||||||
class TrashView(ListModelMixin, PassUserMixin):
|
class TrashView(ListModelMixin, PassUserMixin):
|
||||||
permission_classes = (IsAuthenticated,)
|
permission_classes = (IsAuthenticated, TrashPermissions)
|
||||||
serializer_class = TrashSerializer
|
serializer_class = TrashSerializer
|
||||||
|
|
||||||
class _TrashPermittedObjectsFilter(PermittedObjectsFilter):
|
class _TrashPermittedObjectsFilter(PermittedObjectsFilter):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-09-11 15:47+0000\n"
|
"POT-Creation-Date: 2026-09-12 23:18+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -1632,7 +1632,7 @@ msgid "workflow runs"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:525 documents/serialisers.py:882
|
#: documents/serialisers.py:525 documents/serialisers.py:882
|
||||||
#: documents/serialisers.py:2849 documents/views.py:318 documents/views.py:2693
|
#: documents/serialisers.py:2854 documents/views.py:319 documents/views.py:2694
|
||||||
#: paperless_mail/serialisers.py:156
|
#: paperless_mail/serialisers.py:156
|
||||||
msgid "Insufficient permissions."
|
msgid "Insufficient permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1641,39 +1641,39 @@ msgstr ""
|
|||||||
msgid "Invalid color."
|
msgid "Invalid color."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2326
|
#: documents/serialisers.py:2327
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "File type %(type)s not supported"
|
msgid "File type %(type)s not supported"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2370
|
#: documents/serialisers.py:2371
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field id must be an integer: %(id)s"
|
msgid "Custom field id must be an integer: %(id)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2377
|
#: documents/serialisers.py:2378
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field with id %(id)s does not exist"
|
msgid "Custom field with id %(id)s does not exist"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2394 documents/serialisers.py:2404
|
#: documents/serialisers.py:2395 documents/serialisers.py:2405
|
||||||
msgid ""
|
msgid ""
|
||||||
"Custom fields must be a list of integers or an object mapping ids to values."
|
"Custom fields must be a list of integers or an object mapping ids to values."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2399
|
#: documents/serialisers.py:2400
|
||||||
msgid "Some custom fields don't exist or were specified twice."
|
msgid "Some custom fields don't exist or were specified twice."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2546
|
#: documents/serialisers.py:2547
|
||||||
msgid "Invalid variable detected."
|
msgid "Invalid variable detected."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2905
|
#: documents/serialisers.py:2910
|
||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2935 documents/views.py:4700
|
#: documents/serialisers.py:2940 documents/views.py:4707
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Documents not found: %(ids)s"
|
msgid "Documents not found: %(ids)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1941,40 +1941,40 @@ msgstr ""
|
|||||||
msgid "Unable to parse URI {value}"
|
msgid "Unable to parse URI {value}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:311 documents/views.py:2690
|
#: documents/views.py:312 documents/views.py:2691
|
||||||
msgid "Invalid more_like_id"
|
msgid "Invalid more_like_id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1645
|
#: documents/views.py:1646
|
||||||
msgid "Invalid AI configuration."
|
msgid "Invalid AI configuration."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1656
|
#: documents/views.py:1657
|
||||||
msgid "AI backend request timed out."
|
msgid "AI backend request timed out."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1668
|
#: documents/views.py:1669
|
||||||
msgid "AI backend rejected the request. Check logs for details."
|
msgid "AI backend rejected the request. Check logs for details."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:2515 documents/views.py:2836
|
#: documents/views.py:2516 documents/views.py:2837
|
||||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4713
|
#: documents/views.py:4720
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Insufficient permissions to share document %(id)s."
|
msgid "Insufficient permissions to share document %(id)s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4759
|
#: documents/views.py:4766
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4823
|
#: documents/views.py:4830
|
||||||
msgid "The share link bundle is still being prepared. Please try again later."
|
msgid "The share link bundle is still being prepared. Please try again later."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4837
|
#: documents/views.py:4844
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -102,10 +102,6 @@ CLASSIFIER_MATCH_THRESHOLD: Final[float] = get_float_from_env(
|
|||||||
"PAPERLESS_CLASSIFIER_MATCH_THRESHOLD",
|
"PAPERLESS_CLASSIFIER_MATCH_THRESHOLD",
|
||||||
0.6,
|
0.6,
|
||||||
)
|
)
|
||||||
MATCH_REGEX_TIMEOUT_SECONDS: Final[float] = get_float_from_env(
|
|
||||||
"PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS",
|
|
||||||
0.1,
|
|
||||||
)
|
|
||||||
LLM_INDEX_DIR = DATA_DIR / "llm_index"
|
LLM_INDEX_DIR = DATA_DIR / "llm_index"
|
||||||
LLM_INDEX_LOCK = LLM_INDEX_DIR / "index.lock"
|
LLM_INDEX_LOCK = LLM_INDEX_DIR / "index.lock"
|
||||||
# Cross-process read/write lock guarding the LLM index compaction/migration
|
# Cross-process read/write lock guarding the LLM index compaction/migration
|
||||||
|
|||||||
@@ -854,6 +854,36 @@ class TestAPIProcessedMails(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_bulk_delete_requires_global_delete_permission(self) -> None:
|
||||||
|
owner = User.objects.create_user(username="mail_owner")
|
||||||
|
requester = User.objects.create_user(username="mail_deleter")
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="add_processedmail"),
|
||||||
|
)
|
||||||
|
mail = ProcessedMailFactory(owner=owner)
|
||||||
|
assign_perm("delete_processedmail", requester, mail)
|
||||||
|
self.client.force_authenticate(requester)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.ENDPOINT}bulk_delete/",
|
||||||
|
data={"mail_ids": [mail.pk]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="delete_processedmail"),
|
||||||
|
)
|
||||||
|
requester = User.objects.get(pk=requester.pk)
|
||||||
|
self.client.force_authenticate(requester)
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.ENDPOINT}bulk_delete/",
|
||||||
|
data={"mail_ids": [mail.pk]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertFalse(ProcessedMail.objects.filter(pk=mail.pk).exists())
|
||||||
|
|
||||||
def test_bulk_delete_processed_mails_rejects_mixed_batch_atomically(self) -> None:
|
def test_bulk_delete_processed_mails_rejects_mixed_batch_atomically(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from rest_framework import serializers
|
|||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.filters import OrderingFilter
|
from rest_framework.filters import OrderingFilter
|
||||||
from rest_framework.generics import GenericAPIView
|
from rest_framework.generics import GenericAPIView
|
||||||
|
from rest_framework.permissions import BasePermission
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
@@ -44,6 +45,15 @@ from paperless_mail.serialisers import ProcessedMailSerializer
|
|||||||
from paperless_mail.tasks import process_mail_accounts
|
from paperless_mail.tasks import process_mail_accounts
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteProcessedMailPermissions(BasePermission):
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
return bool(
|
||||||
|
request.user
|
||||||
|
and request.user.is_authenticated
|
||||||
|
and request.user.has_perm("paperless_mail.delete_processedmail"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
test=extend_schema(
|
test=extend_schema(
|
||||||
operation_id="mail_account_test",
|
operation_id="mail_account_test",
|
||||||
@@ -206,7 +216,11 @@ class ProcessedMailViewSet(PassUserMixin, ReadOnlyModelViewSet[ProcessedMail]):
|
|||||||
|
|
||||||
queryset = ProcessedMail.objects.all().order_by("-processed")
|
queryset = ProcessedMail.objects.all().order_by("-processed")
|
||||||
|
|
||||||
@action(methods=["post"], detail=False)
|
@action(
|
||||||
|
methods=["post"],
|
||||||
|
detail=False,
|
||||||
|
permission_classes=[IsAuthenticated, DeleteProcessedMailPermissions],
|
||||||
|
)
|
||||||
def bulk_delete(self, request):
|
def bulk_delete(self, request):
|
||||||
mail_ids = request.data.get("mail_ids", [])
|
mail_ids = request.data.get("mail_ids", [])
|
||||||
if not isinstance(mail_ids, list) or not all(
|
if not isinstance(mail_ids, list) or not all(
|
||||||
|
|||||||
Reference in New Issue
Block a user