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,