Compare commits

...

9 Commits

Author SHA1 Message Date
shamoon
35bc673648 Update workflows.py 2025-10-27 21:09:19 -07:00
shamoon
d0bd111eab Change: make workflowrun a softdeletemodel (#11194) 2025-10-27 20:51:39 +00:00
Trenton H
cd81f750b4 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
2025-10-27 13:24:57 -07:00
shamoon
48d21da13b Fix: support ConsumableDocument in email attachments (#11196) 2025-10-27 10:37:57 -07:00
shamoon
701aafce06 Update issue and discussion templates 2025-10-26 12:14:31 -07:00
Tom Hu
1c4fa7237c Chore: Move to using the codecov action instead of the test-results-action (#11179) 2025-10-26 07:07:36 -07:00
shamoon
63dab0ab09 Change: restrict superuser modifications to superusers only 2025-10-24 16:25:59 -07:00
shamoon
276dc31abe Fix: add missing import of ConfirmButtonComponent in user-edit-dialog (#11167) 2025-10-24 15:50:46 -07:00
shamoon
a11a2ec13f Fix: resolve migration warning in 2.19.2 (#11157) 2025-10-23 15:29:49 -07:00
13 changed files with 212 additions and 45 deletions

View File

@@ -51,5 +51,5 @@ body:
id: logs id: logs
attributes: attributes:
label: Relevant logs or output 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 render: bash

View File

@@ -6,8 +6,8 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
### ⚠️ Please remember: issues are for *bugs* ### ⚠️ Please remember: issues are for *bugs* only! ⚠️
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. 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 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 - type: markdown
@@ -59,6 +59,12 @@ body:
label: Browser logs label: Browser logs
description: Logs from the web browser related to your issue, if needed description: Logs from the web browser related to your issue, if needed
render: bash 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 - type: input
id: version id: version
attributes: attributes:

View File

@@ -181,10 +181,11 @@ jobs:
pytest pytest
- name: Upload backend test results to Codecov - name: Upload backend test results to Codecov
if: always() if: always()
uses: codecov/test-results-action@v1 uses: codecov/codecov-action@v5
with: with:
flags: backend-python-${{ matrix.python-version }} flags: backend-python-${{ matrix.python-version }}
files: junit.xml files: junit.xml
report_type: test_results
- name: Upload backend coverage to Codecov - name: Upload backend coverage to Codecov
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v5
with: with:
@@ -260,11 +261,12 @@ jobs:
- name: Run Jest unit tests - name: Run Jest unit tests
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }} run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
- name: Upload frontend test results to Codecov - name: Upload frontend test results to Codecov
uses: codecov/test-results-action@v1
if: always() if: always()
uses: codecov/codecov-action@v5
with: with:
flags: frontend-node-${{ matrix.node-version }} flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/ directory: src-ui/
report_type: test_results
- name: Upload frontend coverage to Codecov - name: Upload frontend coverage to Codecov
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v5
with: with:

View File

@@ -4539,32 +4539,32 @@
<source>Create new user account</source> <source>Create new user account</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context>
<context context-type="linenumber">70</context> <context context-type="linenumber">72</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2887331217965896363" datatype="html"> <trans-unit id="2887331217965896363" datatype="html">
<source>Edit user account</source> <source>Edit user account</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context>
<context context-type="linenumber">74</context> <context context-type="linenumber">76</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5872286584705575476" datatype="html"> <trans-unit id="5872286584705575476" datatype="html">
<source>Totp deactivated</source> <source>Totp deactivated</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context>
<context context-type="linenumber">130</context> <context context-type="linenumber">132</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6439190193788239059" datatype="html"> <trans-unit id="6439190193788239059" datatype="html">
<source>Totp deactivation failed</source> <source>Totp deactivation failed</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context>
<context context-type="linenumber">133</context> <context context-type="linenumber">135</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context>
<context context-type="linenumber">138</context> <context context-type="linenumber">140</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8419515490539218007" datatype="html"> <trans-unit id="8419515490539218007" datatype="html">

View File

@@ -14,6 +14,7 @@ import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.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 { PasswordComponent } from '../../input/password/password.component'
import { SelectComponent } from '../../input/select/select.component' import { SelectComponent } from '../../input/select/select.component'
import { TextComponent } from '../../input/text/text.component' import { TextComponent } from '../../input/text/text.component'
@@ -28,6 +29,7 @@ import { PermissionsSelectComponent } from '../../permissions-select/permissions
SelectComponent, SelectComponent,
TextComponent, TextComponent,
PasswordComponent, PasswordComponent,
ConfirmButtonComponent,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
], ],

View File

@@ -7,6 +7,8 @@ from django.conf import settings
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from filelock import FileLock from filelock import FileLock
from documents.data_models import ConsumableDocument
if TYPE_CHECKING: if TYPE_CHECKING:
from documents.models import Document from documents.models import Document
@@ -15,7 +17,7 @@ def send_email(
subject: str, subject: str,
body: str, body: str,
to: list[str], to: list[str],
attachments: list[Document], attachments: list[Document | ConsumableDocument],
*, *,
use_archive: bool, use_archive: bool,
) -> int: ) -> int:
@@ -45,17 +47,20 @@ def send_email(
# Something could be renaming the file concurrently so it can't be attached # Something could be renaming the file concurrently so it can't be attached
with FileLock(settings.MEDIA_LOCK): with FileLock(settings.MEDIA_LOCK):
for document in attachments: for document in attachments:
attachment_path = ( if isinstance(document, ConsumableDocument):
document.archive_path attachment_path = document.original_file
if use_archive and document.has_archive_version friendly_filename = document.original_file.name
else document.source_path else:
) attachment_path = (
document.archive_path
friendly_filename = _get_unique_filename( if use_archive and document.has_archive_version
document, else document.source_path
used_filenames, )
archive=use_archive and document.has_archive_version, friendly_filename = _get_unique_filename(
) document,
used_filenames,
archive=use_archive and document.has_archive_version,
)
used_filenames.add(friendly_filename) used_filenames.add(friendly_filename)
with attachment_path.open("rb") as f: with attachment_path.open("rb") as f:

View File

@@ -3,7 +3,6 @@ import logging
from django.db import migrations from django.db import migrations
from django.db import models from django.db import models
from django.db import transaction
from documents.templating.utils import convert_format_str_to_template_format 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): def convert_from_format_to_template(apps, schema_editor):
WorkflowActions = apps.get_model("documents", "WorkflowAction") WorkflowAction = apps.get_model("documents", "WorkflowAction")
with transaction.atomic(): batch_size = 500
for WorkflowAction in WorkflowActions.objects.all(): actions_to_update = []
if not WorkflowAction.assign_title:
continue queryset = (
WorkflowAction.assign_title = convert_format_str_to_template_format( WorkflowAction.objects.filter(assign_title__isnull=False)
WorkflowAction.assign_title, .exclude(assign_title="")
) .only("id", "assign_title")
logger.debug( )
"Converted WorkflowAction id %d title to template format: %s",
WorkflowAction.id, for action in queryset:
WorkflowAction.assign_title, action.assign_title = convert_format_str_to_template_format(
) action.assign_title,
WorkflowAction.save() )
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): class Migration(migrations.Migration):
@@ -35,15 +47,13 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name="WorkflowAction", model_name="workflowaction",
name="assign_title", name="assign_title",
field=models.TextField( field=models.TextField(
null=True,
blank=True, blank=True,
help_text=( help_text="Assign a document title, must be a Jinja2 template, see documentation.",
"Assign a document title, can be a JINJA2 template, " null=True,
"see documentation.", verbose_name="assign title",
),
), ),
), ),
migrations.RunPython( migrations.RunPython(

View File

@@ -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),
),
]

View File

@@ -1547,7 +1547,7 @@ class Workflow(models.Model):
return f"Workflow: {self.name}" return f"Workflow: {self.name}"
class WorkflowRun(models.Model): class WorkflowRun(SoftDeleteModel):
workflow = models.ForeignKey( workflow = models.ForeignKey(
Workflow, Workflow,
on_delete=models.CASCADE, on_delete=models.CASCADE,

View File

@@ -80,7 +80,7 @@ def parse_w_workflow_placeholders(
if doc_url is not None: if doc_url is not None:
formatting.update({"doc_url": doc_url}) formatting.update({"doc_url": doc_url})
logger.debug(f"Jinja Template is : {text}") logger.debug(f"Parsing Workflow Jinja template: {text}")
try: try:
template = _template_environment.from_string( template = _template_environment.from_string(
text, text,

View File

@@ -2,9 +2,11 @@ import types
from unittest.mock import patch from unittest.mock import patch
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import TestCase from django.test import TestCase
from django.utils import timezone from django.utils import timezone
from rest_framework import status
from documents import index from documents import index
from documents.admin import DocumentAdmin from documents.admin import DocumentAdmin
@@ -125,3 +127,36 @@ class TestPaperlessAdmin(DirectoriesMixin, TestCase):
form.request = types.SimpleNamespace(user=superuser) form.request = types.SimpleNamespace(user=superuser)
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
self.assertEqual({}, form.errors) 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")

View File

@@ -30,6 +30,7 @@ from pytest_django.fixtures import SettingsWrapper
from documents import tasks from documents import tasks
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.matching import document_matches_workflow from documents.matching import document_matches_workflow
from documents.matching import existing_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.assertEqual(doc.tags.all().count(), 1)
self.assertIn(self.t2, doc.tags.all()) 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( @override_settings(
PAPERLESS_EMAIL_HOST="localhost", PAPERLESS_EMAIL_HOST="localhost",
EMAIL_ENABLED=True, EMAIL_ENABLED=True,

View File

@@ -125,6 +125,10 @@ class UserViewSet(ModelViewSet):
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
user_to_update: User = self.get_object() 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 ( if (
not request.user.is_superuser not request.user.is_superuser
and request.data.get("is_superuser") is not None and request.data.get("is_superuser") is not None