diff --git a/Pipfile b/Pipfile
index 403089d9c..361a3a877 100644
--- a/Pipfile
+++ b/Pipfile
@@ -14,7 +14,7 @@ django-celery-results = "*"
django-compression-middleware = "*"
django-cors-headers = "*"
django-extensions = "*"
-django-filter = "~=24.3"
+django-filter = "~=25.1"
django-guardian = "*"
django-multiselectfield = "*"
django-soft-delete = "*"
diff --git a/Pipfile.lock b/Pipfile.lock
index 0e14207c1..b80cd3e31 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "8c1714db280429940577d7b88e5a59a63f92147a15bc6ae14fc4ff2820f2790e"
+ "sha256": "4d54b43e6f093a817b2dc9b923f93b889bf7a42cd937ea971cd8773484fc4636"
},
"pipfile-spec": 6,
"requires": {},
@@ -577,12 +577,12 @@
},
"django-filter": {
"hashes": [
- "sha256:c4852822928ce17fb699bcfccd644b3574f1a2d80aeb2b4ff4f16b02dd49dc64",
- "sha256:d8ccaf6732afd21ca0542f6733b11591030fa98669f8d15599b358e24a2cd9c3"
+ "sha256:1ec9eef48fa8da1c0ac9b411744b16c3f4c31176c867886e4c48da369c407153",
+ "sha256:4fa48677cf5857b9b1347fed23e355ea792464e0fe07244d1fdfb8a806215b80"
],
"index": "pypi",
- "markers": "python_version >= '3.8'",
- "version": "==24.3"
+ "markers": "python_version >= '3.9'",
+ "version": "==25.1"
},
"django-guardian": {
"hashes": [
@@ -3582,12 +3582,12 @@
},
"mkdocs-material": {
"hashes": [
- "sha256:1125622067e26940806701219303b27c0933e04533560725d97ec26fd16a39cf",
- "sha256:c87f7d1c39ce6326da5e10e232aed51bae46252e646755900f4b0fc9192fa832"
+ "sha256:414e8376551def6d644b8e6f77226022868532a792eb2c9accf52199009f568f",
+ "sha256:4d1d35e1c1d3e15294cb7fa5d02e0abaee70d408f75027dc7be6e30fb32e6867"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
- "version": "==9.6.3"
+ "version": "==9.6.4"
},
"mkdocs-material-extensions": {
"hashes": [
diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf
index 29b0236f0..fe15461a7 100644
--- a/src-ui/messages.xlf
+++ b/src-ui/messages.xlf
@@ -2230,7 +2230,7 @@
src/app/components/manage/custom-fields/custom-fields.component.ts
- 103
+ 106
src/app/components/manage/mail/mail.component.ts
@@ -2565,7 +2565,7 @@
src/app/components/manage/custom-fields/custom-fields.component.ts
- 105
+ 108
src/app/components/manage/mail/mail.component.ts
@@ -3322,7 +3322,7 @@
src/app/components/manage/custom-fields/custom-fields.component.ts
- 85
+ 87
@@ -3333,7 +3333,7 @@
src/app/components/manage/custom-fields/custom-fields.component.ts
- 93
+ 96
@@ -8193,28 +8193,28 @@
Confirm delete field
src/app/components/manage/custom-fields/custom-fields.component.ts
- 101
+ 104
This operation will permanently delete this field.
src/app/components/manage/custom-fields/custom-fields.component.ts
- 102
+ 105
Deleted field ""
src/app/components/manage/custom-fields/custom-fields.component.ts
- 111
+ 114
Error deleting field "".
src/app/components/manage/custom-fields/custom-fields.component.ts
- 118
+ 122
diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
index 0908b69c0..5facc5cce 100644
--- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
+++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
@@ -71,6 +71,10 @@ export const DOCUMENT_SOURCE_OPTIONS = [
id: DocumentSource.MailFetch,
name: $localize`Mail Fetch`,
},
+ {
+ id: DocumentSource.WebUI,
+ name: $localize`Web UI`,
+ },
]
export const SCHEDULE_DATE_FIELD_OPTIONS = [
diff --git a/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.ts b/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.ts
index 32bf7a004..8399b06e1 100644
--- a/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.ts
+++ b/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.ts
@@ -145,7 +145,10 @@ export class SavedViewWidgetComponent
})
}
- if (this.savedView.display_fields) {
+ if (
+ this.savedView.display_fields &&
+ this.savedView.display_fields.length > 0
+ ) {
this.displayFields = this.savedView.display_fields
}
diff --git a/src-ui/src/app/data/workflow-trigger.ts b/src-ui/src/app/data/workflow-trigger.ts
index 12f76b7a3..4299356b0 100644
--- a/src-ui/src/app/data/workflow-trigger.ts
+++ b/src-ui/src/app/data/workflow-trigger.ts
@@ -4,6 +4,7 @@ export enum DocumentSource {
ConsumeFolder = 1,
ApiUpload = 2,
MailFetch = 3,
+ WebUI = 4,
}
export enum WorkflowTriggerType {
diff --git a/src-ui/src/app/services/rest/saved-view.service.spec.ts b/src-ui/src/app/services/rest/saved-view.service.spec.ts
index 9a84fbd2c..7b12e8533 100644
--- a/src-ui/src/app/services/rest/saved-view.service.spec.ts
+++ b/src-ui/src/app/services/rest/saved-view.service.spec.ts
@@ -114,6 +114,48 @@ describe(`Additional service tests for SavedViewService`, () => {
])
})
+ it('should treat empty display_fields as null', () => {
+ subscription = service
+ .patch({
+ id: 1,
+ name: 'Saved View',
+ show_on_dashboard: true,
+ show_in_sidebar: true,
+ sort_field: 'name',
+ sort_reverse: true,
+ filter_rules: [],
+ display_fields: [],
+ })
+ .subscribe()
+ const req = httpTestingController.expectOne(
+ `${environment.apiBaseUrl}${endpoint}/1/`
+ )
+ expect(req.request.body.display_fields).toBeNull()
+ })
+
+ it('should support patch without reload', () => {
+ subscription = service
+ .patch(
+ {
+ id: 1,
+ name: 'Saved View',
+ show_on_dashboard: true,
+ show_in_sidebar: true,
+ sort_field: 'name',
+ sort_reverse: true,
+ filter_rules: [],
+ },
+ false
+ )
+ .subscribe()
+ const req = httpTestingController.expectOne(
+ `${environment.apiBaseUrl}${endpoint}/1/`
+ )
+ expect(req.request.method).toEqual('PATCH')
+ req.flush({})
+ httpTestingController.verify() // no reload
+ })
+
beforeEach(() => {
// Dont need to setup again
diff --git a/src-ui/src/app/services/rest/saved-view.service.ts b/src-ui/src/app/services/rest/saved-view.service.ts
index a23de7cd1..ef794ae06 100644
--- a/src-ui/src/app/services/rest/saved-view.service.ts
+++ b/src-ui/src/app/services/rest/saved-view.service.ts
@@ -87,12 +87,21 @@ export class SavedViewService extends AbstractPaperlessService {
return super.create(o).pipe(tap(() => this.reload()))
}
- update(o: SavedView) {
- return super.update(o).pipe(tap(() => this.reload()))
+ patch(o: SavedView, reload: boolean = false): Observable {
+ if (o.display_fields?.length === 0) {
+ o.display_fields = null
+ }
+ return super.patch(o).pipe(
+ tap(() => {
+ if (reload) {
+ this.reload()
+ }
+ })
+ )
}
patchMany(objects: SavedView[]): Observable {
- return combineLatest(objects.map((o) => super.patch(o))).pipe(
+ return combineLatest(objects.map((o) => this.patch(o, false))).pipe(
tap(() => this.reload())
)
}
diff --git a/src-ui/src/app/services/upload-documents.service.ts b/src-ui/src/app/services/upload-documents.service.ts
index 602e6d8ae..e2d1b52f4 100644
--- a/src-ui/src/app/services/upload-documents.service.ts
+++ b/src-ui/src/app/services/upload-documents.service.ts
@@ -37,6 +37,7 @@ export class UploadDocumentsService {
private uploadFile(file: File) {
let formData = new FormData()
formData.append('document', file, file.name)
+ formData.append('from_webui', 'true')
let status = this.websocketStatusService.newFileUpload(file.name)
status.message = $localize`Connecting...`
diff --git a/src/documents/data_models.py b/src/documents/data_models.py
index 231e59005..406fe6b5a 100644
--- a/src/documents/data_models.py
+++ b/src/documents/data_models.py
@@ -144,6 +144,7 @@ class DocumentSource(IntEnum):
ConsumeFolder = 1
ApiUpload = 2
MailFetch = 3
+ WebUI = 4
@dataclasses.dataclass
diff --git a/src/documents/migrations/1063_alter_workflowactionwebhook_url_and_more.py b/src/documents/migrations/1063_alter_workflowactionwebhook_url_and_more.py
new file mode 100644
index 000000000..16c1eeb63
--- /dev/null
+++ b/src/documents/migrations/1063_alter_workflowactionwebhook_url_and_more.py
@@ -0,0 +1,52 @@
+# Generated by Django 5.1.6 on 2025-02-20 04:55
+
+import multiselectfield.db.fields
+from django.db import migrations
+from django.db import models
+
+
+# WebUI source was added, so all existing APIUpload sources should be updated to include WebUI
+def update_workflow_sources(apps, schema_editor):
+ WorkflowTrigger = apps.get_model("documents", "WorkflowTrigger")
+ for trigger in WorkflowTrigger.objects.all():
+ sources = list(trigger.sources)
+ if 2 in sources:
+ sources.append(4)
+ trigger.sources = sources
+ trigger.save()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("documents", "1062_alter_savedviewfilterrule_rule_type"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="workflowactionwebhook",
+ name="url",
+ field=models.CharField(
+ help_text="The destination URL for the notification.",
+ max_length=256,
+ verbose_name="webhook url",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="workflowtrigger",
+ name="sources",
+ field=multiselectfield.db.fields.MultiSelectField(
+ choices=[
+ (1, "Consume Folder"),
+ (2, "Api Upload"),
+ (3, "Mail Fetch"),
+ (4, "Web UI"),
+ ],
+ default="1,2,3,4",
+ max_length=7,
+ ),
+ ),
+ migrations.RunPython(
+ code=update_workflow_sources,
+ reverse_code=migrations.RunPython.noop,
+ ),
+ ]
diff --git a/src/documents/models.py b/src/documents/models.py
index 94888ce9f..3f7d459a9 100644
--- a/src/documents/models.py
+++ b/src/documents/models.py
@@ -1054,6 +1054,7 @@ class WorkflowTrigger(models.Model):
CONSUME_FOLDER = DocumentSource.ConsumeFolder.value, _("Consume Folder")
API_UPLOAD = DocumentSource.ApiUpload.value, _("Api Upload")
MAIL_FETCH = DocumentSource.MailFetch.value, _("Mail Fetch")
+ WEB_UI = DocumentSource.WebUI.value, _("Web UI")
class ScheduleDateField(models.TextChoices):
ADDED = "added", _("Added")
@@ -1068,9 +1069,9 @@ class WorkflowTrigger(models.Model):
)
sources = MultiSelectField(
- max_length=5,
+ max_length=7,
choices=DocumentSourceChoices.choices,
- default=f"{DocumentSource.ConsumeFolder},{DocumentSource.ApiUpload},{DocumentSource.MailFetch}",
+ default=f"{DocumentSource.ConsumeFolder},{DocumentSource.ApiUpload},{DocumentSource.MailFetch},{DocumentSource.WebUI}",
)
filter_path = models.CharField(
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index d93b0a65f..251eb8797 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -1147,6 +1147,15 @@ class SavedViewSerializer(OwnedObjectSerializer):
if "user" in validated_data:
# backwards compatibility
validated_data["owner"] = validated_data.pop("user")
+ if (
+ "display_fields" in validated_data
+ and isinstance(
+ validated_data["display_fields"],
+ list,
+ )
+ and len(validated_data["display_fields"]) == 0
+ ):
+ validated_data["display_fields"] = None
super().update(instance, validated_data)
if rules_data is not None:
SavedViewFilterRule.objects.filter(saved_view=instance).delete()
@@ -1537,6 +1546,12 @@ class PostDocumentSerializer(serializers.Serializer):
required=False,
)
+ from_webui = serializers.BooleanField(
+ label="Documents are from Paperless-ngx WebUI",
+ write_only=True,
+ required=False,
+ )
+
def validate_document(self, document):
document_data = document.file.read()
mime_type = magic.from_buffer(document_data, mime=True)
diff --git a/src/documents/tasks.py b/src/documents/tasks.py
index 23b97bb08..e3c579743 100644
--- a/src/documents/tasks.py
+++ b/src/documents/tasks.py
@@ -360,7 +360,7 @@ def empty_trash(doc_ids=None):
)
try:
- deleted_document_ids = documents.values_list("id", flat=True)
+ deleted_document_ids = list(documents.values_list("id", flat=True))
# Temporarily connect the cleanup handler
models.signals.post_delete.connect(cleanup_document_deletion, sender=Document)
documents.delete() # this is effectively a hard delete
diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py
index 7010c5095..6247b0a6e 100644
--- a/src/documents/tests/test_api_documents.py
+++ b/src/documents/tests/test_api_documents.py
@@ -38,6 +38,7 @@ from documents.models import SavedView
from documents.models import ShareLink
from documents.models import StoragePath
from documents.models import Tag
+from documents.models import WorkflowTrigger
from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import DocumentConsumeDelayMixin
@@ -1362,6 +1363,30 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
self.assertEqual(overrides.filename, "simple.pdf")
self.assertEqual(overrides.custom_field_ids, [custom_field.id])
+ def test_upload_with_webui_source(self):
+ """
+ GIVEN: A document with a source file
+ WHEN: Upload the document with 'from_webui' flag
+ THEN: Consume is called with the source set as WebUI
+ """
+ self.consume_file_mock.return_value = celery.result.AsyncResult(
+ id=str(uuid.uuid4()),
+ )
+
+ with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
+ response = self.client.post(
+ "/api/documents/post_document/",
+ {"document": f, "from_webui": True},
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ self.consume_file_mock.assert_called_once()
+
+ input_doc, overrides = self.get_last_consume_delay_call_args()
+
+ self.assertEqual(input_doc.source, WorkflowTrigger.DocumentSourceChoices.WEB_UI)
+
def test_upload_invalid_pdf(self):
"""
GIVEN: Invalid PDF named "*.pdf" that mime_type is in settings.CONSUMER_PDF_RECOVERABLE_MIME_TYPES
@@ -1815,6 +1840,19 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+ # empty display fields treated as none
+ response = self.client.patch(
+ f"/api/saved_views/{v1.id}/",
+ {
+ "display_fields": [],
+ },
+ format="json",
+ )
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ v1.refresh_from_db()
+ self.assertEqual(v1.display_fields, None)
+
def test_saved_view_display_customfields(self):
"""
GIVEN:
diff --git a/src/documents/views.py b/src/documents/views.py
index 08164a91f..92aea1fe4 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -1387,6 +1387,7 @@ class PostDocumentView(GenericAPIView):
created = serializer.validated_data.get("created")
archive_serial_number = serializer.validated_data.get("archive_serial_number")
custom_field_ids = serializer.validated_data.get("custom_fields")
+ from_webui = serializer.validated_data.get("from_webui")
t = int(mktime(datetime.now().timetuple()))
@@ -1401,7 +1402,7 @@ class PostDocumentView(GenericAPIView):
os.utime(temp_file_path, times=(t, t))
input_doc = ConsumableDocument(
- source=DocumentSource.ApiUpload,
+ source=DocumentSource.WebUI if from_webui else DocumentSource.ApiUpload,
original_file=temp_file_path,
)
input_doc_overrides = DocumentMetadataOverrides(
diff --git a/src/paperless/urls.py b/src/paperless/urls.py
index e5a6065be..fa237fe5c 100644
--- a/src/paperless/urls.py
+++ b/src/paperless/urls.py
@@ -305,6 +305,11 @@ urlpatterns = [
],
),
),
+ re_path(
+ r"^confirm-email/(?P[-:\w]+)/$",
+ allauth_account_views.ConfirmEmailView.as_view(),
+ name="account_confirm_email",
+ ),
re_path(
r"^password/reset/key/(?P[0-9A-Za-z]+)-(?P.+)/$",
allauth_account_views.password_reset_from_key,