Compare commits

..
Author SHA1 Message Date
Trenton HolmesandClaude Sonnet 5 a167be5e1f Fix: use validate_set_permissions' normalized dict, dedupe action list
BulkEditObjectsSerializer._validate_permissions discarded the
id-checked/None-normalized dict validate_set_permissions returns, so a
literal null for users/groups reached set_permissions_for_objects raw
and blew up with an uncaught TypeError, masked by the view's broad
except into a vague "check logs" 400. Now stores the returned dict back
onto attrs["permissions"].

Also replaced the two independently hardcoded ("view", "change") lists
in SetPermissionsSerializer.to_internal_value and
validate_set_permissions with one PERMISSION_ACTIONS constant, so
adding a new action can't update one and silently miss the other.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 14:26:40 -07:00
Trenton HolmesandClaude Sonnet 5 4dc1736bee Fix: enforce set_permissions/permissions shape via a real DRF serializer
SetPermissionsSerializer was a bare DictField, and BulkEditSerializer's
set_permissions parameter bypassed even that by hand-calling
validate_set_permissions() on an unchecked dict pulled out of the
generic parameters bag. A bool or nested bool in place of a
users/groups list crashed with a raw TypeError instead of a 400.

Replaced it with a nested serializer (view/change, each with
users/groups as ListField(child=IntegerField())), routed the
BulkEditSerializer bypass through its validation, reused it for
BulkEditObjectsSerializer.permissions, and added a check for unknown
top-level action keys (previously silently dropped, defeating the
Permission.DoesNotExist safeguard in permissions.py for typo'd
actions). Preserved the two intentional no-op cases: an explicit
top-level None (a request that only changes owner) and rejecting an
explicit empty permissions dict.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 14:10:21 -07:00
11 changed files with 301 additions and 233 deletions
-8
View File
@@ -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.
+7 -16
View File
@@ -899,26 +899,17 @@ 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]
+45 -62
View File
@@ -210,6 +210,9 @@ class MatchingModelSerializer(serializers.ModelSerializer[Any]):
return match return match
PERMISSION_ACTIONS = ("view", "change")
class SetPermissionsMixin: class SetPermissionsMixin:
def _validate_user_ids(self, user_ids): def _validate_user_ids(self, user_ids):
users = User.objects.none() users = User.objects.none()
@@ -232,12 +235,9 @@ class SetPermissionsMixin:
return groups return groups
def validate_set_permissions(self, set_permissions=None): def validate_set_permissions(self, set_permissions=None):
permissions_dict = { permissions_dict = {action: {} for action in PERMISSION_ACTIONS}
"view": {},
"change": {},
}
if set_permissions is not None: if set_permissions is not None:
for action in ["view", "change"]: for action in PERMISSION_ACTIONS:
if action in set_permissions: if action in set_permissions:
if "users" in set_permissions[action]: if "users" in set_permissions[action]:
users = set_permissions[action]["users"] users = set_permissions[action]["users"]
@@ -265,41 +265,31 @@ class SerializerWithPerms(serializers.Serializer[dict[str, Any]]):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@extend_schema_field( class _PermissionSetSerializer(serializers.Serializer[dict[str, Any]]):
field={ users = serializers.ListField(
"type": "object", child=serializers.IntegerField(),
"properties": { required=False,
"view": { allow_null=True,
"type": "object", )
"properties": { groups = serializers.ListField(
"users": { child=serializers.IntegerField(),
"type": "array", required=False,
"items": {"type": "integer"}, allow_null=True,
}, )
"groups": {
"type": "array",
"items": {"type": "integer"}, class SetPermissionsSerializer(serializers.Serializer[dict[str, Any]]):
}, view = _PermissionSetSerializer(required=False)
}, change = _PermissionSetSerializer(required=False)
},
"change": { def to_internal_value(self, data):
"type": "object", if isinstance(data, dict):
"properties": { unknown_keys = set(data) - set(PERMISSION_ACTIONS)
"users": { if unknown_keys:
"type": "array", raise serializers.ValidationError(
"items": {"type": "integer"}, {key: "Unknown permission action." for key in sorted(unknown_keys)},
}, )
"groups": { return super().to_internal_value(data)
"type": "array",
"items": {"type": "integer"},
},
},
},
},
},
)
class SetPermissionsSerializer(serializers.DictField):
pass
class OwnedObjectSerializer( class OwnedObjectSerializer(
@@ -470,7 +460,6 @@ class OwnedObjectSerializer(
set_permissions = SetPermissionsSerializer( set_permissions = SetPermissionsSerializer(
label="Set permissions", label="Set permissions",
allow_empty=True,
required=False, required=False,
write_only=True, write_only=True,
) )
@@ -1750,7 +1739,7 @@ class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin): class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
operations = serializers.ListField(required=True, allow_empty=False) operations = serializers.ListField(required=True)
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)
@@ -1788,12 +1777,6 @@ 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:
@@ -2051,8 +2034,13 @@ class BulkEditSerializer(
def _validate_parameters_set_permissions(self, parameters) -> None: def _validate_parameters_set_permissions(self, parameters) -> None:
if "set_permissions" not in parameters: if "set_permissions" not in parameters:
raise serializers.ValidationError("set_permissions not specified") raise serializers.ValidationError("set_permissions not specified")
set_permissions = parameters["set_permissions"]
if set_permissions is not None:
set_permissions = SetPermissionsSerializer().run_validation(
set_permissions,
)
parameters["set_permissions"] = self.validate_set_permissions( parameters["set_permissions"] = self.validate_set_permissions(
parameters["set_permissions"], set_permissions,
) )
if "owner" in parameters and parameters["owner"] is not None: if "owner" in parameters and parameters["owner"] is not None:
self._validate_owner(parameters["owner"]) self._validate_owner(parameters["owner"])
@@ -2130,8 +2118,6 @@ 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")
@@ -2159,12 +2145,6 @@ 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:
@@ -3015,9 +2995,8 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
allow_null=True, allow_null=True,
) )
permissions = serializers.DictField( permissions = SetPermissionsSerializer(
label="Set permissions", label="Set permissions",
allow_empty=False,
required=False, required=False,
write_only=True, write_only=True,
) )
@@ -3053,8 +3032,8 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
) )
return objects return objects
def _validate_permissions(self, permissions) -> None: def _validate_permissions(self, permissions) -> dict:
self.validate_set_permissions( return self.validate_set_permissions(
permissions, permissions,
) )
@@ -3078,7 +3057,11 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
if operation == "set_permissions": if operation == "set_permissions":
permissions = attrs.get("permissions") permissions = attrs.get("permissions")
if permissions is not None: if permissions is not None:
self._validate_permissions(permissions) if not permissions:
raise serializers.ValidationError(
"permissions must not be empty",
)
attrs["permissions"] = self._validate_permissions(permissions)
return attrs return attrs
+6 -11
View File
@@ -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")
+93 -57
View File
@@ -1165,6 +1165,98 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertIn(b"set_permissions not specified", response.content) self.assertIn(b"set_permissions not specified", response.content)
m.assert_not_called() m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_rejects_non_dict_value(self, m) -> None:
self.setup_mock(m, "set_permissions")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "set_permissions",
"parameters": {"set_permissions": False},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"Expected a dictionary", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_rejects_non_list_users(self, m) -> None:
self.setup_mock(m, "set_permissions")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "set_permissions",
"parameters": {
"set_permissions": {"view": {"users": False}},
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"Expected a list", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_rejects_unknown_action(self, m) -> None:
self.setup_mock(m, "set_permissions")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "set_permissions",
"parameters": {
"set_permissions": {"not_a_real_action": {"users": [1]}},
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"Unknown permission action", response.content)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_null_is_a_noop(self, m) -> None:
"""
A `set_permissions: null` value is a deliberate no-op (e.g. a
request that only updates `owner`), not a validation error --
this must keep working even though every other non-dict value is
now rejected.
"""
self.setup_mock(m, "set_permissions")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "set_permissions",
"parameters": {
"set_permissions": None,
"owner": self.user.id,
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
@mock.patch("documents.serialisers.bulk_edit.set_permissions") @mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_merge(self, m) -> None: def test_set_permissions_merge(self, m) -> None:
self.setup_mock(m, "set_permissions") self.setup_mock(m, "set_permissions")
@@ -1300,7 +1392,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.client.force_authenticate(user=user1) self.client.force_authenticate(user=user1)
permissions = { permissions = {
"owner": user1.id, "view": {"users": [user1.id]},
} }
response = self.client.post( response = self.client.post(
@@ -1649,40 +1741,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)
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")
@@ -1733,13 +1791,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(
@@ -1792,21 +1843,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 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(
+103
View File
@@ -1499,6 +1499,109 @@ class TestBulkEditObjectPermissions(APITestCase):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertTrue(Tag.objects.filter(pk=self.t1.id).exists()) self.assertTrue(Tag.objects.filter(pk=self.t1.id).exists())
def test_bulk_object_set_permissions_rejects_empty_permissions(self) -> None:
"""
GIVEN:
- Existing objects
WHEN:
- bulk_edit_objects API endpoint is called with set_permissions
operation and an empty permissions dict
THEN:
- Validation fails rather than silently applying a no-op
"""
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.t1.id],
"object_type": "tags",
"operation": "set_permissions",
"permissions": {},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_bulk_object_set_permissions_rejects_non_dict_permissions(self) -> None:
"""
GIVEN:
- Existing objects
WHEN:
- bulk_edit_objects API endpoint is called with set_permissions
operation and a non-dict permissions value
THEN:
- Validation fails rather than crashing
"""
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.t1.id],
"object_type": "tags",
"operation": "set_permissions",
"permissions": False,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_bulk_object_set_permissions_rejects_unknown_action(self) -> None:
"""
GIVEN:
- Existing objects
WHEN:
- bulk_edit_objects API endpoint is called with set_permissions
operation and an unrecognized permission action name
THEN:
- Validation fails rather than silently no-oping
"""
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.t1.id],
"object_type": "tags",
"operation": "set_permissions",
"permissions": {"not_a_real_action": {"users": [self.user1.id]}},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_bulk_object_set_permissions_null_users_is_a_noop(self) -> None:
"""
GIVEN:
- Existing objects
WHEN:
- bulk_edit_objects API endpoint is called with set_permissions
operation and an explicit null for users/groups on an action
THEN:
- Request succeeds and is treated as "no users/groups for this
action", not a crash -- the normalized (id-checked) dict
returned by validate_set_permissions must actually be used,
not discarded in favor of the raw un-normalized input.
"""
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.t1.id],
"object_type": "tags",
"operation": "set_permissions",
"permissions": {"view": {"users": None}},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_bulk_edit_object_permissions_validation(self) -> None: def test_bulk_edit_object_permissions_validation(self) -> None:
""" """
GIVEN: GIVEN:
-10
View File
@@ -1642,16 +1642,6 @@ 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")
-7
View File
@@ -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+")
-11
View File
@@ -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)
+47 -47
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-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 ""
-4
View File
@@ -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