mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-14 13:47:58 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34f2385df4 | ||
|
|
1ff18656b7 | ||
|
|
57e0a17571 | ||
|
|
3fc85c82e1 | ||
|
|
54ce5f9f61 | ||
|
|
f5a7ab062e | ||
|
|
9a317844c2 | ||
|
|
fc4107d390 | ||
|
|
4acc0bed54 | ||
|
|
9425f53d5c |
@@ -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.
|
||||||
|
|||||||
@@ -1749,8 +1749,18 @@ class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
|
|||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
class PdfEditOperationSerializer(serializers.Serializer[dict[str, int]]):
|
||||||
|
page = serializers.IntegerField(min_value=1)
|
||||||
|
rotate = serializers.IntegerField(required=False)
|
||||||
|
doc = serializers.IntegerField(required=False, min_value=0)
|
||||||
|
|
||||||
|
|
||||||
class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
|
class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
|
||||||
operations = serializers.ListField(required=True, allow_empty=False)
|
operations = serializers.ListField(
|
||||||
|
child=PdfEditOperationSerializer(),
|
||||||
|
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)
|
||||||
@@ -1768,18 +1778,9 @@ class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMix
|
|||||||
)
|
)
|
||||||
|
|
||||||
operations = attrs["operations"]
|
operations = attrs["operations"]
|
||||||
if not isinstance(operations, list):
|
|
||||||
raise serializers.ValidationError("operations must be a list")
|
|
||||||
|
|
||||||
for op in operations:
|
if any(op.get("doc", 0) >= len(operations) for op in operations):
|
||||||
if not isinstance(op, dict):
|
raise serializers.ValidationError("doc index is out of bounds")
|
||||||
raise serializers.ValidationError("invalid operation entry")
|
|
||||||
if "page" not in op or not isinstance(op["page"], int):
|
|
||||||
raise serializers.ValidationError("page must be an integer")
|
|
||||||
if "rotate" in op and not isinstance(op["rotate"], int):
|
|
||||||
raise serializers.ValidationError("rotate must be an integer")
|
|
||||||
if "doc" in op and not isinstance(op["doc"], int):
|
|
||||||
raise serializers.ValidationError("doc must be an integer")
|
|
||||||
|
|
||||||
if attrs["update_document"]:
|
if attrs["update_document"]:
|
||||||
max_idx = max(op.get("doc", 0) for op in operations)
|
max_idx = max(op.get("doc", 0) for op in operations)
|
||||||
@@ -1788,16 +1789,10 @@ 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:
|
||||||
if op["page"] < 1 or op["page"] > doc.page_count:
|
if op["page"] > doc.page_count:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
|
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
|
||||||
)
|
)
|
||||||
@@ -2128,19 +2123,15 @@ class BulkEditSerializer(
|
|||||||
def _validate_parameters_edit_pdf(self, parameters, document_id) -> None:
|
def _validate_parameters_edit_pdf(self, parameters, document_id) -> None:
|
||||||
if "operations" not in parameters:
|
if "operations" not in parameters:
|
||||||
raise serializers.ValidationError("operations not specified")
|
raise serializers.ValidationError("operations not specified")
|
||||||
if not isinstance(parameters["operations"], list):
|
operations_field = serializers.ListField(
|
||||||
raise serializers.ValidationError("operations must be a list")
|
child=PdfEditOperationSerializer(),
|
||||||
if not parameters["operations"]:
|
allow_empty=False,
|
||||||
raise serializers.ValidationError("operations must not be empty")
|
)
|
||||||
for op in parameters["operations"]:
|
parameters["operations"] = operations_field.run_validation(
|
||||||
if not isinstance(op, dict):
|
parameters["operations"],
|
||||||
raise serializers.ValidationError("invalid operation entry")
|
)
|
||||||
if "page" not in op or not isinstance(op["page"], int):
|
operations = parameters["operations"]
|
||||||
raise serializers.ValidationError("page must be an integer")
|
|
||||||
if "rotate" in op and not isinstance(op["rotate"], int):
|
|
||||||
raise serializers.ValidationError("rotate must be an integer")
|
|
||||||
if "doc" in op and not isinstance(op["doc"], int):
|
|
||||||
raise serializers.ValidationError("doc must be an integer")
|
|
||||||
if "update_document" in parameters:
|
if "update_document" in parameters:
|
||||||
if not isinstance(parameters["update_document"], bool):
|
if not isinstance(parameters["update_document"], bool):
|
||||||
raise serializers.ValidationError("update_document must be a boolean")
|
raise serializers.ValidationError("update_document must be a boolean")
|
||||||
@@ -2152,24 +2143,21 @@ class BulkEditSerializer(
|
|||||||
else:
|
else:
|
||||||
parameters["include_metadata"] = True
|
parameters["include_metadata"] = True
|
||||||
|
|
||||||
|
if any(op.get("doc", 0) >= len(operations) for op in operations):
|
||||||
|
raise serializers.ValidationError("doc index is out of bounds")
|
||||||
|
|
||||||
if parameters["update_document"]:
|
if parameters["update_document"]:
|
||||||
max_idx = max(op.get("doc", 0) for op in parameters["operations"])
|
max_idx = max(op.get("doc", 0) for op in operations)
|
||||||
if max_idx > 0:
|
if max_idx > 0:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
"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:
|
||||||
for op in parameters["operations"]:
|
for op in operations:
|
||||||
if op["page"] < 1 or op["page"] > doc.page_count:
|
if op["page"] > doc.page_count:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
|
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1189,18 +1189,13 @@ 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)
|
||||||
|
|
||||||
# A retried task is republished with the same task_id, so this fires
|
PaperlessTask.objects.create(
|
||||||
# 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,
|
||||||
defaults={
|
task_type=task_type,
|
||||||
"task_type": task_type,
|
trigger_source=trigger_source,
|
||||||
"trigger_source": trigger_source,
|
status=PaperlessTask.Status.PENDING,
|
||||||
"status": PaperlessTask.Status.PENDING,
|
input_data=input_data,
|
||||||
"input_data": input_data,
|
owner_id=owner_id,
|
||||||
"owner_id": owner_id,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
logger.exception("Creating PaperlessTask failed")
|
logger.exception("Creating PaperlessTask failed")
|
||||||
|
|||||||
@@ -1667,22 +1667,6 @@ 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 index is out of bounds", response.content)
|
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")
|
||||||
@@ -1733,13 +1717,6 @@ 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(
|
||||||
@@ -1751,7 +1728,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(b"invalid operation entry", response.content)
|
self.assertIn(b"Expected a dictionary", response.content)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/edit_pdf/",
|
"/api/documents/edit_pdf/",
|
||||||
@@ -1764,7 +1741,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(b"page must be an integer", response.content)
|
self.assertIn(b"valid integer is required", response.content)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/edit_pdf/",
|
"/api/documents/edit_pdf/",
|
||||||
@@ -1777,7 +1754,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(b"rotate must be an integer", response.content)
|
self.assertIn(b"valid integer is required", response.content)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/edit_pdf/",
|
"/api/documents/edit_pdf/",
|
||||||
@@ -1790,9 +1767,16 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
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"valid integer is required", response.content)
|
||||||
|
|
||||||
for doc_index in (-1, 2**32):
|
# A negative doc index is rejected by PdfEditOperationSerializer's
|
||||||
|
# own min_value=0 field constraint, before the "doc index is out
|
||||||
|
# of bounds" object-level check (against len(operations)) ever
|
||||||
|
# runs -- hence the different expected message per case.
|
||||||
|
for doc_index, expected_message in (
|
||||||
|
(-1, b"greater than or equal to 0"),
|
||||||
|
(2**32, b"doc index is out of bounds"),
|
||||||
|
):
|
||||||
with self.subTest(doc_index=doc_index):
|
with self.subTest(doc_index=doc_index):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/edit_pdf/",
|
"/api/documents/edit_pdf/",
|
||||||
@@ -1805,7 +1789,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(b"doc index is out of bounds", response.content)
|
self.assertIn(expected_message, response.content)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/edit_pdf/",
|
"/api/documents/edit_pdf/",
|
||||||
@@ -1813,7 +1797,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
{
|
{
|
||||||
"documents": [self.doc2.id],
|
"documents": [self.doc2.id],
|
||||||
"update_document": True,
|
"update_document": True,
|
||||||
"operations": [{"page": 1, "doc": 1}, {"page": 2, "doc": 2}],
|
"operations": [{"page": 1, "doc": 0}, {"page": 2, "doc": 1}],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
@@ -1838,6 +1822,86 @@ 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"Invalid source_mode", response.content)
|
self.assertIn(b"Invalid source_mode", response.content)
|
||||||
|
|
||||||
|
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
||||||
|
def test_edit_pdf_rejects_empty_operations(self, m) -> None:
|
||||||
|
"""
|
||||||
|
An empty operations list previously reached bulk_edit.edit_pdf()
|
||||||
|
and crashed with `ValueError: max() iterable argument is empty`
|
||||||
|
(via `max(op.get("doc", 0) for op in operations)`) whenever
|
||||||
|
update_document was true. Must now be rejected up front.
|
||||||
|
"""
|
||||||
|
self.setup_mock(m, "edit_pdf")
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/edit_pdf/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"operations": [],
|
||||||
|
"update_document": True,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
m.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
||||||
|
def test_edit_pdf_rejects_negative_doc_index(self, m) -> None:
|
||||||
|
"""
|
||||||
|
A negative `doc` index was previously silently accepted and used
|
||||||
|
as a wrapping Python list index instead of being rejected.
|
||||||
|
"""
|
||||||
|
self.setup_mock(m, "edit_pdf")
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/edit_pdf/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"operations": [{"page": 1, "doc": -1}],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
m.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
||||||
|
def test_edit_pdf_rejects_out_of_bounds_doc_index(self, m) -> None:
|
||||||
|
"""
|
||||||
|
A `doc` index far larger than the number of operations previously
|
||||||
|
drove `pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)]` to
|
||||||
|
attempt allocating an enormous number of real objects.
|
||||||
|
"""
|
||||||
|
self.setup_mock(m, "edit_pdf")
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/edit_pdf/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"operations": [{"page": 1, "doc": 2**33}],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
m.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
||||||
|
def test_edit_pdf_rejects_non_positive_page(self, m) -> None:
|
||||||
|
self.setup_mock(m, "edit_pdf")
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/edit_pdf/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"operations": [{"page": 0}],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
||||||
def test_edit_pdf_page_out_of_bounds(self, m) -> None:
|
def test_edit_pdf_page_out_of_bounds(self, m) -> None:
|
||||||
self.setup_mock(m, "edit_pdf")
|
self.setup_mock(m, "edit_pdf")
|
||||||
@@ -1855,6 +1919,46 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertIn(b"out of bounds", response.content)
|
self.assertIn(b"out of bounds", response.content)
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch("documents.serialisers.bulk_edit.edit_pdf")
|
||||||
|
def test_bulk_edit_edit_pdf_rejects_empty_operations(self, m) -> None:
|
||||||
|
"""
|
||||||
|
Same validation gap as test_edit_pdf_rejects_empty_operations, but
|
||||||
|
via the legacy generic /api/documents/bulk_edit/ method="edit_pdf"
|
||||||
|
path, which hand-parses `parameters["operations"]` independently
|
||||||
|
in BulkEditSerializer._validate_parameters_edit_pdf.
|
||||||
|
"""
|
||||||
|
self.setup_mock(m, "edit_pdf")
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "edit_pdf",
|
||||||
|
"parameters": {"operations": [], "update_document": True},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
m.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch("documents.serialisers.bulk_edit.edit_pdf")
|
||||||
|
def test_bulk_edit_edit_pdf_rejects_out_of_bounds_doc_index(self, m) -> None:
|
||||||
|
self.setup_mock(m, "edit_pdf")
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "edit_pdf",
|
||||||
|
"parameters": {"operations": [{"page": 1, "doc": 2**33}]},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
||||||
def test_edit_pdf_insufficient_permissions(self, m) -> None:
|
def test_edit_pdf_insufficient_permissions(self, m) -> None:
|
||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||||
|
|||||||
@@ -1643,12 +1643,13 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
mock_consume_file.assert_not_called()
|
mock_consume_file.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("pikepdf.open")
|
@mock.patch("pikepdf.open")
|
||||||
def test_edit_pdf_rejects_invalid_operations(self, mock_open) -> None:
|
def test_edit_pdf_rejects_out_of_bounds_output_index(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.assertLogs("paperless.bulk_edit", level="ERROR"):
|
||||||
with self.assertRaisesRegex(ValueError, "index is out of bounds"):
|
with self.assertRaisesRegex(ValueError, "index is out of bounds"):
|
||||||
bulk_edit.edit_pdf([self.doc2.id], operations)
|
bulk_edit.edit_pdf(
|
||||||
|
[self.doc2.id],
|
||||||
|
[{"page": 1, "doc": 2**32}],
|
||||||
|
)
|
||||||
|
|
||||||
mock_open.assert_not_called()
|
mock_open.assert_not_called()
|
||||||
|
|
||||||
|
|||||||
@@ -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+")
|
||||||
|
|||||||
@@ -106,17 +106,6 @@ 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)
|
||||||
|
|||||||
@@ -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-13 22:13+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:2868 documents/views.py:319 documents/views.py:2694
|
#: 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:2341
|
#: 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:2385
|
#: 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:2392
|
#: 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:2409 documents/serialisers.py:2419
|
#: 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:2414
|
#: 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:2561
|
#: documents/serialisers.py:2547
|
||||||
msgid "Invalid variable detected."
|
msgid "Invalid variable detected."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2924
|
#: documents/serialisers.py:2910
|
||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2954 documents/views.py:4707
|
#: 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 ""
|
||||||
@@ -2258,151 +2258,151 @@ msgstr ""
|
|||||||
msgid "paperless application settings"
|
msgid "paperless application settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:560
|
#: paperless/settings/__init__.py:556
|
||||||
msgid "English (US)"
|
msgid "English (US)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:561
|
#: paperless/settings/__init__.py:557
|
||||||
msgid "Arabic"
|
msgid "Arabic"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:562
|
#: paperless/settings/__init__.py:558
|
||||||
msgid "Afrikaans"
|
msgid "Afrikaans"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:563
|
#: paperless/settings/__init__.py:559
|
||||||
msgid "Belarusian"
|
msgid "Belarusian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:564
|
#: paperless/settings/__init__.py:560
|
||||||
msgid "Bulgarian"
|
msgid "Bulgarian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:565
|
#: paperless/settings/__init__.py:561
|
||||||
msgid "Catalan"
|
msgid "Catalan"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:566
|
#: paperless/settings/__init__.py:562
|
||||||
msgid "Czech"
|
msgid "Czech"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:567
|
#: paperless/settings/__init__.py:563
|
||||||
msgid "Danish"
|
msgid "Danish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:568
|
#: paperless/settings/__init__.py:564
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:569
|
#: paperless/settings/__init__.py:565
|
||||||
msgid "Greek"
|
msgid "Greek"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:570
|
#: paperless/settings/__init__.py:566
|
||||||
msgid "English (GB)"
|
msgid "English (GB)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:571
|
#: paperless/settings/__init__.py:567
|
||||||
msgid "Spanish"
|
msgid "Spanish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:572
|
#: paperless/settings/__init__.py:568
|
||||||
msgid "Persian"
|
msgid "Persian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:573
|
#: paperless/settings/__init__.py:569
|
||||||
msgid "Finnish"
|
msgid "Finnish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:574
|
#: paperless/settings/__init__.py:570
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:575
|
#: paperless/settings/__init__.py:571
|
||||||
msgid "Hungarian"
|
msgid "Hungarian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:576
|
#: paperless/settings/__init__.py:572
|
||||||
msgid "Indonesian"
|
msgid "Indonesian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:577
|
#: paperless/settings/__init__.py:573
|
||||||
msgid "Italian"
|
msgid "Italian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:578
|
#: paperless/settings/__init__.py:574
|
||||||
msgid "Japanese"
|
msgid "Japanese"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:579
|
#: paperless/settings/__init__.py:575
|
||||||
msgid "Korean"
|
msgid "Korean"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:580
|
#: paperless/settings/__init__.py:576
|
||||||
msgid "Luxembourgish"
|
msgid "Luxembourgish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:581
|
#: paperless/settings/__init__.py:577
|
||||||
msgid "Norwegian"
|
msgid "Norwegian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:582
|
#: paperless/settings/__init__.py:578
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:583
|
#: paperless/settings/__init__.py:579
|
||||||
msgid "Polish"
|
msgid "Polish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:584
|
#: paperless/settings/__init__.py:580
|
||||||
msgid "Portuguese (Brazil)"
|
msgid "Portuguese (Brazil)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:585
|
#: paperless/settings/__init__.py:581
|
||||||
msgid "Portuguese"
|
msgid "Portuguese"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:586
|
#: paperless/settings/__init__.py:582
|
||||||
msgid "Romanian"
|
msgid "Romanian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:587
|
#: paperless/settings/__init__.py:583
|
||||||
msgid "Russian"
|
msgid "Russian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:588
|
#: paperless/settings/__init__.py:584
|
||||||
msgid "Slovak"
|
msgid "Slovak"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:589
|
#: paperless/settings/__init__.py:585
|
||||||
msgid "Slovenian"
|
msgid "Slovenian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:590
|
#: paperless/settings/__init__.py:586
|
||||||
msgid "Serbian"
|
msgid "Serbian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:591
|
#: paperless/settings/__init__.py:587
|
||||||
msgid "Swedish"
|
msgid "Swedish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:592
|
#: paperless/settings/__init__.py:588
|
||||||
msgid "Turkish"
|
msgid "Turkish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:593
|
#: paperless/settings/__init__.py:589
|
||||||
msgid "Ukrainian"
|
msgid "Ukrainian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:594
|
#: paperless/settings/__init__.py:590
|
||||||
msgid "Vietnamese"
|
msgid "Vietnamese"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:595
|
#: paperless/settings/__init__.py:591
|
||||||
msgid "Chinese Simplified"
|
msgid "Chinese Simplified"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:596
|
#: paperless/settings/__init__.py:592
|
||||||
msgid "Chinese Traditional"
|
msgid "Chinese Traditional"
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user