diff --git a/docs/advanced_usage.md b/docs/advanced_usage.md
index ee0cfddce..a07005320 100644
--- a/docs/advanced_usage.md
+++ b/docs/advanced_usage.md
@@ -842,7 +842,7 @@ MariaDB: `mariadb-tzinfo-to-sql /usr/share/zoneinfo | mariadb -u root mysql -p`
## Barcodes {#barcodes}
-Paperless is able to utilize barcodes for automatically performing some tasks.
+Paperless is able to utilize barcodes for automatically performing some tasks. Barcodes are only supported for PDF documents or TIFF, [if enabled](configuration.md#PAPERLESS_CONSUMER_BARCODE_TIFF_SUPPORT).
At this time, the library utilized for detection of barcodes supports the following types:
diff --git a/docs/changelog.md b/docs/changelog.md
index c361309d1..a5bc21432 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,5 +1,40 @@
# Changelog
+## paperless-ngx 2.20.14
+
+### Bug Fixes
+
+- Fix: do not submit permissions for non-owners [@shamoon](https://github.com/shamoon) ([#12571](https://github.com/paperless-ngx/paperless-ngx/pull/12571))
+- Fix: prevent duplicate parent tag IDs [@shamoon](https://github.com/shamoon) ([#12522](https://github.com/paperless-ngx/paperless-ngx/pull/12522))
+- Fix: dont defer tag change application in workflows [@shamoon](https://github.com/shamoon) ([#12478](https://github.com/paperless-ngx/paperless-ngx/pull/12478))
+- Fix: limit share link viewset actions [@shamoon](https://github.com/shamoon) ([#12461](https://github.com/paperless-ngx/paperless-ngx/pull/12461))
+- Fix: add fallback ordering for documents by id after created [@shamoon](https://github.com/shamoon) ([#12440](https://github.com/paperless-ngx/paperless-ngx/pull/12440))
+- Fixhancement: default mail-created correspondent matching to exact [@shamoon](https://github.com/shamoon) ([#12414](https://github.com/paperless-ngx/paperless-ngx/pull/12414))
+- Fix: validate date CF value in serializer [@shamoon](https://github.com/shamoon) ([#12410](https://github.com/paperless-ngx/paperless-ngx/pull/12410))
+
+### All App Changes
+
+
+7 changes
+
+- Fix: do not submit permissions for non-owners [@shamoon](https://github.com/shamoon) ([#12571](https://github.com/paperless-ngx/paperless-ngx/pull/12571))
+- Fix: prevent duplicate parent tag IDs [@shamoon](https://github.com/shamoon) ([#12522](https://github.com/paperless-ngx/paperless-ngx/pull/12522))
+- Fix: dont defer tag change application in workflows [@shamoon](https://github.com/shamoon) ([#12478](https://github.com/paperless-ngx/paperless-ngx/pull/12478))
+- Fix: limit share link viewset actions [@shamoon](https://github.com/shamoon) ([#12461](https://github.com/paperless-ngx/paperless-ngx/pull/12461))
+- Fix: add fallback ordering for documents by id after created [@shamoon](https://github.com/shamoon) ([#12440](https://github.com/paperless-ngx/paperless-ngx/pull/12440))
+- Fixhancement: default mail-created correspondent matching to exact [@shamoon](https://github.com/shamoon) ([#12414](https://github.com/paperless-ngx/paperless-ngx/pull/12414))
+- Fix: validate date CF value in serializer [@shamoon](https://github.com/shamoon) ([#12410](https://github.com/paperless-ngx/paperless-ngx/pull/12410))
+
+
+## paperless-ngx 2.20.13
+
+### Bug Fixes
+
+- Fix: suggest corrections only if visible results
+- Fix: require view permission for more-like search
+- Fix: validate document link targets
+- Fix: enforce permissions when attaching accounts to mail rules
+
## paperless-ngx 2.20.12
### Security
diff --git a/docs/usage.md b/docs/usage.md
index 1528af390..e8b884251 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -417,7 +417,7 @@ still have "object-level" permissions.
| SystemStatus | View the system status dialog and corresponding API endpoint. Admin users also retain system status access. |
| Tag | Add, edit, delete or view Tags. |
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.
:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
-| User | Add, edit, delete or view Users. |
+| User | Add, edit, delete or view other user accounts via Settings > Users & Groups and `/api/users/`. These permissions are not needed for users to edit their own profile via "My Profile" or `/api/profile/`. |
| Workflow | Add, edit, delete or view Workflows.
Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). |
#### Detailed Explanation of Object Permissions {#object-permissions}
@@ -428,6 +428,8 @@ still have "object-level" permissions.
| View | Confers the ability to view (not edit) a document, tag, etc.
Users without 'view' (or higher) permissions will be shown _'Private'_ in place of the object name for example when viewing a document with a tag for which the user doesn't have permissions. |
| Edit | Confers the ability to edit (and view) a document, tag, etc. |
+For related metadata such as tags, correspondents, document types, and storage paths, object visibility and document assignment are intentionally distinct. A user may still retain or submit a known object ID when editing a document even if that related object is displayed as _Private_ or omitted from search and selection results. This allows documents to preserve existing assignments that the current user cannot necessarily inspect in detail.
+
### Password reset
In order to enable the password reset feature you will need to setup an SMTP backend, see
diff --git a/pyproject.toml b/pyproject.toml
index fca865782..1537155a8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "paperless-ngx"
-version = "2.20.13"
+version = "2.20.14"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md"
requires-python = ">=3.11"
diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf
index 92c7db70d..c373c7dab 100644
--- a/src-ui/messages.xlf
+++ b/src-ui/messages.xlf
@@ -4062,14 +4062,14 @@
Create new item
src/app/components/common/edit-dialog/edit-dialog.component.ts
- 121
+ 119
Edit item
src/app/components/common/edit-dialog/edit-dialog.component.ts
- 125
+ 123
@@ -4859,32 +4859,32 @@
Create new user account
src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts
- 72
+ 70
Edit user account
src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts
- 76
+ 74
Totp deactivated
src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts
- 132
+ 130
Totp deactivation failed
src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts
- 135
+ 133
src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts
- 140
+ 138
diff --git a/src-ui/package.json b/src-ui/package.json
index f781e606e..7fde629ca 100644
--- a/src-ui/package.json
+++ b/src-ui/package.json
@@ -1,6 +1,6 @@
{
"name": "paperless-ngx-ui",
- "version": "2.20.13",
+ "version": "2.20.14",
"scripts": {
"preinstall": "npx only-allow pnpm",
"ng": "ng",
diff --git a/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.spec.ts
index fa845f369..1dbd54edf 100644
--- a/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.spec.ts
+++ b/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.spec.ts
@@ -26,6 +26,7 @@ import {
} from 'src/app/data/matching-model'
import { Tag } from 'src/app/data/tag'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
+import { PermissionsService } from 'src/app/services/permissions.service'
import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
@@ -87,6 +88,7 @@ describe('EditDialogComponent', () => {
let component: TestComponent
let fixture: ComponentFixture
let tagService: TagService
+ let permissionsService: PermissionsService
let settingsService: SettingsService
let activeModal: NgbActiveModal
let httpTestingController: HttpTestingController
@@ -118,8 +120,10 @@ describe('EditDialogComponent', () => {
}).compileComponents()
tagService = TestBed.inject(TagService)
+ permissionsService = TestBed.inject(PermissionsService)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = currentUser
+ permissionsService.initialize([], currentUser as any)
activeModal = TestBed.inject(NgbActiveModal)
httpTestingController = TestBed.inject(HttpTestingController)
@@ -226,6 +230,25 @@ describe('EditDialogComponent', () => {
expect(updateSpy).toHaveBeenCalled()
})
+ it('should not submit owner or permissions for non-owner edits', () => {
+ component.object = tag
+ component.dialogMode = EditDialogMode.EDIT
+ component.ngOnInit()
+
+ component.objectForm.get('name').setValue('Updated tag')
+ component.save()
+
+ const req = httpTestingController.expectOne(
+ `${environment.apiBaseUrl}tags/${tag.id}/`
+ )
+ expect(req.request.method).toEqual('PUT')
+ expect(req.request.body.name).toEqual('Updated tag')
+ expect(req.request.body.owner).toEqual(tag.owner)
+ expect(req.request.body.set_permissions).toBeUndefined()
+
+ req.flush({})
+ })
+
it('should create an object on save in edit mode', () => {
const createSpy = jest.spyOn(tagService, 'create')
component.dialogMode = EditDialogMode.CREATE
diff --git a/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.ts
index 94abfc79f..3d9026833 100644
--- a/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.ts
+++ b/src-ui/src/app/components/common/edit-dialog/edit-dialog.component.ts
@@ -18,6 +18,7 @@ import { ObjectWithId } from 'src/app/data/object-with-id'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { User } from 'src/app/data/user'
+import { PermissionsService } from 'src/app/services/permissions.service'
import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
@@ -42,6 +43,7 @@ export abstract class EditDialogComponent<
protected activeModal = inject(NgbActiveModal)
protected userService = inject(UserService)
protected settingsService = inject(SettingsService)
+ protected permissionsService = inject(PermissionsService)
users: User[]
@@ -69,10 +71,6 @@ export abstract class EditDialogComponent<
ngOnInit(): void {
if (this.object != null && this.dialogMode !== EditDialogMode.CREATE) {
- if ((this.object as ObjectWithPermissions).permissions) {
- this.object['set_permissions'] = this.object['permissions']
- }
-
this.object['permissions_form'] = {
owner: (this.object as ObjectWithPermissions).owner,
set_permissions: (this.object as ObjectWithPermissions).permissions,
@@ -151,18 +149,28 @@ export abstract class EditDialogComponent<
return Object.assign({}, this.objectForm.value)
}
+ protected shouldSubmitPermissions(): boolean {
+ return (
+ this.dialogMode === EditDialogMode.CREATE ||
+ this.permissionsService.currentUserOwnsObject(this.object)
+ )
+ }
+
save() {
this.error = null
const formValues = this.getFormValues()
const permissionsObject: PermissionsFormObject =
this.objectForm.get('permissions_form')?.value
- if (permissionsObject) {
+ if (permissionsObject && this.shouldSubmitPermissions()) {
formValues.owner = permissionsObject.owner
formValues.set_permissions = permissionsObject.set_permissions
- delete formValues.permissions_form
}
+ delete formValues.permissions_form
var newObject = Object.assign(Object.assign({}, this.object), formValues)
+ if (!this.shouldSubmitPermissions()) {
+ delete newObject['set_permissions']
+ }
var serverResponse: Observable
switch (this.dialogMode) {
case EditDialogMode.CREATE:
diff --git a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts
index 1c87a4308..215b2c137 100644
--- a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts
+++ b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts
@@ -9,7 +9,6 @@ import { first } from 'rxjs'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { Group } from 'src/app/data/group'
import { User } from 'src/app/data/user'
-import { PermissionsService } from 'src/app/services/permissions.service'
import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
@@ -39,7 +38,6 @@ export class UserEditDialogComponent
implements OnInit
{
private toastService = inject(ToastService)
- private permissionsService = inject(PermissionsService)
private groupsService: GroupService
groups: Group[]
diff --git a/src-ui/src/app/components/common/input/tags/tags.component.spec.ts b/src-ui/src/app/components/common/input/tags/tags.component.spec.ts
index ae543075e..21ca3eeff 100644
--- a/src-ui/src/app/components/common/input/tags/tags.component.spec.ts
+++ b/src-ui/src/app/components/common/input/tags/tags.component.spec.ts
@@ -205,6 +205,20 @@ describe('TagsComponent', () => {
expect(component.value).toEqual([2, 1])
})
+ it('should not duplicate parents when adding sibling nested tags', () => {
+ const root: Tag = { id: 1, name: 'root' }
+ const parent: Tag = { id: 2, name: 'parent', parent: 1 }
+ const leafA: Tag = { id: 3, name: 'leaf-a', parent: 2 }
+ const leafB: Tag = { id: 4, name: 'leaf-b', parent: 2 }
+ component.tags = [root, parent, leafA, leafB]
+
+ component.value = []
+ component.addTag(3)
+ component.addTag(4)
+
+ expect(component.value).toEqual([3, 2, 1, 4])
+ })
+
it('should return ancestors from root to parent using getParentChain', () => {
const root: Tag = { id: 1, name: 'root' }
const mid: Tag = { id: 2, name: 'mid', parent: 1 }
diff --git a/src-ui/src/app/components/common/input/tags/tags.component.ts b/src-ui/src/app/components/common/input/tags/tags.component.ts
index 4546dabcb..c540ff9d7 100644
--- a/src-ui/src/app/components/common/input/tags/tags.component.ts
+++ b/src-ui/src/app/components/common/input/tags/tags.component.ts
@@ -153,11 +153,13 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
}
public onAdd(tag: Tag) {
- if (tag.parent) {
+ if (tag?.parent) {
// add all parents recursively
const parent = this.getTag(tag.parent)
- this.value = [...this.value, parent.id]
- this.onAdd(parent)
+ if (parent && !this.value.includes(parent.id)) {
+ this.value = [...this.value, parent.id]
+ this.onAdd(parent)
+ }
}
}
diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts
index 4b66010c6..0db350bc2 100644
--- a/src-ui/src/environments/environment.prod.ts
+++ b/src-ui/src/environments/environment.prod.ts
@@ -6,7 +6,7 @@ export const environment = {
apiVersion: '10', // match src/paperless/settings.py
appTitle: 'Paperless-ngx',
tag: 'prod',
- version: '2.20.13',
+ version: '2.20.14',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index 5615cc927..1efbf6b7b 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -911,6 +911,8 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInsta
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
diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
index a72abc2d5..dd49a718c 100644
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -894,7 +894,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))
except Document.DoesNotExist:
# Document was hard deleted by a previous workflow or another process
logger.info(
@@ -928,14 +927,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(
@@ -982,7 +980,6 @@ def run_workflows(
"modified",
],
)
- document.tags.set(doc_tag_ids)
WorkflowRun.objects.create(
workflow=workflow,
diff --git a/src/documents/tests/test_api_custom_fields.py b/src/documents/tests/test_api_custom_fields.py
index ac7679632..3606102ac 100644
--- a/src/documents/tests/test_api_custom_fields.py
+++ b/src/documents/tests/test_api_custom_fields.py
@@ -1320,3 +1320,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)
diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py
index 1361aedfc..173a93a30 100644
--- a/src/documents/tests/test_api_documents.py
+++ b/src/documents/tests/test_api_documents.py
@@ -1168,6 +1168,43 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
self.assertIn("all", response.data)
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_list_with_include_selection_data(self) -> None:
correspondent = Correspondent.objects.create(name="c1")
doc_type = DocumentType.objects.create(name="dt1")
@@ -3379,7 +3416,7 @@ 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_next_asn(self) -> None:
+ def test_next_asn(self):
"""
GIVEN:
- Existing documents with ASNs, highest owned by user2
diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py
index 0fd893a5b..cbb4781f5 100644
--- a/src/documents/tests/test_workflows.py
+++ b/src/documents/tests/test_workflows.py
@@ -3757,6 +3757,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",
)
diff --git a/src/documents/views.py b/src/documents/views.py
index 60e9f008d..5c957dd96 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -87,6 +87,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
@@ -909,7 +910,7 @@ class DocumentViewSet(
return (
Document.objects.filter(root_document__isnull=True)
.distinct()
- .order_by("-created")
+ .order_by("-created", "-id")
.annotate(effective_content=Coalesce(latest_version_content, F("content")))
.annotate(num_notes=Count("notes"))
.select_related("correspondent", "storage_path", "document_type", "owner")
@@ -3739,7 +3740,14 @@ class TasksViewSet(ReadOnlyModelViewSet[PaperlessTask]):
)
-class ShareLinkViewSet(PassUserMixin, ModelViewSet[ShareLink]):
+class ShareLinkViewSet(
+ PassUserMixin,
+ CreateModelMixin,
+ RetrieveModelMixin,
+ DestroyModelMixin,
+ ListModelMixin,
+ GenericViewSet,
+):
model = ShareLink
queryset = ShareLink.objects.all()
diff --git a/src/documents/workflows/mutations.py b/src/documents/workflows/mutations.py
index b93a26781..1a5e62ac6 100644
--- a/src/documents/workflows/mutations.py
+++ b/src/documents/workflows/mutations.py
@@ -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
@@ -200,7 +194,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.
@@ -209,14 +202,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
diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po
index dcfa36940..897c4aa8d 100644
--- a/src/locale/en_US/LC_MESSAGES/django.po
+++ b/src/locale/en_US/LC_MESSAGES/django.po
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-13 21:13+0000\n"
+"POT-Creation-Date: 2026-04-14 22:14+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -1308,8 +1308,8 @@ msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:463 documents/serialisers.py:815
-#: documents/serialisers.py:2545 documents/views.py:2131
-#: documents/views.py:2186 paperless_mail/serialisers.py:143
+#: documents/serialisers.py:2547 documents/views.py:2132
+#: documents/views.py:2187 paperless_mail/serialisers.py:143
msgid "Insufficient permissions."
msgstr ""
@@ -1317,39 +1317,39 @@ msgstr ""
msgid "Invalid color."
msgstr ""
-#: documents/serialisers.py:2168
+#: documents/serialisers.py:2170
#, python-format
msgid "File type %(type)s not supported"
msgstr ""
-#: documents/serialisers.py:2212
+#: documents/serialisers.py:2214
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
-#: documents/serialisers.py:2219
+#: documents/serialisers.py:2221
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
-#: documents/serialisers.py:2236 documents/serialisers.py:2246
+#: documents/serialisers.py:2238 documents/serialisers.py:2248
msgid ""
"Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
-#: documents/serialisers.py:2241
+#: documents/serialisers.py:2243
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
-#: documents/serialisers.py:2388
+#: documents/serialisers.py:2390
msgid "Invalid variable detected."
msgstr ""
-#: documents/serialisers.py:2601
+#: documents/serialisers.py:2603
msgid "Duplicate document identifiers are not allowed."
msgstr ""
-#: documents/serialisers.py:2631 documents/views.py:3796
+#: documents/serialisers.py:2633 documents/views.py:3804
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1617,28 +1617,28 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
-#: documents/views.py:2088
+#: documents/views.py:2089
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
-#: documents/views.py:2124 documents/views.py:2183
+#: documents/views.py:2125 documents/views.py:2184
msgid "Invalid more_like_id"
msgstr ""
-#: documents/views.py:3808
+#: documents/views.py:3816
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
-#: documents/views.py:3851
+#: documents/views.py:3859
msgid "Bundle is already being processed."
msgstr ""
-#: documents/views.py:3908
+#: documents/views.py:3916
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
-#: documents/views.py:3918
+#: documents/views.py:3926
msgid "The share link bundle is unavailable."
msgstr ""
diff --git a/src/paperless/version.py b/src/paperless/version.py
index b0b675c87..e10dae825 100644
--- a/src/paperless/version.py
+++ b/src/paperless/version.py
@@ -1,6 +1,6 @@
from typing import Final
-__version__: Final[tuple[int, int, int]] = (2, 20, 13)
+__version__: Final[tuple[int, int, int]] = (2, 20, 14)
# Version string like X.Y.Z
__full_version_str__: Final[str] = ".".join(map(str, __version__))
# Version string like X.Y
diff --git a/src/paperless_mail/mail.py b/src/paperless_mail/mail.py
index ecc785b1a..75bb7b134 100644
--- a/src/paperless_mail/mail.py
+++ b/src/paperless_mail/mail.py
@@ -481,6 +481,7 @@ class MailAccountHandler(LoggingMixin):
name=name,
defaults={
"match": name,
+ "matching_algorithm": Correspondent.MATCH_LITERAL,
},
)[0]
except DatabaseError as e:
diff --git a/src/paperless_mail/tests/test_mail.py b/src/paperless_mail/tests/test_mail.py
index 80a718a46..7d038dd38 100644
--- a/src/paperless_mail/tests/test_mail.py
+++ b/src/paperless_mail/tests/test_mail.py
@@ -450,7 +450,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)
diff --git a/uv.lock b/uv.lock
index 470d18145..d4bb929cc 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2854,7 +2854,7 @@ wheels = [
[[package]]
name = "paperless-ngx"
-version = "2.20.13"
+version = "2.20.14"
source = { virtual = "." }
dependencies = [
{ name = "azure-ai-documentintelligence", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },