From a11a2ec13f1c47ac4c99104ba94b896186bdf39f Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:29:49 -0700 Subject: [PATCH 01/18] Fix: resolve migration warning in 2.19.2 (#11157) --- .../migrations/1073_migrate_workflow_title_jinja.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/documents/migrations/1073_migrate_workflow_title_jinja.py b/src/documents/migrations/1073_migrate_workflow_title_jinja.py index 3f5689629..c3f929eff 100644 --- a/src/documents/migrations/1073_migrate_workflow_title_jinja.py +++ b/src/documents/migrations/1073_migrate_workflow_title_jinja.py @@ -35,15 +35,13 @@ class Migration(migrations.Migration): operations = [ migrations.AlterField( - model_name="WorkflowAction", + model_name="workflowaction", name="assign_title", field=models.TextField( - null=True, blank=True, - help_text=( - "Assign a document title, can be a JINJA2 template, " - "see documentation.", - ), + help_text="Assign a document title, must be a Jinja2 template, see documentation.", + null=True, + verbose_name="assign title", ), ), migrations.RunPython( From 276dc31abea03bbeb1c8a723520f2445135f4968 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 24 Oct 2025 09:42:05 -0700 Subject: [PATCH 02/18] Fix: add missing import of ConfirmButtonComponent in user-edit-dialog (#11167) --- src-ui/messages.xlf | 10 +++++----- .../user-edit-dialog/user-edit-dialog.component.ts | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 8eb325858..f466c2c09 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -4539,32 +4539,32 @@ Create new user account src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts - 70 + 72 Edit user account src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts - 74 + 76 Totp deactivated src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts - 130 + 132 Totp deactivation failed src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts - 133 + 135 src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts - 138 + 140 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 86e60151b..1c87a4308 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 @@ -14,6 +14,7 @@ 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' import { ToastService } from 'src/app/services/toast.service' +import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' import { PasswordComponent } from '../../input/password/password.component' import { SelectComponent } from '../../input/select/select.component' import { TextComponent } from '../../input/text/text.component' @@ -28,6 +29,7 @@ import { PermissionsSelectComponent } from '../../permissions-select/permissions SelectComponent, TextComponent, PasswordComponent, + ConfirmButtonComponent, FormsModule, ReactiveFormsModule, ], From 63dab0ab09448e84efed3c469d7a08f5b3b80c05 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 24 Oct 2025 16:25:59 -0700 Subject: [PATCH 03/18] Change: restrict superuser modifications to superusers only --- src/documents/tests/test_admin.py | 35 +++++++++++++++++++++++++++++++ src/paperless/views.py | 4 ++++ 2 files changed, 39 insertions(+) diff --git a/src/documents/tests/test_admin.py b/src/documents/tests/test_admin.py index 278014f7c..61a579dc7 100644 --- a/src/documents/tests/test_admin.py +++ b/src/documents/tests/test_admin.py @@ -2,9 +2,11 @@ import types from unittest.mock import patch from django.contrib.admin.sites import AdminSite +from django.contrib.auth.models import Permission from django.contrib.auth.models import User from django.test import TestCase from django.utils import timezone +from rest_framework import status from documents import index from documents.admin import DocumentAdmin @@ -125,3 +127,36 @@ class TestPaperlessAdmin(DirectoriesMixin, TestCase): form.request = types.SimpleNamespace(user=superuser) self.assertTrue(form.is_valid()) self.assertEqual({}, form.errors) + + def test_superuser_can_only_be_modified_by_superuser(self): + superuser = User.objects.create_superuser(username="superuser", password="test") + user = User.objects.create( + username="test", + is_superuser=False, + is_staff=True, + ) + change_user_perm = Permission.objects.get(codename="change_user") + user.user_permissions.add(change_user_perm) + + self.client.force_login(user) + response = self.client.patch( + f"/api/users/{superuser.pk}/", + {"first_name": "Updated"}, + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + response.content.decode(), + "Superusers can only be modified by other superusers", + ) + + self.client.logout() + self.client.force_login(superuser) + response = self.client.patch( + f"/api/users/{superuser.pk}/", + {"first_name": "Updated"}, + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + superuser.refresh_from_db() + self.assertEqual(superuser.first_name, "Updated") diff --git a/src/paperless/views.py b/src/paperless/views.py index fc5bb5463..69375e1bc 100644 --- a/src/paperless/views.py +++ b/src/paperless/views.py @@ -125,6 +125,10 @@ class UserViewSet(ModelViewSet): def update(self, request, *args, **kwargs): user_to_update: User = self.get_object() + if not request.user.is_superuser and user_to_update.is_superuser: + return HttpResponseForbidden( + "Superusers can only be modified by other superusers", + ) if ( not request.user.is_superuser and request.data.get("is_superuser") is not None From 1c4fa7237c150b68647c7e3fac4de041b02e4319 Mon Sep 17 00:00:00 2001 From: Tom Hu <88201630+thomasrockhu-codecov@users.noreply.github.com> Date: Sun, 26 Oct 2025 18:07:36 +0400 Subject: [PATCH 04/18] Chore: Move to using the codecov action instead of the test-results-action (#11179) --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a560c506e..17e9a4109 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -181,10 +181,11 @@ jobs: pytest - name: Upload backend test results to Codecov if: always() - uses: codecov/test-results-action@v1 + uses: codecov/codecov-action@v5 with: flags: backend-python-${{ matrix.python-version }} files: junit.xml + report_type: test_results - name: Upload backend coverage to Codecov uses: codecov/codecov-action@v5 with: @@ -260,11 +261,12 @@ jobs: - name: Run Jest unit tests run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }} - name: Upload frontend test results to Codecov - uses: codecov/test-results-action@v1 if: always() + uses: codecov/codecov-action@v5 with: flags: frontend-node-${{ matrix.node-version }} directory: src-ui/ + report_type: test_results - name: Upload frontend coverage to Codecov uses: codecov/codecov-action@v5 with: From 701aafce0672adcf4a203046c8882633861c9e3b Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 26 Oct 2025 12:14:31 -0700 Subject: [PATCH 05/18] Update issue and discussion templates --- .github/DISCUSSION_TEMPLATE/support.yml | 2 +- .github/ISSUE_TEMPLATE/bug-report.yml | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/DISCUSSION_TEMPLATE/support.yml b/.github/DISCUSSION_TEMPLATE/support.yml index 18c0812f2..311aaf7f3 100644 --- a/.github/DISCUSSION_TEMPLATE/support.yml +++ b/.github/DISCUSSION_TEMPLATE/support.yml @@ -51,5 +51,5 @@ body: id: logs attributes: label: Relevant logs or output - description: If you have logs, errors that might help, paste it here. + description: If you have logs, errors that might help, paste it here. For example other containers or services (database, redis, etc). render: bash diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 07e9e4690..b6baf49bf 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -6,8 +6,8 @@ body: - type: markdown attributes: value: | - ### ⚠️ Please remember: issues are for *bugs* - That is, something you believe affects every single user of Paperless-ngx, not just you. If you're not sure, start with one of the other options below. + ### ⚠️ Please remember: issues are for *bugs* only! ⚠️ + That is, something you believe affects every single user of Paperless-ngx (and the demo, for example), not just you. If you are not sure, start with one of the other options below. Also, note that **Paperless-ngx does not perform OCR or archive file creation itself**, those are handled by other tools. Problems with OCR or archive versions of specific files should likely be raised 'upstream', see https://github.com/ocrmypdf/OCRmyPDF/issues or https://github.com/tesseract-ocr/tesseract/issues - type: markdown @@ -59,6 +59,12 @@ body: label: Browser logs description: Logs from the web browser related to your issue, if needed render: bash + - type: textarea + id: logs_services + attributes: + label: Services logs + description: Logs from other services (or containers) related to your issue, if needed. For example, the database or redis logs. + render: bash - type: input id: version attributes: From 48d21da13b512f42c8dc0d417720d6da0bc66bb3 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 27 Oct 2025 10:37:57 -0700 Subject: [PATCH 06/18] Fix: support ConsumableDocument in email attachments (#11196) --- src/documents/mail.py | 29 ++++++----- src/documents/tests/test_workflows.py | 75 +++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/documents/mail.py b/src/documents/mail.py index 240b41e18..6af48ca9b 100644 --- a/src/documents/mail.py +++ b/src/documents/mail.py @@ -7,6 +7,8 @@ from django.conf import settings from django.core.mail import EmailMessage from filelock import FileLock +from documents.data_models import ConsumableDocument + if TYPE_CHECKING: from documents.models import Document @@ -15,7 +17,7 @@ def send_email( subject: str, body: str, to: list[str], - attachments: list[Document], + attachments: list[Document | ConsumableDocument], *, use_archive: bool, ) -> int: @@ -45,17 +47,20 @@ def send_email( # Something could be renaming the file concurrently so it can't be attached with FileLock(settings.MEDIA_LOCK): for document in attachments: - attachment_path = ( - document.archive_path - if use_archive and document.has_archive_version - else document.source_path - ) - - friendly_filename = _get_unique_filename( - document, - used_filenames, - archive=use_archive and document.has_archive_version, - ) + if isinstance(document, ConsumableDocument): + attachment_path = document.original_file + friendly_filename = document.original_file.name + else: + attachment_path = ( + document.archive_path + if use_archive and document.has_archive_version + else document.source_path + ) + friendly_filename = _get_unique_filename( + document, + used_filenames, + archive=use_archive and document.has_archive_version, + ) used_filenames.add(friendly_filename) with attachment_path.open("rb") as f: diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index a6da01578..c25565ae6 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -30,6 +30,7 @@ from pytest_django.fixtures import SettingsWrapper from documents import tasks from documents.data_models import ConsumableDocument +from documents.data_models import DocumentMetadataOverrides from documents.data_models import DocumentSource from documents.matching import document_matches_workflow from documents.matching import existing_document_matches_workflow @@ -2788,6 +2789,80 @@ class TestWorkflows( self.assertEqual(doc.tags.all().count(), 1) self.assertIn(self.t2, doc.tags.all()) + @override_settings( + PAPERLESS_EMAIL_HOST="localhost", + EMAIL_ENABLED=True, + PAPERLESS_URL="http://localhost:8000", + ) + @mock.patch("django.core.mail.message.EmailMessage.send") + def test_workflow_assignment_then_email_includes_attachment(self, mock_email_send): + """ + GIVEN: + - Workflow with assignment and email actions + - Email action configured to include the document + WHEN: + - Workflow is run on a newly created document + THEN: + - Email action sends the document as an attachment + """ + + storage_path = StoragePath.objects.create( + name="sp2", + path="workflow/{{ document.pk }}", + ) + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + ) + assignment_action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.ASSIGNMENT, + assign_storage_path=storage_path, + assign_owner=self.user2, + ) + assignment_action.assign_tags.add(self.t1) + + email_action_config = WorkflowActionEmail.objects.create( + subject="Doc ready {doc_title}", + body="Document URL: {doc_url}", + to="owner@example.com", + include_document=True, + ) + email_action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.EMAIL, + email=email_action_config, + ) + + workflow = Workflow.objects.create(name="Assignment then email", order=0) + workflow.triggers.add(trigger) + workflow.actions.set([assignment_action, email_action]) + + temp_working_copy = shutil.copy( + self.SAMPLE_DIR / "simple.pdf", + self.dirs.scratch_dir / "working-copy.pdf", + ) + + Document.objects.create( + title="workflow doc", + correspondent=self.c, + checksum="wf-assignment-email", + mime_type="application/pdf", + ) + + consumable_document = ConsumableDocument( + source=DocumentSource.ConsumeFolder, + original_file=temp_working_copy, + ) + + mock_email_send.return_value = 1 + + with self.assertNoLogs("paperless.handlers", level="ERROR"): + run_workflows( + WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + consumable_document, + overrides=DocumentMetadataOverrides(), + ) + + mock_email_send.assert_called_once() + @override_settings( PAPERLESS_EMAIL_HOST="localhost", EMAIL_ENABLED=True, From cd81f750b4a39b9f7c12949fa7c062aa4489486b Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Mon, 27 Oct 2025 13:24:57 -0700 Subject: [PATCH 07/18] Chore: Minor migration optimization for workflow titles (#11197) * Makes the migration just a little more efficient * Do it in batches, just in case * Fixes the model klass name --- .../1073_migrate_workflow_title_jinja.py | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/src/documents/migrations/1073_migrate_workflow_title_jinja.py b/src/documents/migrations/1073_migrate_workflow_title_jinja.py index c3f929eff..9d80a277f 100644 --- a/src/documents/migrations/1073_migrate_workflow_title_jinja.py +++ b/src/documents/migrations/1073_migrate_workflow_title_jinja.py @@ -3,7 +3,6 @@ import logging from django.db import migrations from django.db import models -from django.db import transaction from documents.templating.utils import convert_format_str_to_template_format @@ -11,21 +10,34 @@ logger = logging.getLogger("paperless.migrations") def convert_from_format_to_template(apps, schema_editor): - WorkflowActions = apps.get_model("documents", "WorkflowAction") + WorkflowAction = apps.get_model("documents", "WorkflowAction") - with transaction.atomic(): - for WorkflowAction in WorkflowActions.objects.all(): - if not WorkflowAction.assign_title: - continue - WorkflowAction.assign_title = convert_format_str_to_template_format( - WorkflowAction.assign_title, - ) - logger.debug( - "Converted WorkflowAction id %d title to template format: %s", - WorkflowAction.id, - WorkflowAction.assign_title, - ) - WorkflowAction.save() + batch_size = 500 + actions_to_update = [] + + queryset = ( + WorkflowAction.objects.filter(assign_title__isnull=False) + .exclude(assign_title="") + .only("id", "assign_title") + ) + + for action in queryset: + action.assign_title = convert_format_str_to_template_format( + action.assign_title, + ) + logger.debug( + "Converted WorkflowAction id %d title to template format: %s", + action.id, + action.assign_title, + ) + actions_to_update.append(action) + + if actions_to_update: + WorkflowAction.objects.bulk_update( + actions_to_update, + ["assign_title"], + batch_size=batch_size, + ) class Migration(migrations.Migration): From d0bd111eaba58c061c381b279c5dad8913b47a39 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 27 Oct 2025 13:51:39 -0700 Subject: [PATCH 08/18] Change: make workflowrun a softdeletemodel (#11194) --- ...ted_at_workflowrun_restored_at_and_more.py | 28 +++++++++++++++++++ src/documents/models.py | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 src/documents/migrations/1074_workflowrun_deleted_at_workflowrun_restored_at_and_more.py diff --git a/src/documents/migrations/1074_workflowrun_deleted_at_workflowrun_restored_at_and_more.py b/src/documents/migrations/1074_workflowrun_deleted_at_workflowrun_restored_at_and_more.py new file mode 100644 index 000000000..4381eabb1 --- /dev/null +++ b/src/documents/migrations/1074_workflowrun_deleted_at_workflowrun_restored_at_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.6 on 2025-10-27 15:11 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("documents", "1073_migrate_workflow_title_jinja"), + ] + + operations = [ + migrations.AddField( + model_name="workflowrun", + name="deleted_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="workflowrun", + name="restored_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="workflowrun", + name="transaction_id", + field=models.UUIDField(blank=True, null=True), + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 4794bc82f..12dab2b6d 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -1547,7 +1547,7 @@ class Workflow(models.Model): return f"Workflow: {self.name}" -class WorkflowRun(models.Model): +class WorkflowRun(SoftDeleteModel): workflow = models.ForeignKey( Workflow, on_delete=models.CASCADE, From 35bc67364811423114f41c8ad44424123e09ec7c Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 27 Oct 2025 21:09:19 -0700 Subject: [PATCH 09/18] Update workflows.py --- src/documents/templating/workflows.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/documents/templating/workflows.py b/src/documents/templating/workflows.py index 25f1e57ef..67f3ac930 100644 --- a/src/documents/templating/workflows.py +++ b/src/documents/templating/workflows.py @@ -80,7 +80,7 @@ def parse_w_workflow_placeholders( if doc_url is not None: formatting.update({"doc_url": doc_url}) - logger.debug(f"Jinja Template is : {text}") + logger.debug(f"Parsing Workflow Jinja template: {text}") try: template = _template_environment.from_string( text, From d904aaef60a90cd02e20db385b87cf5b005c63f9 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 28 Oct 2025 10:14:42 -0700 Subject: [PATCH 10/18] Change: make workflow action only title draggable (#11209) --- .../workflow-edit-dialog.component.html | 8 +++++--- .../workflow-edit-dialog.component.scss | 4 ++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html index 61daa1fa2..fab644baa 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -77,9 +77,11 @@
@for (action of object?.actions; track action; let i = $index){ -
-
-