Compare commits

...
119 changed files with 61793 additions and 49471 deletions
+8
View File
@@ -1209,6 +1209,14 @@ 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.
+30 -23
View File
@@ -2068,7 +2068,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">215</context> <context context-type="linenumber">219</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="131016739441837046" datatype="html"> <trans-unit id="131016739441837046" datatype="html">
@@ -2079,7 +2079,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">227</context> <context context-type="linenumber">231</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3643866539136239218" datatype="html"> <trans-unit id="3643866539136239218" datatype="html">
@@ -2415,11 +2415,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">317</context> <context context-type="linenumber">321</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">351</context> <context context-type="linenumber">355</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
@@ -2612,11 +2612,18 @@
<context context-type="linenumber">100</context> <context context-type="linenumber">100</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5672013191898828327" datatype="html">
<source>Apply AI Suggestions</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">104</context>
</context-group>
</trans-unit>
<trans-unit id="9172233176401579786" datatype="html"> <trans-unit id="9172233176401579786" datatype="html">
<source>Scheduled</source> <source>Scheduled</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">110</context> <context context-type="linenumber">114</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
@@ -2627,7 +2634,7 @@
<source>Web UI</source> <source>Web UI</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">112</context> <context context-type="linenumber">116</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
@@ -2638,7 +2645,7 @@
<source>API Upload</source> <source>API Upload</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">115</context> <context context-type="linenumber">119</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
@@ -2649,106 +2656,106 @@
<source>Folder Consume</source> <source>Folder Consume</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">119</context> <context context-type="linenumber">123</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1456901843819961615" datatype="html"> <trans-unit id="1456901843819961615" datatype="html">
<source>Email Consume</source> <source>Email Consume</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">123</context> <context context-type="linenumber">127</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="29832309535656200" datatype="html"> <trans-unit id="29832309535656200" datatype="html">
<source>System</source> <source>System</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">125</context> <context context-type="linenumber">129</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2233560223291461480" datatype="html"> <trans-unit id="2233560223291461480" datatype="html">
<source>Manual</source> <source>Manual</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">126</context> <context context-type="linenumber">130</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5404910960991552159" datatype="html"> <trans-unit id="5404910960991552159" datatype="html">
<source>Dismiss selected</source> <source>Dismiss selected</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">239</context> <context context-type="linenumber">243</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9169677036332103838" datatype="html"> <trans-unit id="9169677036332103838" datatype="html">
<source>Dismiss visible</source> <source>Dismiss visible</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">240</context> <context context-type="linenumber">244</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3169751690815214293" datatype="html"> <trans-unit id="3169751690815214293" datatype="html">
<source>Confirm Dismiss</source> <source>Confirm Dismiss</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">314</context> <context context-type="linenumber">318</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5029621907742319073" datatype="html"> <trans-unit id="5029621907742319073" datatype="html">
<source>Dismiss <x id="PH" equiv-text="tasks.size"/> tasks?</source> <source>Dismiss <x id="PH" equiv-text="tasks.size"/> tasks?</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">315</context> <context context-type="linenumber">319</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3597309129998924778" datatype="html"> <trans-unit id="3597309129998924778" datatype="html">
<source>Error dismissing tasks</source> <source>Error dismissing tasks</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">326</context> <context context-type="linenumber">330</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">360</context> <context context-type="linenumber">364</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2132179171926568807" datatype="html"> <trans-unit id="2132179171926568807" datatype="html">
<source>Error dismissing task</source> <source>Error dismissing task</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">338</context> <context context-type="linenumber">342</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1323591410517879795" datatype="html"> <trans-unit id="1323591410517879795" datatype="html">
<source>Confirm Dismiss All</source> <source>Confirm Dismiss All</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">348</context> <context context-type="linenumber">352</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4157200209636243740" datatype="html"> <trans-unit id="4157200209636243740" datatype="html">
<source>Dismiss all <x id="PH" equiv-text="this.totalTasks()"/> tasks?</source> <source>Dismiss all <x id="PH" equiv-text="this.totalTasks()"/> tasks?</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">349</context> <context context-type="linenumber">353</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8149502458056418229" datatype="html"> <trans-unit id="8149502458056418229" datatype="html">
<source>Success. New document id <x id="PH" equiv-text="documentId"/> created</source> <source>Success. New document id <x id="PH" equiv-text="documentId"/> created</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">408</context> <context context-type="linenumber">412</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8760066891202884337" datatype="html"> <trans-unit id="8760066891202884337" datatype="html">
<source>Duplicate of document #<x id="PH" equiv-text="duplicateOf"/></source> <source>Duplicate of document #<x id="PH" equiv-text="duplicateOf"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">418</context> <context context-type="linenumber">422</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">452</context> <context context-type="linenumber">456</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3418677553313974490" datatype="html"> <trans-unit id="3418677553313974490" datatype="html">
@@ -99,6 +99,10 @@ const TASK_TYPE_OPTIONS: Array<{
value: PaperlessTaskType.BulkDelete, value: PaperlessTaskType.BulkDelete,
label: $localize`Bulk Delete`, label: $localize`Bulk Delete`,
}, },
{
value: PaperlessTaskType.ApplyAiSuggestions,
label: $localize`Apply AI Suggestions`,
},
] ]
const TRIGGER_SOURCE_OPTIONS: Array<{ const TRIGGER_SOURCE_OPTIONS: Array<{
+1
View File
@@ -12,6 +12,7 @@ export enum PaperlessTaskType {
ReprocessDocument = 'reprocess_document', ReprocessDocument = 'reprocess_document',
BuildShareLink = 'build_share_link', BuildShareLink = 'build_share_link',
BulkDelete = 'bulk_delete', BulkDelete = 'bulk_delete',
ApplyAiSuggestions = 'apply_ai_suggestions',
} }
export enum PaperlessTaskTriggerSource { export enum PaperlessTaskTriggerSource {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16 -7
View File
@@ -899,17 +899,26 @@ def edit_pdf(
pdf_docs: list[pikepdf.Pdf] = [] pdf_docs: list[pikepdf.Pdf] = []
try: try:
if not operations:
raise ValueError("Output document index is out of bounds")
max_idx = max(op.get("doc", 0) for op in operations)
if update_document and max_idx > 0:
logger.error(
"Update requested but multiple output documents specified",
)
raise ValueError("Multiple output documents specified")
if any(
op.get("doc", 0) < 0 or op.get("doc", 0) >= len(operations)
for op in operations
):
raise ValueError("Output document index is out of bounds")
with pikepdf.open(pair.source_doc.source_path) as src: with pikepdf.open(pair.source_doc.source_path) as src:
# prepare output documents # prepare output documents
max_idx = max(op.get("doc", 0) for op in operations)
pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)] pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)]
if update_document and len(pdf_docs) > 1:
logger.error(
"Update requested but multiple output documents specified",
)
raise ValueError("Multiple output documents specified")
for op in operations: for op in operations:
dst = pdf_docs[op.get("doc", 0)] dst = pdf_docs[op.get("doc", 0)]
page = src.pages[op["page"] - 1] page = src.pages[op["page"] - 1]
+29 -4
View File
@@ -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):
+26 -5
View File
@@ -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
@@ -1749,7 +1750,7 @@ class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin): class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
operations = serializers.ListField(required=True) operations = serializers.ListField(required=True, allow_empty=False)
delete_original = serializers.BooleanField(required=False, default=False) delete_original = serializers.BooleanField(required=False, default=False)
update_document = serializers.BooleanField(required=False, default=False) update_document = serializers.BooleanField(required=False, default=False)
include_metadata = serializers.BooleanField(required=False, default=True) include_metadata = serializers.BooleanField(required=False, default=True)
@@ -1787,6 +1788,12 @@ class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMix
"update_document only allowed with a single output document", "update_document only allowed with a single output document",
) )
if any(
op.get("doc", 0) < 0 or op.get("doc", 0) >= len(operations)
for op in operations
):
raise serializers.ValidationError("doc index is out of bounds")
doc = Document.objects.get(id=documents[0]) doc = Document.objects.get(id=documents[0])
if doc.page_count: if doc.page_count:
for op in operations: for op in operations:
@@ -2123,6 +2130,8 @@ class BulkEditSerializer(
raise serializers.ValidationError("operations not specified") raise serializers.ValidationError("operations not specified")
if not isinstance(parameters["operations"], list): if not isinstance(parameters["operations"], list):
raise serializers.ValidationError("operations must be a list") raise serializers.ValidationError("operations must be a list")
if not parameters["operations"]:
raise serializers.ValidationError("operations must not be empty")
for op in parameters["operations"]: for op in parameters["operations"]:
if not isinstance(op, dict): if not isinstance(op, dict):
raise serializers.ValidationError("invalid operation entry") raise serializers.ValidationError("invalid operation entry")
@@ -2150,6 +2159,12 @@ class BulkEditSerializer(
"update_document only allowed with a single output document", "update_document only allowed with a single output document",
) )
if any(
op.get("doc", 0) < 0 or op.get("doc", 0) >= len(parameters["operations"])
for op in parameters["operations"]
):
raise serializers.ValidationError("doc index is out of bounds")
doc = Document.objects.get(id=document_id) doc = Document.objects.get(id=document_id)
# doc existence is already validated # doc existence is already validated
if doc.page_count: if doc.page_count:
@@ -2839,10 +2854,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 +3622,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)
+11 -6
View File
@@ -1189,13 +1189,18 @@ def before_task_publish_handler(
trigger_source = _determine_trigger_source(headers) trigger_source = _determine_trigger_source(headers)
owner_id = _extract_owner_id(task_type, task_kwargs) owner_id = _extract_owner_id(task_type, task_kwargs)
PaperlessTask.objects.create( # A retried task is republished with the same task_id, so this fires
# again for it; get_or_create keeps the original PENDING record
# instead of raising a duplicate-key IntegrityError on the retry.
PaperlessTask.objects.get_or_create(
task_id=task_id, task_id=task_id,
task_type=task_type, defaults={
trigger_source=trigger_source, "task_type": task_type,
status=PaperlessTask.Status.PENDING, "trigger_source": trigger_source,
input_data=input_data, "status": PaperlessTask.Status.PENDING,
owner_id=owner_id, "input_data": input_data,
"owner_id": owner_id,
},
) )
except Exception: # pragma: no cover except Exception: # pragma: no cover
logger.exception("Creating PaperlessTask failed") logger.exception("Creating PaperlessTask failed")
@@ -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
+70 -1
View File
@@ -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:
@@ -1636,6 +1649,40 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_legacy_bulk_edit_rejects_out_of_bounds_pdf_doc_index(self) -> None:
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "edit_pdf",
"parameters": {
"operations": [{"page": 1, "doc": 2**32}],
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"doc index is out of bounds", response.content)
def test_legacy_bulk_edit_rejects_empty_pdf_operations(self) -> None:
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "edit_pdf",
"parameters": {"operations": []},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"operations must not be empty", response.content)
@mock.patch("documents.views.bulk_edit.edit_pdf") @mock.patch("documents.views.bulk_edit.edit_pdf")
def test_edit_pdf(self, m) -> None: def test_edit_pdf(self, m) -> None:
self.setup_mock(m, "edit_pdf") self.setup_mock(m, "edit_pdf")
@@ -1686,6 +1733,13 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"Expected a list of items", response.content) self.assertIn(b"Expected a list of items", response.content)
response = self.client.post(
"/api/documents/edit_pdf/",
{"documents": [self.doc2.id], "operations": []},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
response = self.client.post( response = self.client.post(
"/api/documents/edit_pdf/", "/api/documents/edit_pdf/",
json.dumps( json.dumps(
@@ -1738,6 +1792,21 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"doc must be an integer", response.content) self.assertIn(b"doc must be an integer", response.content)
for doc_index in (-1, 2**32):
with self.subTest(doc_index=doc_index):
response = self.client.post(
"/api/documents/edit_pdf/",
json.dumps(
{
"documents": [self.doc2.id],
"operations": [{"page": 1, "doc": doc_index}],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"doc index is out of bounds", response.content)
response = self.client.post( response = self.client.post(
"/api/documents/edit_pdf/", "/api/documents/edit_pdf/",
json.dumps( json.dumps(
+64
View File
@@ -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={
+32
View File
@@ -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(
+10
View File
@@ -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:
+42
View File
@@ -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:
+10
View File
@@ -1642,6 +1642,16 @@ class TestPDFActions(DirectoriesMixin, TestCase):
mock_group.assert_not_called() mock_group.assert_not_called()
mock_consume_file.assert_not_called() mock_consume_file.assert_not_called()
@mock.patch("pikepdf.open")
def test_edit_pdf_rejects_invalid_operations(self, mock_open) -> None:
for operations in ([], [{"page": 1, "doc": 2**32}]):
with self.subTest(operations=operations):
with self.assertLogs("paperless.bulk_edit", level="ERROR"):
with self.assertRaisesRegex(ValueError, "index is out of bounds"):
bulk_edit.edit_pdf([self.doc2.id], operations)
mock_open.assert_not_called()
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay") @mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
@mock.patch("documents.tasks.consume_file.apply_async") @mock.patch("documents.tasks.consume_file.apply_async")
@mock.patch("documents.bulk_edit.tempfile.mkdtemp") @mock.patch("documents.bulk_edit.tempfile.mkdtemp")
@@ -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)
+7
View File
@@ -1,5 +1,6 @@
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
@@ -9,6 +10,12 @@ 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],
+11
View File
@@ -106,6 +106,17 @@ class TestBeforeTaskPublishHandler:
assert task.task_type == PaperlessTask.TaskType.TRAIN_CLASSIFIER assert task.task_type == PaperlessTask.TaskType.TRAIN_CLASSIFIER
assert task.trigger_source == PaperlessTask.TriggerSource.MANUAL assert task.trigger_source == PaperlessTask.TriggerSource.MANUAL
# A Celery retry republishes with the same task_id; this must not
# raise a duplicate-key IntegrityError, and must leave the original
# PENDING record alone.
send_publish(
"documents.tasks.train_classifier",
(),
{},
headers={"id": task_id},
)
assert PaperlessTask.objects.filter(task_id=task_id).count() == 1
def test_creates_task_for_sanity_check(self) -> None: def test_creates_task_for_sanity_check(self) -> None:
task_id = send_publish("documents.tasks.sanity_check", (), {}) task_id = send_publish("documents.tasks.sanity_check", (), {})
task = PaperlessTask.objects.get(task_id=task_id) task = PaperlessTask.objects.get(task_id=task_id)
+6
View File
@@ -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
View File
@@ -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):
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+56 -56
View File
@@ -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-13 22:13+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:2868 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:2341
#, 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:2385
#, 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:2392
#, 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:2409 documents/serialisers.py:2419
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:2414
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:2561
msgid "Invalid variable detected." msgid "Invalid variable detected."
msgstr "" msgstr ""
#: documents/serialisers.py:2905 #: documents/serialisers.py:2924
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:2954 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 ""
@@ -2258,151 +2258,151 @@ msgstr ""
msgid "paperless application settings" msgid "paperless application settings"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:556 #: paperless/settings/__init__.py:560
msgid "English (US)" msgid "English (US)"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:557 #: paperless/settings/__init__.py:561
msgid "Arabic" msgid "Arabic"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:558 #: paperless/settings/__init__.py:562
msgid "Afrikaans" msgid "Afrikaans"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:559 #: paperless/settings/__init__.py:563
msgid "Belarusian" msgid "Belarusian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:560 #: paperless/settings/__init__.py:564
msgid "Bulgarian" msgid "Bulgarian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:561 #: paperless/settings/__init__.py:565
msgid "Catalan" msgid "Catalan"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:562 #: paperless/settings/__init__.py:566
msgid "Czech" msgid "Czech"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:563 #: paperless/settings/__init__.py:567
msgid "Danish" msgid "Danish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:564 #: paperless/settings/__init__.py:568
msgid "German" msgid "German"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:565 #: paperless/settings/__init__.py:569
msgid "Greek" msgid "Greek"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:566 #: paperless/settings/__init__.py:570
msgid "English (GB)" msgid "English (GB)"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:567 #: paperless/settings/__init__.py:571
msgid "Spanish" msgid "Spanish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:568 #: paperless/settings/__init__.py:572
msgid "Persian" msgid "Persian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:569 #: paperless/settings/__init__.py:573
msgid "Finnish" msgid "Finnish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:570 #: paperless/settings/__init__.py:574
msgid "French" msgid "French"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:571 #: paperless/settings/__init__.py:575
msgid "Hungarian" msgid "Hungarian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:572 #: paperless/settings/__init__.py:576
msgid "Indonesian" msgid "Indonesian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:573 #: paperless/settings/__init__.py:577
msgid "Italian" msgid "Italian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:574 #: paperless/settings/__init__.py:578
msgid "Japanese" msgid "Japanese"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:575 #: paperless/settings/__init__.py:579
msgid "Korean" msgid "Korean"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:576 #: paperless/settings/__init__.py:580
msgid "Luxembourgish" msgid "Luxembourgish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:577 #: paperless/settings/__init__.py:581
msgid "Norwegian" msgid "Norwegian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:578 #: paperless/settings/__init__.py:582
msgid "Dutch" msgid "Dutch"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:579 #: paperless/settings/__init__.py:583
msgid "Polish" msgid "Polish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:580 #: paperless/settings/__init__.py:584
msgid "Portuguese (Brazil)" msgid "Portuguese (Brazil)"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:581 #: paperless/settings/__init__.py:585
msgid "Portuguese" msgid "Portuguese"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:582 #: paperless/settings/__init__.py:586
msgid "Romanian" msgid "Romanian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:583 #: paperless/settings/__init__.py:587
msgid "Russian" msgid "Russian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:584 #: paperless/settings/__init__.py:588
msgid "Slovak" msgid "Slovak"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:585 #: paperless/settings/__init__.py:589
msgid "Slovenian" msgid "Slovenian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:586 #: paperless/settings/__init__.py:590
msgid "Serbian" msgid "Serbian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:587 #: paperless/settings/__init__.py:591
msgid "Swedish" msgid "Swedish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:588 #: paperless/settings/__init__.py:592
msgid "Turkish" msgid "Turkish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:589 #: paperless/settings/__init__.py:593
msgid "Ukrainian" msgid "Ukrainian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:590 #: paperless/settings/__init__.py:594
msgid "Vietnamese" msgid "Vietnamese"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:591 #: paperless/settings/__init__.py:595
msgid "Chinese Simplified" msgid "Chinese Simplified"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:592 #: paperless/settings/__init__.py:596
msgid "Chinese Traditional" msgid "Chinese Traditional"
msgstr "" msgstr ""
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More