mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-04-01 05:42:43 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4678df6bf2 | ||
|
|
501cdd92d2 | ||
|
|
66c5c46913 | ||
|
|
3cfe9fa2a8 | ||
|
|
3292a0e7cc |
@@ -967,6 +967,8 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
||||
getattr(request, "user", None) if request is not None else None,
|
||||
doc_ids,
|
||||
)
|
||||
elif field.data_type == CustomField.FieldDataType.DATE:
|
||||
data["value"] = serializers.DateField().to_internal_value(data["value"])
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -818,7 +818,6 @@ def run_workflows(
|
||||
# Refresh this so the matching data is fresh and instance fields are re-freshed
|
||||
# Otherwise, this instance might be behind and overwrite the work another process did
|
||||
document.refresh_from_db()
|
||||
doc_tag_ids = list(document.tags.values_list("pk", flat=True))
|
||||
|
||||
if matching.document_matches_workflow(document, workflow, trigger_type):
|
||||
action: WorkflowAction
|
||||
@@ -836,14 +835,13 @@ def run_workflows(
|
||||
apply_assignment_to_document(
|
||||
action,
|
||||
document,
|
||||
doc_tag_ids,
|
||||
logging_group,
|
||||
)
|
||||
elif action.type == WorkflowAction.WorkflowActionType.REMOVAL:
|
||||
if use_overrides and overrides:
|
||||
apply_removal_to_overrides(action, overrides)
|
||||
else:
|
||||
apply_removal_to_document(action, document, doc_tag_ids)
|
||||
apply_removal_to_document(action, document)
|
||||
elif action.type == WorkflowAction.WorkflowActionType.EMAIL:
|
||||
context = build_workflow_action_context(document, overrides)
|
||||
execute_email_action(
|
||||
@@ -886,7 +884,6 @@ def run_workflows(
|
||||
"modified",
|
||||
],
|
||||
)
|
||||
document.tags.set(doc_tag_ids)
|
||||
|
||||
WorkflowRun.objects.create(
|
||||
workflow=workflow,
|
||||
|
||||
@@ -1425,3 +1425,41 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
results = response.data["results"]
|
||||
self.assertEqual(results[0]["document_count"], 0)
|
||||
|
||||
def test_patch_document_invalid_date_custom_field_returns_validation_error(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- A date custom field
|
||||
- A document
|
||||
WHEN:
|
||||
- Patching the document with a date string in the wrong format
|
||||
THEN:
|
||||
- HTTP 400 is returned instead of an internal server error
|
||||
- No custom field instance is created
|
||||
"""
|
||||
cf_date = CustomField.objects.create(
|
||||
name="datefield",
|
||||
data_type=CustomField.FieldDataType.DATE,
|
||||
)
|
||||
doc = Document.objects.create(
|
||||
title="Doc",
|
||||
checksum="123",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/documents/{doc.pk}/",
|
||||
{
|
||||
"custom_fields": [
|
||||
{
|
||||
"field": cf_date.pk,
|
||||
"value": "10.03.2026",
|
||||
},
|
||||
],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("custom_fields", response.data)
|
||||
self.assertEqual(CustomFieldInstance.objects.count(), 0)
|
||||
|
||||
@@ -1087,6 +1087,43 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
self.assertEqual(len(response.data["all"]), 50)
|
||||
self.assertCountEqual(response.data["all"], [d.id for d in docs])
|
||||
|
||||
def test_default_ordering_uses_id_as_tiebreaker(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Documents sharing the same created date
|
||||
WHEN:
|
||||
- API request for documents without an explicit ordering
|
||||
THEN:
|
||||
- Results are correctly ordered by created > id
|
||||
"""
|
||||
older_doc = Document.objects.create(
|
||||
checksum="older",
|
||||
content="older",
|
||||
created=date(2024, 1, 1),
|
||||
)
|
||||
first_same_date_doc = Document.objects.create(
|
||||
checksum="same-date-1",
|
||||
content="same-date-1",
|
||||
created=date(2024, 1, 2),
|
||||
)
|
||||
second_same_date_doc = Document.objects.create(
|
||||
checksum="same-date-2",
|
||||
content="same-date-2",
|
||||
created=date(2024, 1, 2),
|
||||
)
|
||||
|
||||
response = self.client.get("/api/documents/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
[result["id"] for result in response.data["results"]],
|
||||
[
|
||||
second_same_date_doc.id,
|
||||
first_same_date_doc.id,
|
||||
older_doc.id,
|
||||
],
|
||||
)
|
||||
|
||||
def test_statistics(self):
|
||||
doc1 = Document.objects.create(
|
||||
title="none1",
|
||||
@@ -2953,6 +2990,58 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
self.assertEqual(create_resp.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(create_resp.data["document"], doc.pk)
|
||||
|
||||
def test_share_link_update_methods_not_allowed(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- An existing share link
|
||||
WHEN:
|
||||
- PUT and PATCH requests are made to its detail endpoint
|
||||
THEN:
|
||||
- The API rejects them with 405 and the link is unchanged
|
||||
"""
|
||||
doc = Document.objects.create(
|
||||
title="test",
|
||||
mime_type="application/pdf",
|
||||
content="share link content",
|
||||
)
|
||||
expiration = timezone.now() + timedelta(days=7)
|
||||
create_resp = self.client.post(
|
||||
"/api/share_links/",
|
||||
data={
|
||||
"document": doc.pk,
|
||||
"expiration": expiration.isoformat(),
|
||||
"file_version": ShareLink.FileVersion.ORIGINAL,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(create_resp.status_code, status.HTTP_201_CREATED)
|
||||
share_link_id = create_resp.data["id"]
|
||||
|
||||
patch_resp = self.client.patch(
|
||||
f"/api/share_links/{share_link_id}/",
|
||||
data={
|
||||
"expiration": None,
|
||||
"file_version": ShareLink.FileVersion.ARCHIVE,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(patch_resp.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||
|
||||
put_resp = self.client.put(
|
||||
f"/api/share_links/{share_link_id}/",
|
||||
data={
|
||||
"document": doc.pk,
|
||||
"expiration": None,
|
||||
"file_version": ShareLink.FileVersion.ARCHIVE,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(put_resp.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||
|
||||
share_link = ShareLink.objects.get(pk=share_link_id)
|
||||
self.assertEqual(share_link.file_version, ShareLink.FileVersion.ORIGINAL)
|
||||
self.assertIsNotNone(share_link.expiration)
|
||||
|
||||
def test_next_asn(self):
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -3512,6 +3512,124 @@ class TestWorkflows(
|
||||
as_json=False,
|
||||
)
|
||||
|
||||
@mock.patch("documents.signals.handlers.execute_webhook_action")
|
||||
def test_workflow_webhook_action_does_not_overwrite_concurrent_tags(
|
||||
self,
|
||||
mock_execute_webhook_action,
|
||||
):
|
||||
"""
|
||||
GIVEN:
|
||||
- A document updated workflow with only a webhook action
|
||||
- A tag update that happens after run_workflows
|
||||
WHEN:
|
||||
- The workflow runs
|
||||
THEN:
|
||||
- The concurrent tag update is preserved
|
||||
"""
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||
)
|
||||
webhook_action = WorkflowActionWebhook.objects.create(
|
||||
use_params=False,
|
||||
body="Test message: {{doc_url}}",
|
||||
url="http://paperless-ngx.com",
|
||||
include_document=False,
|
||||
)
|
||||
action = WorkflowAction.objects.create(
|
||||
type=WorkflowAction.WorkflowActionType.WEBHOOK,
|
||||
webhook=webhook_action,
|
||||
)
|
||||
w = Workflow.objects.create(
|
||||
name="Webhook workflow",
|
||||
order=0,
|
||||
)
|
||||
w.triggers.add(trigger)
|
||||
w.actions.add(action)
|
||||
w.save()
|
||||
|
||||
inbox_tag = Tag.objects.create(name="inbox")
|
||||
error_tag = Tag.objects.create(name="error")
|
||||
doc = Document.objects.create(
|
||||
title="sample test",
|
||||
correspondent=self.c,
|
||||
original_filename="sample.pdf",
|
||||
)
|
||||
doc.tags.add(inbox_tag)
|
||||
|
||||
def add_error_tag(*args, **kwargs):
|
||||
Document.objects.get(pk=doc.pk).tags.add(error_tag)
|
||||
|
||||
mock_execute_webhook_action.side_effect = add_error_tag
|
||||
|
||||
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||
|
||||
doc.refresh_from_db()
|
||||
self.assertCountEqual(doc.tags.all(), [inbox_tag, error_tag])
|
||||
|
||||
@mock.patch("documents.signals.handlers.execute_webhook_action")
|
||||
def test_workflow_tag_actions_do_not_overwrite_concurrent_tags(
|
||||
self,
|
||||
mock_execute_webhook_action,
|
||||
):
|
||||
"""
|
||||
GIVEN:
|
||||
- A document updated workflow that clears tags and assigns an inbox tag
|
||||
- A later tag update that happens before the workflow finishes
|
||||
WHEN:
|
||||
- The workflow runs
|
||||
THEN:
|
||||
- The later tag update is preserved
|
||||
"""
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||
)
|
||||
removal_action = WorkflowAction.objects.create(
|
||||
type=WorkflowAction.WorkflowActionType.REMOVAL,
|
||||
remove_all_tags=True,
|
||||
)
|
||||
assign_action = WorkflowAction.objects.create(
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
assign_action.assign_tags.add(self.t1)
|
||||
webhook_action = WorkflowActionWebhook.objects.create(
|
||||
use_params=False,
|
||||
body="Test message: {{doc_url}}",
|
||||
url="http://paperless-ngx.com",
|
||||
include_document=False,
|
||||
)
|
||||
notify_action = WorkflowAction.objects.create(
|
||||
type=WorkflowAction.WorkflowActionType.WEBHOOK,
|
||||
webhook=webhook_action,
|
||||
)
|
||||
w = Workflow.objects.create(
|
||||
name="Workflow tag race",
|
||||
order=0,
|
||||
)
|
||||
w.triggers.add(trigger)
|
||||
w.actions.add(removal_action)
|
||||
w.actions.add(assign_action)
|
||||
w.actions.add(notify_action)
|
||||
w.save()
|
||||
|
||||
doc = Document.objects.create(
|
||||
title="sample test",
|
||||
correspondent=self.c,
|
||||
original_filename="sample.pdf",
|
||||
owner=self.user3,
|
||||
)
|
||||
doc.tags.add(self.t2, self.t3)
|
||||
|
||||
def add_error_tag(*args, **kwargs):
|
||||
Document.objects.get(pk=doc.pk).tags.add(self.t2)
|
||||
|
||||
mock_execute_webhook_action.side_effect = add_error_tag
|
||||
|
||||
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||
|
||||
doc.refresh_from_db()
|
||||
self.assertEqual(doc.owner, self.user2)
|
||||
self.assertCountEqual(doc.tags.all(), [self.t1, self.t2])
|
||||
|
||||
@override_settings(
|
||||
PAPERLESS_URL="http://localhost:8000",
|
||||
)
|
||||
|
||||
@@ -76,6 +76,7 @@ from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.filters import SearchFilter
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.mixins import CreateModelMixin
|
||||
from rest_framework.mixins import DestroyModelMixin
|
||||
from rest_framework.mixins import ListModelMixin
|
||||
from rest_framework.mixins import RetrieveModelMixin
|
||||
@@ -785,7 +786,7 @@ class DocumentViewSet(
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Document.objects.distinct()
|
||||
.order_by("-created")
|
||||
.order_by("-created", "-id")
|
||||
.annotate(num_notes=Count("notes"))
|
||||
.select_related("correspondent", "storage_path", "document_type", "owner")
|
||||
.prefetch_related("tags", "custom_fields", "notes")
|
||||
@@ -2702,7 +2703,14 @@ class TasksViewSet(ReadOnlyModelViewSet):
|
||||
)
|
||||
|
||||
|
||||
class ShareLinkViewSet(ModelViewSet, PassUserMixin):
|
||||
class ShareLinkViewSet(
|
||||
PassUserMixin,
|
||||
CreateModelMixin,
|
||||
RetrieveModelMixin,
|
||||
DestroyModelMixin,
|
||||
ListModelMixin,
|
||||
GenericViewSet,
|
||||
):
|
||||
model = ShareLink
|
||||
|
||||
queryset = ShareLink.objects.all()
|
||||
|
||||
@@ -16,7 +16,6 @@ logger = logging.getLogger("paperless.workflows.mutations")
|
||||
def apply_assignment_to_document(
|
||||
action: WorkflowAction,
|
||||
document: Document,
|
||||
doc_tag_ids: list[int],
|
||||
logging_group,
|
||||
):
|
||||
"""
|
||||
@@ -25,12 +24,7 @@ def apply_assignment_to_document(
|
||||
action: WorkflowAction, annotated with 'has_assign_*' boolean fields
|
||||
"""
|
||||
if action.has_assign_tags:
|
||||
tag_ids_to_add: set[int] = set()
|
||||
for tag in action.assign_tags.all():
|
||||
tag_ids_to_add.add(tag.pk)
|
||||
tag_ids_to_add.update(int(pk) for pk in tag.get_ancestors_pks())
|
||||
|
||||
doc_tag_ids[:] = list(set(doc_tag_ids) | tag_ids_to_add)
|
||||
document.add_nested_tags(action.assign_tags.all())
|
||||
|
||||
if action.assign_correspondent:
|
||||
document.correspondent = action.assign_correspondent
|
||||
@@ -197,7 +191,6 @@ def apply_assignment_to_overrides(
|
||||
def apply_removal_to_document(
|
||||
action: WorkflowAction,
|
||||
document: Document,
|
||||
doc_tag_ids: list[int],
|
||||
):
|
||||
"""
|
||||
Apply removal actions to a Document instance.
|
||||
@@ -206,14 +199,15 @@ def apply_removal_to_document(
|
||||
"""
|
||||
|
||||
if action.remove_all_tags:
|
||||
doc_tag_ids.clear()
|
||||
document.tags.clear()
|
||||
else:
|
||||
tag_ids_to_remove: set[int] = set()
|
||||
for tag in action.remove_tags.all():
|
||||
tag_ids_to_remove.add(tag.pk)
|
||||
tag_ids_to_remove.update(int(pk) for pk in tag.get_descendants_pks())
|
||||
|
||||
doc_tag_ids[:] = [t for t in doc_tag_ids if t not in tag_ids_to_remove]
|
||||
if tag_ids_to_remove:
|
||||
document.tags.remove(*tag_ids_to_remove)
|
||||
|
||||
if action.remove_all_correspondents or (
|
||||
document.correspondent
|
||||
|
||||
@@ -472,6 +472,7 @@ class MailAccountHandler(LoggingMixin):
|
||||
name=name,
|
||||
defaults={
|
||||
"match": name,
|
||||
"matching_algorithm": Correspondent.MATCH_LITERAL,
|
||||
},
|
||||
)[0]
|
||||
except DatabaseError as e:
|
||||
|
||||
@@ -448,7 +448,7 @@ class TestMail(
|
||||
c = handler._get_correspondent(message, rule)
|
||||
self.assertIsNotNone(c)
|
||||
self.assertEqual(c.name, "someone@somewhere.com")
|
||||
self.assertEqual(c.matching_algorithm, MatchingModel.MATCH_ANY)
|
||||
self.assertEqual(c.matching_algorithm, MatchingModel.MATCH_LITERAL)
|
||||
self.assertEqual(c.match, "someone@somewhere.com")
|
||||
c = handler._get_correspondent(message2, rule)
|
||||
self.assertIsNotNone(c)
|
||||
|
||||
Reference in New Issue
Block a user