diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
index 349e213aa..b85a7eaf4 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
+++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
@@ -1330,4 +1330,18 @@ describe('DocumentDetailComponent', () => {
expect(createSpy).toHaveBeenCalledWith('a')
expect(urlRevokeSpy).toHaveBeenCalled()
})
+
+ it('should get email enabled status from settings', () => {
+ jest.spyOn(settingsService, 'get').mockReturnValue(true)
+ expect(component.emailEnabled).toBeTruthy()
+ })
+
+ it('should support open share links and email modals', () => {
+ const modalSpy = jest.spyOn(modalService, 'open')
+ initNormally()
+ component.openShareLinks()
+ expect(modalSpy).toHaveBeenCalled()
+ component.openEmailDocument()
+ expect(modalSpy).toHaveBeenCalled()
+ })
})
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts
index 30e34d9cf..27a74cfcd 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.ts
+++ b/src-ui/src/app/components/document-detail/document-detail.component.ts
@@ -88,6 +88,7 @@ import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspo
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { EditDialogMode } from '../common/edit-dialog/edit-dialog.component'
import { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
+import { EmailDocumentDialogComponent } from '../common/email-document-dialog/email-document-dialog.component'
import { CheckComponent } from '../common/input/check/check.component'
import { DateComponent } from '../common/input/date/date.component'
import { DocumentLinkComponent } from '../common/input/document-link/document-link.component'
@@ -99,7 +100,7 @@ import { TagsComponent } from '../common/input/tags/tags.component'
import { TextComponent } from '../common/input/text/text.component'
import { UrlComponent } from '../common/input/url/url.component'
import { PageHeaderComponent } from '../common/page-header/page-header.component'
-import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/share-links-dropdown.component'
+import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
import { DocumentHistoryComponent } from '../document-history/document-history.component'
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
@@ -145,7 +146,6 @@ export enum ZoomSetting {
CustomFieldsDropdownComponent,
DocumentNotesComponent,
DocumentHistoryComponent,
- ShareLinksDropdownComponent,
CheckComponent,
DateComponent,
DocumentLinkComponent,
@@ -1426,6 +1426,26 @@ export class DocumentDetailComponent
})
}
+ public openShareLinks() {
+ const modal = this.modalService.open(ShareLinksDialogComponent)
+ modal.componentInstance.documentId = this.document.id
+ modal.componentInstance.hasArchiveVersion =
+ !!this.document?.archived_file_name
+ }
+
+ get emailEnabled(): boolean {
+ return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED)
+ }
+
+ public openEmailDocument() {
+ const modal = this.modalService.open(EmailDocumentDialogComponent, {
+ backdrop: 'static',
+ })
+ modal.componentInstance.documentId = this.document.id
+ modal.componentInstance.hasArchiveVersion =
+ !!this.document?.archived_file_name
+ }
+
private tryRenderTiff() {
this.http.get(this.previewUrl, { responseType: 'arraybuffer' }).subscribe({
next: (res) => {
diff --git a/src-ui/src/app/services/rest/document.service.spec.ts b/src-ui/src/app/services/rest/document.service.spec.ts
index 4d7d7cef7..84f7f6f8a 100644
--- a/src-ui/src/app/services/rest/document.service.spec.ts
+++ b/src-ui/src/app/services/rest/document.service.spec.ts
@@ -355,6 +355,21 @@ it('should include custom fields in sort fields if user has permission', () => {
])
})
+it('should call appropriate api endpoint for email document', () => {
+ subscription = service
+ .emailDocument(
+ documents[0].id,
+ 'hello@paperless-ngx.com',
+ 'hello',
+ 'world',
+ true
+ )
+ .subscribe()
+ httpTestingController.expectOne(
+ `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/email/`
+ )
+})
+
afterEach(() => {
subscription?.unsubscribe()
httpTestingController.verify()
diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts
index bbb611adf..0c6c8cfa6 100644
--- a/src-ui/src/app/services/rest/document.service.ts
+++ b/src-ui/src/app/services/rest/document.service.ts
@@ -258,4 +258,19 @@ export class DocumentService extends AbstractPaperlessService
{
public get searchQuery(): string {
return this._searchQuery
}
+
+ emailDocument(
+ documentId: number,
+ addresses: string,
+ subject: string,
+ message: string,
+ useArchiveVersion: boolean
+ ): Observable {
+ return this.http.post(this.getResourceUrl(documentId, 'email'), {
+ addresses: addresses,
+ subject: subject,
+ message: message,
+ use_archive_version: useArchiveVersion,
+ })
+ }
}
diff --git a/src-ui/src/main.ts b/src-ui/src/main.ts
index a9d446891..dd31a6b1e 100644
--- a/src-ui/src/main.ts
+++ b/src-ui/src/main.ts
@@ -112,6 +112,7 @@ import {
questionCircle,
scissors,
search,
+ send,
slashCircle,
sliders2Vertical,
sortAlphaDown,
@@ -316,6 +317,7 @@ const icons = {
questionCircle,
scissors,
search,
+ send,
slashCircle,
sliders2Vertical,
sortAlphaDown,
diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py
index 6247b0a6e..28261b392 100644
--- a/src/documents/tests/test_api_documents.py
+++ b/src/documents/tests/test_api_documents.py
@@ -15,6 +15,7 @@ from dateutil import parser
from django.conf import settings
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
+from django.core import mail
from django.core.cache import cache
from django.db import DataError
from django.test import override_settings
@@ -2651,6 +2652,153 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(doc1.tags.count(), 2)
+ @override_settings(
+ EMAIL_ENABLED=True,
+ EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
+ )
+ def test_email_document(self):
+ """
+ GIVEN:
+ - Existing document
+ WHEN:
+ - API request is made to email document action
+ THEN:
+ - Email is sent, with document (original or archive) attached
+ """
+ doc = Document.objects.create(
+ title="test",
+ mime_type="application/pdf",
+ content="this is a document 1",
+ checksum="1",
+ filename="test.pdf",
+ archive_checksum="A",
+ archive_filename="archive.pdf",
+ )
+ doc2 = Document.objects.create(
+ title="test2",
+ mime_type="application/pdf",
+ content="this is a document 2",
+ checksum="2",
+ filename="test2.pdf",
+ )
+
+ archive_file = Path(__file__).parent / "samples" / "simple.pdf"
+ source_file = Path(__file__).parent / "samples" / "simple.pdf"
+
+ shutil.copy(archive_file, doc.archive_path)
+ shutil.copy(source_file, doc2.source_path)
+
+ self.client.post(
+ f"/api/documents/{doc.pk}/email/",
+ {
+ "addresses": "hello@paperless-ngx.com",
+ "subject": "test",
+ "message": "hello",
+ },
+ )
+
+ self.assertEqual(len(mail.outbox), 1)
+ self.assertEqual(mail.outbox[0].attachments[0][0], "archive.pdf")
+
+ self.client.post(
+ f"/api/documents/{doc2.pk}/email/",
+ {
+ "addresses": "hello@paperless-ngx.com",
+ "subject": "test",
+ "message": "hello",
+ "use_archive_version": False,
+ },
+ )
+
+ self.assertEqual(len(mail.outbox), 2)
+ self.assertEqual(mail.outbox[1].attachments[0][0], "test2.pdf")
+
+ @mock.patch("django.core.mail.message.EmailMessage.send", side_effect=Exception)
+ def test_email_document_errors(self, mocked_send):
+ """
+ GIVEN:
+ - Existing document
+ WHEN:
+ - API request is made to email document action with insufficient permissions
+ - API request is made to email document action with invalid document id
+ - API request is made to email document action with missing data
+ - API request is made to email document action with invalid email address
+ - API request is made to email document action and error occurs during email send
+ THEN:
+ - Error response is returned
+ """
+ user1 = User.objects.create_user(username="test1")
+ user1.user_permissions.add(*Permission.objects.all())
+ user1.save()
+
+ doc = Document.objects.create(
+ title="test",
+ mime_type="application/pdf",
+ content="this is a document 1",
+ checksum="1",
+ filename="test.pdf",
+ archive_checksum="A",
+ archive_filename="archive.pdf",
+ )
+
+ doc2 = Document.objects.create(
+ title="test2",
+ mime_type="application/pdf",
+ content="this is a document 2",
+ checksum="2",
+ owner=self.user,
+ )
+
+ self.client.force_authenticate(user1)
+
+ resp = self.client.post(
+ f"/api/documents/{doc2.pk}/email/",
+ {
+ "addresses": "hello@paperless-ngx.com",
+ "subject": "test",
+ "message": "hello",
+ },
+ )
+ self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
+
+ resp = self.client.post(
+ "/api/documents/999/email/",
+ {
+ "addresses": "hello@paperless-ngx.com",
+ "subject": "test",
+ "message": "hello",
+ },
+ )
+ self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
+
+ resp = self.client.post(
+ f"/api/documents/{doc.pk}/email/",
+ {
+ "addresses": "hello@paperless-ngx.com",
+ },
+ )
+ self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
+
+ resp = self.client.post(
+ f"/api/documents/{doc.pk}/email/",
+ {
+ "addresses": "hello@paperless-ngx.com,hello",
+ "subject": "test",
+ "message": "hello",
+ },
+ )
+ self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
+
+ resp = self.client.post(
+ f"/api/documents/{doc.pk}/email/",
+ {
+ "addresses": "hello@paperless-ngx.com",
+ "subject": "test",
+ "message": "hello",
+ },
+ )
+ self.assertEqual(resp.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
+
@mock.patch("django_softdelete.models.SoftDeleteModel.delete")
def test_warn_on_delete_with_old_uuid_field(self, mocked_delete):
"""
diff --git a/src/documents/views.py b/src/documents/views.py
index aceea6699..a4e35a2f4 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -37,6 +37,7 @@ from django.http import HttpResponse
from django.http import HttpResponseBadRequest
from django.http import HttpResponseForbidden
from django.http import HttpResponseRedirect
+from django.http import HttpResponseServerError
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.decorators import method_decorator
@@ -106,6 +107,7 @@ from documents.filters import ObjectOwnedPermissionsFilter
from documents.filters import ShareLinkFilterSet
from documents.filters import StoragePathFilterSet
from documents.filters import TagFilterSet
+from documents.mail import send_email
from documents.matching import match_correspondents
from documents.matching import match_document_types
from documents.matching import match_storage_paths
@@ -1023,6 +1025,57 @@ class DocumentViewSet(
return Response(sorted(entries, key=lambda x: x["timestamp"], reverse=True))
+ @action(methods=["post"], detail=True)
+ def email(self, request, pk=None):
+ try:
+ doc = Document.objects.select_related("owner").get(pk=pk)
+ if request.user is not None and not has_perms_owner_aware(
+ request.user,
+ "view_document",
+ doc,
+ ):
+ return HttpResponseForbidden("Insufficient permissions")
+ except Document.DoesNotExist:
+ raise Http404
+
+ try:
+ if (
+ "addresses" not in request.data
+ or "subject" not in request.data
+ or "message" not in request.data
+ ):
+ return HttpResponseBadRequest("Missing required fields")
+
+ use_archive_version = request.data.get("use_archive_version", True)
+
+ addresses = request.data.get("addresses").split(",")
+ if not all(
+ re.match(r"[^@]+@[^@]+\.[^@]+", address.strip())
+ for address in addresses
+ ):
+ return HttpResponseBadRequest("Invalid email address found")
+
+ send_email(
+ subject=request.data.get("subject"),
+ body=request.data.get("message"),
+ to=addresses,
+ attachment=(
+ doc.archive_path
+ if use_archive_version and doc.has_archive_version
+ else doc.source_path
+ ),
+ attachment_mime_type=doc.mime_type,
+ )
+ logger.debug(
+ f"Sent document {doc.id} via email to {addresses}",
+ )
+ return Response({"message": "Email sent"})
+ except Exception as e:
+ logger.warning(f"An error occurred emailing document: {e!s}")
+ return HttpResponseServerError(
+ "Error emailing document, check logs for more detail.",
+ )
+
@extend_schema_view(
list=extend_schema(
From b40479632b99fdfd3dae6dafb2733b9be09cf012 Mon Sep 17 00:00:00 2001
From: Andy Grunwald
Date: Fri, 21 Feb 2025 19:20:40 +0100
Subject: [PATCH 02/10] Development: Fix ImageMagick policy.xml path in
devcontainer setup (#9188)
---
.devcontainer/Dockerfile | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 2aecc45a1..777f833da 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -123,13 +123,13 @@ RUN set -eux \
WORKDIR /usr/src/paperless/src/docker/
COPY [ \
- "docker/imagemagick-policy.xml", \
+ "docker/rootfs/etc/ImageMagick-6/paperless-policy.xml", \
"./" \
]
RUN set -eux \
&& echo "Configuring ImageMagick" \
- && mv imagemagick-policy.xml /etc/ImageMagick-6/policy.xml
+ && mv paperless-policy.xml /etc/ImageMagick-6/policy.xml
# Packages needed only for building a few quick Python
# dependencies
From 6b7fb286f7ef4b6e8a80cce4f35135e4a7a7209a Mon Sep 17 00:00:00 2001
From: Max Mehl <6170081+mxmehl@users.noreply.github.com>
Date: Fri, 21 Feb 2025 22:29:21 +0100
Subject: [PATCH 03/10] Chore: bump gotenberg docker images (#9189)
* Chore: update gotenberg Docker images to latest minor version
* Chore: update gotenberg Docker images to latest minor version for devcontainer
---
.devcontainer/docker-compose.devcontainer.sqlite-tika.yml | 2 +-
docker/compose/docker-compose.ci-test.yml | 2 +-
docker/compose/docker-compose.mariadb-tika.yml | 2 +-
docker/compose/docker-compose.postgres-tika.yml | 2 +-
docker/compose/docker-compose.sqlite-tika.yml | 2 +-
5 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/.devcontainer/docker-compose.devcontainer.sqlite-tika.yml b/.devcontainer/docker-compose.devcontainer.sqlite-tika.yml
index 7209339e1..fa463da5c 100644
--- a/.devcontainer/docker-compose.devcontainer.sqlite-tika.yml
+++ b/.devcontainer/docker-compose.devcontainer.sqlite-tika.yml
@@ -65,7 +65,7 @@ services:
command: /bin/sh -c "chown -R paperless:paperless /usr/src/paperless/paperless-ngx/src/documents/static/frontend && chown -R paperless:paperless /usr/src/paperless/paperless-ngx/.ruff_cache && while sleep 1000; do :; done"
gotenberg:
- image: docker.io/gotenberg/gotenberg:7.10
+ image: docker.io/gotenberg/gotenberg:8.17
restart: unless-stopped
# The Gotenberg Chromium route is used to convert .eml files. We do not
diff --git a/docker/compose/docker-compose.ci-test.yml b/docker/compose/docker-compose.ci-test.yml
index d67aa9f61..343027cf2 100644
--- a/docker/compose/docker-compose.ci-test.yml
+++ b/docker/compose/docker-compose.ci-test.yml
@@ -5,7 +5,7 @@
services:
gotenberg:
- image: docker.io/gotenberg/gotenberg:8.7
+ image: docker.io/gotenberg/gotenberg:8.17
hostname: gotenberg
container_name: gotenberg
network_mode: host
diff --git a/docker/compose/docker-compose.mariadb-tika.yml b/docker/compose/docker-compose.mariadb-tika.yml
index b451ce9e8..c158797a5 100644
--- a/docker/compose/docker-compose.mariadb-tika.yml
+++ b/docker/compose/docker-compose.mariadb-tika.yml
@@ -77,7 +77,7 @@ services:
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
- image: docker.io/gotenberg/gotenberg:8.7
+ image: docker.io/gotenberg/gotenberg:8.17
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.
diff --git a/docker/compose/docker-compose.postgres-tika.yml b/docker/compose/docker-compose.postgres-tika.yml
index e168dfadc..28acd55b0 100644
--- a/docker/compose/docker-compose.postgres-tika.yml
+++ b/docker/compose/docker-compose.postgres-tika.yml
@@ -71,7 +71,7 @@ services:
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
- image: docker.io/gotenberg/gotenberg:8.7
+ image: docker.io/gotenberg/gotenberg:8.17
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
diff --git a/docker/compose/docker-compose.sqlite-tika.yml b/docker/compose/docker-compose.sqlite-tika.yml
index abfb64cdf..54292a845 100644
--- a/docker/compose/docker-compose.sqlite-tika.yml
+++ b/docker/compose/docker-compose.sqlite-tika.yml
@@ -59,7 +59,7 @@ services:
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
- image: docker.io/gotenberg/gotenberg:8.7
+ image: docker.io/gotenberg/gotenberg:8.17
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
From ea911e73c6c7be8cb51646ae5b9f8da0e453e2f3 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Sat, 22 Feb 2025 07:27:44 -0800
Subject: [PATCH 04/10] Fix: correct split confirm removal (#9195)
---
.../split-confirm-dialog/split-confirm-dialog.component.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.ts b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.ts
index 785f2f3d4..437418367 100644
--- a/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.ts
+++ b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.ts
@@ -84,7 +84,7 @@ export class SplitConfirmDialogComponent
addSplit() {
if (this.page === this.totalPages) return
this.pages.add(this.page)
- this.pages = new Set(Array.from(this.pages).sort())
+ this.pages = new Set(Array.from(this.pages).sort((a, b) => a - b))
this.confirmButtonEnabled = this.pages.size > 0
}
From a548c32c1fcec5a79a6e930d07a4ba113573b5aa Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Sun, 23 Feb 2025 13:52:41 -0800
Subject: [PATCH 05/10] Enhancement: allow disabling the filesystem consumer
(#9199)
---
.../etc/s6-overlay/s6-rc.d/svc-consumer/run | 16 ++++++++++++----
docs/configuration.md | 5 +++++
docs/setup.md | 2 ++
3 files changed, 19 insertions(+), 4 deletions(-)
diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/svc-consumer/run b/docker/rootfs/etc/s6-overlay/s6-rc.d/svc-consumer/run
index 3e1c0472b..209803d41 100755
--- a/docker/rootfs/etc/s6-overlay/s6-rc.d/svc-consumer/run
+++ b/docker/rootfs/etc/s6-overlay/s6-rc.d/svc-consumer/run
@@ -1,10 +1,18 @@
#!/command/with-contenv /usr/bin/bash
# shellcheck shell=bash
-cd ${PAPERLESS_SRC_DIR}
-if [[ -n "${USER_IS_NON_ROOT}" ]]; then
- exec python3 manage.py document_consumer
+if [[ -n "${PAPERLESS_CONSUMER_DISABLE}" ]]; then
+ echo "[svc-consumer] Consumer is disabled, exiting"
+ # https://skarnet.org/software/s6/s6-svc.html
+ s6-svc -Od .
+
else
- exec s6-setuidgid paperless python3 manage.py document_consumer
+ cd ${PAPERLESS_SRC_DIR}
+
+ if [[ -n "${USER_IS_NON_ROOT}" ]]; then
+ exec python3 manage.py document_consumer
+ else
+ exec s6-setuidgid paperless python3 manage.py document_consumer
+ fi
fi
diff --git a/docs/configuration.md b/docs/configuration.md
index 3724c792d..441d46105 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1030,6 +1030,11 @@ be used with caution!
## Document Consumption {#consume_config}
+#### [`PAPERLESS_CONSUMER_DISABLE=`](#PAPERLESS_CONSUMER_DISABLE) {#PAPERLESS_CONSUMER_DISABLE}
+
+: Completely disable the directory-based consumer in docker. If you don't plan to consume documents
+via the consumption directory, you can disable the consumer to save resources.
+
#### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES}
: When the consumer detects a duplicate document, it will not touch
diff --git a/docs/setup.md b/docs/setup.md
index f2b82d070..e337a492a 100644
--- a/docs/setup.md
+++ b/docs/setup.md
@@ -714,6 +714,8 @@ the Pi and configuring some options in paperless can help improve
performance immensely:
- Stick with SQLite to save some resources.
+- If you do not need the filesystem-based consumer, consider disabling it
+ entirely by setting [`PAPERLESS_CONSUMER_DISABLE`](configuration.md#PAPERLESS_CONSUMER_DISABLE) to `true`.
- Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that paperless will
only OCR the first page of your documents. In most cases, this page
contains enough information to be able to find it.
From 047f7c3619a30a24f6c9736f80491decc7d19b8e Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Mon, 24 Feb 2025 09:23:20 -0800
Subject: [PATCH 06/10] Enhancement: support default groups for regular and
social account signup (#9039)
---
docs/configuration.md | 31 +++++++++-
src/paperless/adapter.py | 38 ++++++++++--
src/paperless/apps.py | 6 ++
src/paperless/settings.py | 3 +
src/paperless/signals.py | 18 ++++++
src/paperless/tests/test_adapter.py | 36 ++++++++++++
src/paperless/tests/test_signals.py | 91 +++++++++++++++++++++++++++++
7 files changed, 216 insertions(+), 7 deletions(-)
diff --git a/docs/configuration.md b/docs/configuration.md
index 441d46105..391b97d13 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -557,6 +557,20 @@ This is for use with self-signed certificates against local IMAP servers.
Settings this value has security implications for the security of your email.
Understand what it does and be sure you need to before setting.
+### Authentication & SSO {#authentication}
+
+#### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS}
+
+: Allow users to signup for a new Paperless-ngx account.
+
+ Defaults to False
+
+#### [`PAPERLESS_ACCOUNT_DEFAULT_GROUPS=`](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_ACCOUNT_DEFAULT_GROUPS}
+
+: A list of group names that users will be added to when they sign up for a new account. Groups listed here must already exist.
+
+ Defaults to None
+
#### [`PAPERLESS_SOCIALACCOUNT_PROVIDERS=`](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) {#PAPERLESS_SOCIALACCOUNT_PROVIDERS}
: This variable is used to setup login and signup via social account providers which are compatible with django-allauth.
@@ -580,12 +594,25 @@ system. See the corresponding
Defaults to True
-#### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS}
+#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS=`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS}
-: Allow users to signup for a new Paperless-ngx account.
+: Sync groups from the third party authentication system (e.g. OIDC) to Paperless-ngx. When enabled, users will be added or removed from groups based on their group membership in the third party authentication system. Groups must already exist in Paperless-ngx and have the same name as in the third party authentication system. Groups are updated upon logging in via the third party authentication system, see the corresponding [django-allauth documentation](https://docs.allauth.org/en/dev/socialaccount/signals.html).
+
+: In order to pass groups from the authentication system you will need to update your [PAPERLESS_SOCIALACCOUNT_PROVIDERS](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) setting by adding a top-level "SCOPES" setting which includes "groups", e.g.:
+
+ ```json
+ {"openid_connect":{"SCOPE": ["openid","profile","email","groups"]...
+ ```
Defaults to False
+#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
+
+: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
+If both the [PAPERLESS_ACCOUNT_DEFAULT_GROUPS](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS) setting and this setting are used, the user will be added to both sets of groups.
+
+ Defaults to None
+
#### [`PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL=`](#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL) {#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL}
: The protocol used when generating URLs, e.g. login callback URLs. See the corresponding
diff --git a/src/paperless/adapter.py b/src/paperless/adapter.py
index add2bf45d..e29acb2ff 100644
--- a/src/paperless/adapter.py
+++ b/src/paperless/adapter.py
@@ -1,12 +1,17 @@
+import logging
from urllib.parse import quote
from allauth.account.adapter import DefaultAccountAdapter
from allauth.core import context
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from django.conf import settings
+from django.contrib.auth.models import Group
+from django.contrib.auth.models import User
from django.forms import ValidationError
from django.urls import reverse
+logger = logging.getLogger("paperless.auth")
+
class CustomAccountAdapter(DefaultAccountAdapter):
def is_open_for_signup(self, request):
@@ -61,6 +66,20 @@ class CustomAccountAdapter(DefaultAccountAdapter):
path = path.replace("UID-KEY", quote(key))
return settings.PAPERLESS_URL + path
+ def save_user(self, request, user, form, commit=True): # noqa: FBT002
+ """
+ Save the user instance. Default groups are assigned to the user, if
+ specified in the settings.
+ """
+ user: User = super().save_user(request, user, form, commit)
+ group_names: list[str] = settings.ACCOUNT_DEFAULT_GROUPS
+ if len(group_names) > 0:
+ groups = Group.objects.filter(name__in=group_names)
+ logger.debug(f"Adding default groups to user `{user}`: {group_names}")
+ user.groups.add(*groups)
+ user.save()
+ return user
+
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
def is_open_for_signup(self, request, sociallogin):
@@ -80,10 +99,19 @@ class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
url = reverse("base")
return url
- def populate_user(self, request, sociallogin, data):
+ def save_user(self, request, sociallogin, form=None):
"""
- Populate the user with data from the social account. Stub is kept in case
- global default permissions are implemented in the future.
+ Save the user instance. Default groups are assigned to the user, if
+ specified in the settings.
"""
- # TODO: If default global permissions are implemented, should also be here
- return super().populate_user(request, sociallogin, data) # pragma: no cover
+ # save_user also calls account_adapter save_user which would set ACCOUNT_DEFAULT_GROUPS
+ user: User = super().save_user(request, sociallogin, form)
+ group_names: list[str] = settings.SOCIAL_ACCOUNT_DEFAULT_GROUPS
+ if len(group_names) > 0:
+ groups = Group.objects.filter(name__in=group_names)
+ logger.debug(
+ f"Adding default social groups to user `{user}`: {group_names}",
+ )
+ user.groups.add(*groups)
+ user.save()
+ return user
diff --git a/src/paperless/apps.py b/src/paperless/apps.py
index b4147a2e3..819d8d5ff 100644
--- a/src/paperless/apps.py
+++ b/src/paperless/apps.py
@@ -2,6 +2,7 @@ from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
from paperless.signals import handle_failed_login
+from paperless.signals import handle_social_account_updated
class PaperlessConfig(AppConfig):
@@ -13,4 +14,9 @@ class PaperlessConfig(AppConfig):
from django.contrib.auth.signals import user_login_failed
user_login_failed.connect(handle_failed_login)
+
+ from allauth.socialaccount.signals import social_account_updated
+
+ social_account_updated.connect(handle_social_account_updated)
+
AppConfig.ready(self)
diff --git a/src/paperless/settings.py b/src/paperless/settings.py
index 8072f694e..0c8c71ab9 100644
--- a/src/paperless/settings.py
+++ b/src/paperless/settings.py
@@ -480,6 +480,7 @@ ACCOUNT_DEFAULT_HTTP_PROTOCOL = os.getenv(
ACCOUNT_ADAPTER = "paperless.adapter.CustomAccountAdapter"
ACCOUNT_ALLOW_SIGNUPS = __get_boolean("PAPERLESS_ACCOUNT_ALLOW_SIGNUPS")
+ACCOUNT_DEFAULT_GROUPS = __get_list("PAPERLESS_ACCOUNT_DEFAULT_GROUPS")
SOCIALACCOUNT_ADAPTER = "paperless.adapter.CustomSocialAccountAdapter"
SOCIALACCOUNT_ALLOW_SIGNUPS = __get_boolean(
@@ -490,6 +491,8 @@ SOCIALACCOUNT_AUTO_SIGNUP = __get_boolean("PAPERLESS_SOCIAL_AUTO_SIGNUP")
SOCIALACCOUNT_PROVIDERS = json.loads(
os.getenv("PAPERLESS_SOCIALACCOUNT_PROVIDERS", "{}"),
)
+SOCIAL_ACCOUNT_DEFAULT_GROUPS = __get_list("PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS")
+SOCIAL_ACCOUNT_SYNC_GROUPS = __get_boolean("PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS")
MFA_TOTP_ISSUER = "Paperless-ngx"
diff --git a/src/paperless/signals.py b/src/paperless/signals.py
index fa0298685..a173ccc2e 100644
--- a/src/paperless/signals.py
+++ b/src/paperless/signals.py
@@ -30,3 +30,21 @@ def handle_failed_login(sender, credentials, request, **kwargs):
log_output += f" from private IP `{client_ip}`."
logger.info(log_output)
+
+
+def handle_social_account_updated(sender, request, sociallogin, **kwargs):
+ """
+ Handle the social account update signal.
+ """
+ from django.contrib.auth.models import Group
+
+ social_account_groups = sociallogin.account.extra_data.get(
+ "groups",
+ [],
+ ) # None if not found
+ if settings.SOCIAL_ACCOUNT_SYNC_GROUPS and social_account_groups is not None:
+ groups = Group.objects.filter(name__in=social_account_groups)
+ logger.debug(
+ f"Syncing groups for user `{sociallogin.user}`: {social_account_groups}",
+ )
+ sociallogin.user.groups.set(groups, clear=True)
diff --git a/src/paperless/tests/test_adapter.py b/src/paperless/tests/test_adapter.py
index 5659a279a..be4ad3d90 100644
--- a/src/paperless/tests/test_adapter.py
+++ b/src/paperless/tests/test_adapter.py
@@ -4,6 +4,8 @@ from allauth.account.adapter import get_adapter
from allauth.core import context
from allauth.socialaccount.adapter import get_adapter as get_social_adapter
from django.conf import settings
+from django.contrib.auth.models import Group
+from django.contrib.auth.models import User
from django.forms import ValidationError
from django.http import HttpRequest
from django.test import TestCase
@@ -81,6 +83,24 @@ class TestCustomAccountAdapter(TestCase):
expected_url,
)
+ @override_settings(ACCOUNT_DEFAULT_GROUPS=["group1", "group2"])
+ def test_save_user_adds_groups(self):
+ Group.objects.create(name="group1")
+ user = User.objects.create_user("testuser")
+ adapter = get_adapter()
+ form = mock.Mock(
+ cleaned_data={
+ "username": "testuser",
+ "email": "user@example.com",
+ },
+ )
+
+ user = adapter.save_user(HttpRequest(), user, form, commit=True)
+
+ self.assertEqual(user.groups.count(), 1)
+ self.assertTrue(user.groups.filter(name="group1").exists())
+ self.assertFalse(user.groups.filter(name="group2").exists())
+
class TestCustomSocialAccountAdapter(TestCase):
def test_is_open_for_signup(self):
@@ -105,3 +125,19 @@ class TestCustomSocialAccountAdapter(TestCase):
adapter.get_connect_redirect_url(request, socialaccount),
expected_url,
)
+
+ @override_settings(SOCIAL_ACCOUNT_DEFAULT_GROUPS=["group1", "group2"])
+ def test_save_user_adds_groups(self):
+ Group.objects.create(name="group1")
+ adapter = get_social_adapter()
+ request = HttpRequest()
+ user = User.objects.create_user("testuser")
+ sociallogin = mock.Mock(
+ user=user,
+ )
+
+ user = adapter.save_user(request, sociallogin, None)
+
+ self.assertEqual(user.groups.count(), 1)
+ self.assertTrue(user.groups.filter(name="group1").exists())
+ self.assertFalse(user.groups.filter(name="group2").exists())
diff --git a/src/paperless/tests/test_signals.py b/src/paperless/tests/test_signals.py
index dc425d667..0948ca575 100644
--- a/src/paperless/tests/test_signals.py
+++ b/src/paperless/tests/test_signals.py
@@ -1,7 +1,13 @@
+from unittest.mock import Mock
+
+from django.contrib.auth.models import Group
+from django.contrib.auth.models import User
from django.http import HttpRequest
from django.test import TestCase
+from django.test import override_settings
from paperless.signals import handle_failed_login
+from paperless.signals import handle_social_account_updated
class TestFailedLoginLogging(TestCase):
@@ -99,3 +105,88 @@ class TestFailedLoginLogging(TestCase):
"INFO:paperless.auth:Login failed for user `john lennon` from private IP `10.0.0.1`.",
],
)
+
+
+class TestSyncSocialLoginGroups(TestCase):
+ @override_settings(SOCIAL_ACCOUNT_SYNC_GROUPS=True)
+ def test_sync_enabled(self):
+ """
+ GIVEN:
+ - Enabled group syncing, a user, and a social login
+ WHEN:
+ - The social login is updated via signal after login
+ THEN:
+ - The user's groups are updated to match the social login's groups
+ """
+ group = Group.objects.create(name="group1")
+ user = User.objects.create_user(username="testuser")
+ sociallogin = Mock(
+ user=user,
+ account=Mock(
+ extra_data={
+ "groups": ["group1"],
+ },
+ ),
+ )
+ handle_social_account_updated(
+ sender=None,
+ request=HttpRequest(),
+ sociallogin=sociallogin,
+ )
+ self.assertEqual(list(user.groups.all()), [group])
+
+ @override_settings(SOCIAL_ACCOUNT_SYNC_GROUPS=False)
+ def test_sync_disabled(self):
+ """
+ GIVEN:
+ - Disabled group syncing, a user, and a social login
+ WHEN:
+ - The social login is updated via signal after login
+ THEN:
+ - The user's groups are not updated
+ """
+ Group.objects.create(name="group1")
+ user = User.objects.create_user(username="testuser")
+ sociallogin = Mock(
+ user=user,
+ account=Mock(
+ extra_data={
+ "groups": ["group1"],
+ },
+ ),
+ )
+ handle_social_account_updated(
+ sender=None,
+ request=HttpRequest(),
+ sociallogin=sociallogin,
+ )
+ self.assertEqual(list(user.groups.all()), [])
+
+ @override_settings(SOCIAL_ACCOUNT_SYNC_GROUPS=True)
+ def test_no_groups(self):
+ """
+ GIVEN:
+ - Enabled group syncing, a user, and a social login with no groups
+ WHEN:
+ - The social login is updated via signal after login
+ THEN:
+ - The user's groups are cleared to match the social login's groups
+ """
+ group = Group.objects.create(name="group1")
+ user = User.objects.create_user(username="testuser")
+ user.groups.add(group)
+ user.save()
+ sociallogin = Mock(
+ user=user,
+ account=Mock(
+ extra_data={
+ "groups": [],
+ },
+ ),
+ )
+ handle_social_account_updated(
+ sender=None,
+ request=HttpRequest(),
+ sociallogin=sociallogin,
+ )
+ self.assertEqual(list(user.groups.all()), [])
From 3104417076245027f85e18e67f4278609e3e49ec Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Mon, 24 Feb 2025 12:51:52 -0800
Subject: [PATCH 07/10] Enhancement: include celery log in logs view (#9214)
---
src/documents/views.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/documents/views.py b/src/documents/views.py
index a4e35a2f4..7809f84f1 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -1194,7 +1194,7 @@ class UnifiedSearchViewSet(DocumentViewSet):
class LogViewSet(ViewSet):
permission_classes = (IsAuthenticated, PaperlessAdminPermissions)
- log_files = ["paperless", "mail"]
+ log_files = ["paperless", "mail", "celery"]
def get_log_filename(self, log):
return os.path.join(settings.LOGGING_DIR, f"{log}.log")
From 827fcba2778de919265f5eece76dcf5586effca3 Mon Sep 17 00:00:00 2001
From: Trenton H <797416+stumpylog@users.noreply.github.com>
Date: Mon, 24 Feb 2025 15:06:14 -0800
Subject: [PATCH 08/10] Chore: Reduce imports for a slight memory improvement
(#9217)
---
.ruff.toml | 2 +-
src/documents/barcodes.py | 5 ++++-
src/documents/bulk_download.py | 8 +++++---
src/documents/bulk_edit.py | 7 ++++++-
src/documents/caching.py | 5 +++--
src/documents/classifier.py | 10 ++++++----
src/documents/filters.py | 7 ++++++-
src/documents/index.py | 13 +++++++++----
src/documents/matching.py | 7 ++++++-
src/documents/parsers.py | 11 ++++++++---
src/documents/serialisers.py | 8 +++++++-
src/documents/signals/handlers.py | 14 ++++++++++----
12 files changed, 71 insertions(+), 26 deletions(-)
diff --git a/.ruff.toml b/.ruff.toml
index ae1bed609..0fc170c96 100644
--- a/.ruff.toml
+++ b/.ruff.toml
@@ -26,7 +26,7 @@ extend-select = [
"T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
"TID", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid
- "TCH", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tch
+ "TC", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tc
"PLC", # https://docs.astral.sh/ruff/rules/#pylint-pl
"PLE", # https://docs.astral.sh/ruff/rules/#pylint-pl
"RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf
diff --git a/src/documents/barcodes.py b/src/documents/barcodes.py
index 4fe0670af..3b0c1d33b 100644
--- a/src/documents/barcodes.py
+++ b/src/documents/barcodes.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import logging
import re
import tempfile
@@ -10,7 +12,6 @@ from pdf2image import convert_from_path
from pikepdf import Page
from pikepdf import PasswordError
from pikepdf import Pdf
-from PIL import Image
from documents.converters import convert_from_tiff_to_pdf
from documents.data_models import ConsumableDocument
@@ -25,6 +26,8 @@ from documents.utils import maybe_override_pixel_limit
if TYPE_CHECKING:
from collections.abc import Callable
+ from PIL import Image
+
logger = logging.getLogger("paperless.barcodes")
diff --git a/src/documents/bulk_download.py b/src/documents/bulk_download.py
index 5bdc3e74a..7e87f0488 100644
--- a/src/documents/bulk_download.py
+++ b/src/documents/bulk_download.py
@@ -1,12 +1,14 @@
+from __future__ import annotations
+
from pathlib import Path
from typing import TYPE_CHECKING
from typing import NoReturn
-from zipfile import ZipFile
-
-from documents.models import Document
if TYPE_CHECKING:
from collections.abc import Callable
+ from zipfile import ZipFile
+
+ from documents.models import Document
class BulkArchiveStrategy:
diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py
index f6adfc8a9..be4608e36 100644
--- a/src/documents/bulk_edit.py
+++ b/src/documents/bulk_edit.py
@@ -1,8 +1,11 @@
+from __future__ import annotations
+
import hashlib
import itertools
import logging
import tempfile
from pathlib import Path
+from typing import TYPE_CHECKING
from typing import Literal
from celery import chain
@@ -10,7 +13,6 @@ from celery import chord
from celery import group
from celery import shared_task
from django.conf import settings
-from django.contrib.auth.models import User
from django.db.models import Q
from django.utils import timezone
@@ -29,6 +31,9 @@ from documents.tasks import bulk_update_documents
from documents.tasks import consume_file
from documents.tasks import update_document_content_maybe_archive_file
+if TYPE_CHECKING:
+ from django.contrib.auth.models import User
+
logger: logging.Logger = logging.getLogger("paperless.bulk_edit")
diff --git a/src/documents/caching.py b/src/documents/caching.py
index 6eb2b691f..1099a7a73 100644
--- a/src/documents/caching.py
+++ b/src/documents/caching.py
@@ -1,9 +1,10 @@
+from __future__ import annotations
+
import logging
from binascii import hexlify
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import Final
-from typing import Optional
from django.core.cache import cache
@@ -80,7 +81,7 @@ def get_suggestion_cache(document_id: int) -> SuggestionCacheData | None:
def set_suggestions_cache(
document_id: int,
suggestions: dict,
- classifier: Optional["DocumentClassifier"],
+ classifier: DocumentClassifier | None,
*,
timeout=CACHE_50_MINUTES,
) -> None:
diff --git a/src/documents/classifier.py b/src/documents/classifier.py
index 5bc8be2c6..548a4e833 100644
--- a/src/documents/classifier.py
+++ b/src/documents/classifier.py
@@ -1,22 +1,22 @@
+from __future__ import annotations
+
import logging
import pickle
import re
import time
import warnings
-from collections.abc import Iterator
from hashlib import sha256
from pathlib import Path
from typing import TYPE_CHECKING
-from typing import Optional
if TYPE_CHECKING:
+ from collections.abc import Iterator
from datetime import datetime
from numpy import ndarray
from django.conf import settings
from django.core.cache import cache
-from sklearn.exceptions import InconsistentVersionWarning
from documents.caching import CACHE_50_MINUTES
from documents.caching import CLASSIFIER_HASH_KEY
@@ -38,7 +38,7 @@ class ClassifierModelCorruptError(Exception):
pass
-def load_classifier(*, raise_exception: bool = False) -> Optional["DocumentClassifier"]:
+def load_classifier(*, raise_exception: bool = False) -> DocumentClassifier | None:
if not settings.MODEL_FILE.is_file():
logger.debug(
"Document classification model does not exist (yet), not "
@@ -103,6 +103,8 @@ class DocumentClassifier:
self._stop_words = None
def load(self) -> None:
+ from sklearn.exceptions import InconsistentVersionWarning
+
# Catch warnings for processing
with warnings.catch_warnings(record=True) as w:
with Path(settings.MODEL_FILE).open("rb") as f:
diff --git a/src/documents/filters.py b/src/documents/filters.py
index 1ce782ee6..b63da50e6 100644
--- a/src/documents/filters.py
+++ b/src/documents/filters.py
@@ -1,9 +1,11 @@
+from __future__ import annotations
+
import functools
import inspect
import json
import operator
-from collections.abc import Callable
from contextlib import contextmanager
+from typing import TYPE_CHECKING
from django.contrib.contenttypes.models import ContentType
from django.db.models import Case
@@ -39,6 +41,9 @@ from documents.models import ShareLink
from documents.models import StoragePath
from documents.models import Tag
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"]
ID_KWARGS = ["in", "exact"]
INT_KWARGS = ["exact", "gt", "gte", "lt", "lte", "isnull"]
diff --git a/src/documents/index.py b/src/documents/index.py
index 4b11325ff..9b3a1724c 100644
--- a/src/documents/index.py
+++ b/src/documents/index.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import logging
import math
from collections import Counter
@@ -5,10 +7,10 @@ from contextlib import contextmanager
from datetime import datetime
from datetime import timezone
from shutil import rmtree
+from typing import TYPE_CHECKING
from typing import Literal
from django.conf import settings
-from django.db.models import QuerySet
from django.utils import timezone as django_timezone
from guardian.shortcuts import get_users_with_perms
from whoosh import classify
@@ -32,10 +34,7 @@ from whoosh.qparser import QueryParser
from whoosh.qparser.dateparse import DateParserPlugin
from whoosh.qparser.dateparse import English
from whoosh.qparser.plugins import FieldsPlugin
-from whoosh.reading import IndexReader
from whoosh.scoring import TF_IDF
-from whoosh.searching import ResultsPage
-from whoosh.searching import Searcher
from whoosh.util.times import timespan
from whoosh.writing import AsyncWriter
@@ -44,6 +43,12 @@ from documents.models import Document
from documents.models import Note
from documents.models import User
+if TYPE_CHECKING:
+ from django.db.models import QuerySet
+ from whoosh.reading import IndexReader
+ from whoosh.searching import ResultsPage
+ from whoosh.searching import Searcher
+
logger = logging.getLogger("paperless.index")
diff --git a/src/documents/matching.py b/src/documents/matching.py
index 59c0ccfda..ab3866518 100644
--- a/src/documents/matching.py
+++ b/src/documents/matching.py
@@ -1,8 +1,10 @@
+from __future__ import annotations
+
import logging
import re
from fnmatch import fnmatch
+from typing import TYPE_CHECKING
-from documents.classifier import DocumentClassifier
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentSource
from documents.models import Correspondent
@@ -15,6 +17,9 @@ from documents.models import Workflow
from documents.models import WorkflowTrigger
from documents.permissions import get_objects_for_user_owner_aware
+if TYPE_CHECKING:
+ from documents.classifier import DocumentClassifier
+
logger = logging.getLogger("paperless.matching")
diff --git a/src/documents/parsers.py b/src/documents/parsers.py
index 28d903fdd..1465234a9 100644
--- a/src/documents/parsers.py
+++ b/src/documents/parsers.py
@@ -1,4 +1,5 @@
-import datetime
+from __future__ import annotations
+
import logging
import mimetypes
import os
@@ -6,10 +7,10 @@ import re
import shutil
import subprocess
import tempfile
-from collections.abc import Iterator
from functools import lru_cache
from pathlib import Path
from re import Match
+from typing import TYPE_CHECKING
from django.conf import settings
from django.utils import timezone
@@ -19,6 +20,10 @@ from documents.signals import document_consumer_declaration
from documents.utils import copy_file_with_basic_stats
from documents.utils import run_subprocess
+if TYPE_CHECKING:
+ import datetime
+ from collections.abc import Iterator
+
# This regular expression will try to find dates in the document at
# hand and will match the following formats:
# - XX.YY.ZZZZ with XX + YY being 1 or 2 and ZZZZ being 2 or 4 digits
@@ -106,7 +111,7 @@ def get_supported_file_extensions() -> set[str]:
return extensions
-def get_parser_class_for_mime_type(mime_type: str) -> type["DocumentParser"] | None:
+def get_parser_class_for_mime_type(mime_type: str) -> type[DocumentParser] | None:
"""
Returns the best parser (by weight) for the given mimetype or
None if no parser exists
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index aeba5a721..a486fe241 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -1,10 +1,12 @@
+from __future__ import annotations
+
import datetime
import logging
import math
import re
import zoneinfo
-from collections.abc import Iterable
from decimal import Decimal
+from typing import TYPE_CHECKING
import magic
from celery import states
@@ -32,6 +34,7 @@ from rest_framework.fields import SerializerMethodField
if settings.AUDIT_LOG_ENABLED:
from auditlog.context import set_actor
+
from documents import bulk_edit
from documents.data_models import DocumentSource
from documents.models import Correspondent
@@ -60,6 +63,9 @@ from documents.templating.utils import convert_format_str_to_template_format
from documents.validators import uri_validator
from documents.validators import url_validator
+if TYPE_CHECKING:
+ from collections.abc import Iterable
+
logger = logging.getLogger("paperless.serializers")
diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
index 0079e5f8c..4345e04d5 100644
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -1,7 +1,9 @@
+from __future__ import annotations
+
import logging
import os
import shutil
-from pathlib import Path
+from typing import TYPE_CHECKING
import httpx
from celery import shared_task
@@ -23,9 +25,6 @@ from guardian.shortcuts import remove_perm
from documents import matching
from documents.caching import clear_document_caches
-from documents.classifier import DocumentClassifier
-from documents.data_models import ConsumableDocument
-from documents.data_models import DocumentMetadataOverrides
from documents.file_handling import create_source_path_directory
from documents.file_handling import delete_empty_directories
from documents.file_handling import generate_unique_filename
@@ -46,6 +45,13 @@ from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import set_permissions_for_object
from documents.templating.workflows import parse_w_workflow_placeholders
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from documents.classifier import DocumentClassifier
+ from documents.data_models import ConsumableDocument
+ from documents.data_models import DocumentMetadataOverrides
+
logger = logging.getLogger("paperless.handlers")
From d364436817ca678fc23d6b0589b7c2fd7774af3a Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Mon, 24 Feb 2025 17:40:45 -0800
Subject: [PATCH 09/10] Fix: fix safari thumbnails again (#9219)
---
src-ui/src/styles.scss | 2 ++
src-ui/src/theme.scss | 23 +----------------------
2 files changed, 3 insertions(+), 22 deletions(-)
diff --git a/src-ui/src/styles.scss b/src-ui/src/styles.scss
index 589356566..a3f385ed5 100644
--- a/src-ui/src/styles.scss
+++ b/src-ui/src/styles.scss
@@ -767,6 +767,8 @@ canvas.hiddenCanvasElement {
}
.document-card {
+ overflow: hidden;
+
.card-footer i-bs svg {
vertical-align: middle;
}
diff --git a/src-ui/src/theme.scss b/src-ui/src/theme.scss
index fc8c13d3b..b60b70a0e 100644
--- a/src-ui/src/theme.scss
+++ b/src-ui/src/theme.scss
@@ -190,6 +190,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,