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'" },