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 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/.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/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 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..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 @@ -1030,6 +1057,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. diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 71335d020..ef53776ac 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -385,7 +385,7 @@ src/app/components/document-detail/document-detail.component.html - 100 + 117 @@ -534,7 +534,7 @@ src/app/components/document-detail/document-detail.component.html - 353 + 370 @@ -593,7 +593,7 @@ src/app/components/document-detail/document-detail.component.html - 346 + 363 src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html @@ -739,7 +739,7 @@ src/app/components/document-detail/document-detail.component.html - 366 + 383 src/app/components/document-list/document-list.component.html @@ -1190,7 +1190,7 @@ src/app/components/document-detail/document-detail.component.html - 322 + 339 src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -2077,8 +2077,8 @@ 19 - src/app/components/common/share-links-dropdown/share-links-dropdown.component.html - 37 + src/app/components/common/share-links-dialog/share-links-dialog.component.html + 36 src/app/components/document-detail/document-detail.component.html @@ -3391,7 +3391,7 @@ src/app/components/document-detail/document-detail.component.html - 94 + 111 src/app/components/document-detail/document-detail.component.ts @@ -4300,7 +4300,7 @@ src/app/components/document-detail/document-detail.component.html - 288 + 305 @@ -4402,6 +4402,10 @@ src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html 10 + + src/app/components/document-detail/document-detail.component.html + 96 + First name @@ -5080,6 +5084,62 @@ 233 + + Email address(es) + + src/app/components/common/email-document-dialog/email-document-dialog.component.html + 7 + + + + Subject + + src/app/components/common/email-document-dialog/email-document-dialog.component.html + 11 + + + + Message + + src/app/components/common/email-document-dialog/email-document-dialog.component.html + 15 + + + + Use archive version + + src/app/components/common/email-document-dialog/email-document-dialog.component.html + 23 + + + + Send email + + src/app/components/common/email-document-dialog/email-document-dialog.component.html + 29 + + + + Email Document + + src/app/components/common/email-document-dialog/email-document-dialog.component.ts + 17 + + + + Email sent + + src/app/components/common/email-document-dialog/email-document-dialog.component.ts + 65 + + + + Error emailing document + + src/app/components/common/email-document-dialog/email-document-dialog.component.ts + 69 + + Include @@ -5101,8 +5161,8 @@ 58 - src/app/components/common/share-links-dropdown/share-links-dropdown.component.html - 64 + src/app/components/common/share-links-dialog/share-links-dialog.component.html + 65 src/app/components/manage/management-list/management-list.component.html @@ -5562,8 +5622,8 @@ 155 - src/app/components/common/share-links-dropdown/share-links-dropdown.component.html - 29 + src/app/components/common/share-links-dialog/share-links-dialog.component.html + 28 src/app/components/common/system-status-dialog/system-status-dialog.component.html @@ -5604,8 +5664,8 @@ 162 - src/app/components/common/share-links-dropdown/share-links-dropdown.component.html - 40 + src/app/components/common/share-links-dialog/share-links-dialog.component.html + 39 @@ -5784,103 +5844,103 @@ 320 - - Share Links - - src/app/components/common/share-links-dropdown/share-links-dropdown.component.html - 4 - - - src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts - 32 - - No existing links - src/app/components/common/share-links-dropdown/share-links-dropdown.component.html - 9,11 + src/app/components/common/share-links-dialog/share-links-dialog.component.html + 8,10 Share - src/app/components/common/share-links-dropdown/share-links-dropdown.component.html - 33 + src/app/components/common/share-links-dialog/share-links-dialog.component.html + 32 Share archive version - src/app/components/common/share-links-dropdown/share-links-dropdown.component.html - 47 + src/app/components/common/share-links-dialog/share-links-dialog.component.html + 48 Expires - src/app/components/common/share-links-dropdown/share-links-dropdown.component.html - 51 + src/app/components/common/share-links-dialog/share-links-dialog.component.html + 52 1 day - src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts - 25 + src/app/components/common/share-links-dialog/share-links-dialog.component.ts + 20 - src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts - 111 + src/app/components/common/share-links-dialog/share-links-dialog.component.ts + 104 7 days - src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts - 26 + src/app/components/common/share-links-dialog/share-links-dialog.component.ts + 21 30 days - src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts - 27 + src/app/components/common/share-links-dialog/share-links-dialog.component.ts + 22 Never - src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts - 28 + src/app/components/common/share-links-dialog/share-links-dialog.component.ts + 23 + + + + Share Links + + src/app/components/common/share-links-dialog/share-links-dialog.component.ts + 27 + + + src/app/components/document-detail/document-detail.component.html + 92 Error retrieving links - src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts - 92 + src/app/components/common/share-links-dialog/share-links-dialog.component.ts + 85 days - src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts - 111 + src/app/components/common/share-links-dialog/share-links-dialog.component.ts + 104 Error deleting link - src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts - 140 + src/app/components/common/share-links-dialog/share-links-dialog.component.ts + 133 Error creating link - src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts - 168 + src/app/components/common/share-links-dialog/share-links-dialog.component.ts + 161 @@ -6434,25 +6494,32 @@ 70 + + Send + + src/app/components/document-detail/document-detail.component.html + 88 + + Previous src/app/components/document-detail/document-detail.component.html - 97 + 114 Details src/app/components/document-detail/document-detail.component.html - 110 + 127 Title src/app/components/document-detail/document-detail.component.html - 113 + 130 src/app/components/document-list/document-list.component.html @@ -6475,21 +6542,21 @@ Archive serial number src/app/components/document-detail/document-detail.component.html - 114 + 131 Date created src/app/components/document-detail/document-detail.component.html - 115 + 132 Correspondent src/app/components/document-detail/document-detail.component.html - 117 + 134 src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -6516,7 +6583,7 @@ Document type src/app/components/document-detail/document-detail.component.html - 119 + 136 src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -6543,7 +6610,7 @@ Storage path src/app/components/document-detail/document-detail.component.html - 121 + 138 src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -6566,7 +6633,7 @@ Default src/app/components/document-detail/document-detail.component.html - 122 + 139 src/app/components/manage/saved-views/saved-views.component.html @@ -6577,14 +6644,14 @@ Content src/app/components/document-detail/document-detail.component.html - 218 + 235 Metadata src/app/components/document-detail/document-detail.component.html - 227 + 244 src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts @@ -6595,119 +6662,119 @@ Date modified src/app/components/document-detail/document-detail.component.html - 234 + 251 Date added src/app/components/document-detail/document-detail.component.html - 238 + 255 Media filename src/app/components/document-detail/document-detail.component.html - 242 + 259 Original filename src/app/components/document-detail/document-detail.component.html - 246 + 263 Original MD5 checksum src/app/components/document-detail/document-detail.component.html - 250 + 267 Original file size src/app/components/document-detail/document-detail.component.html - 254 + 271 Original mime type src/app/components/document-detail/document-detail.component.html - 258 + 275 Archive MD5 checksum src/app/components/document-detail/document-detail.component.html - 263 + 280 Archive file size src/app/components/document-detail/document-detail.component.html - 269 + 286 Original document metadata src/app/components/document-detail/document-detail.component.html - 278 + 295 Archived document metadata src/app/components/document-detail/document-detail.component.html - 281 + 298 Notes src/app/components/document-detail/document-detail.component.html - 300,303 + 317,320 History src/app/components/document-detail/document-detail.component.html - 311 + 328 Save & next src/app/components/document-detail/document-detail.component.html - 348 + 365 Save & close src/app/components/document-detail/document-detail.component.html - 351 + 368 Document loading... src/app/components/document-detail/document-detail.component.html - 361 + 378 Enter Password src/app/components/document-detail/document-detail.component.html - 415 + 432 @@ -7004,11 +7071,11 @@ An error occurred loading tiff: src/app/components/document-detail/document-detail.component.ts - 1461 + 1481 src/app/components/document-detail/document-detail.component.ts - 1465 + 1485 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 } diff --git a/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.html b/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.html new file mode 100644 index 000000000..56d404fd5 --- /dev/null +++ b/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.html @@ -0,0 +1,32 @@ + + + diff --git a/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.scss b/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.spec.ts b/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.spec.ts new file mode 100644 index 000000000..7a3659205 --- /dev/null +++ b/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.spec.ts @@ -0,0 +1,72 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' +import { provideHttpClientTesting } from '@angular/common/http/testing' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { of, throwError } from 'rxjs' +import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' +import { PermissionsService } from 'src/app/services/permissions.service' +import { DocumentService } from 'src/app/services/rest/document.service' +import { ToastService } from 'src/app/services/toast.service' +import { EmailDocumentDialogComponent } from './email-document-dialog.component' + +describe('EmailDocumentDialogComponent', () => { + let component: EmailDocumentDialogComponent + let fixture: ComponentFixture + let documentService: DocumentService + let permissionsService: PermissionsService + let toastService: ToastService + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + EmailDocumentDialogComponent, + IfPermissionsDirective, + NgxBootstrapIconsModule.pick(allIcons), + ], + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + NgbActiveModal, + ], + }).compileComponents() + + fixture = TestBed.createComponent(EmailDocumentDialogComponent) + documentService = TestBed.inject(DocumentService) + toastService = TestBed.inject(ToastService) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should set hasArchiveVersion and useArchiveVersion', () => { + expect(component.hasArchiveVersion).toBeTruthy() + component.hasArchiveVersion = false + expect(component.hasArchiveVersion).toBeFalsy() + expect(component.useArchiveVersion).toBeFalsy() + }) + + it('should support sending document via email, showing error if needed', () => { + const toastErrorSpy = jest.spyOn(toastService, 'showError') + const toastSuccessSpy = jest.spyOn(toastService, 'showInfo') + component.emailAddress = 'hello@paperless-ngx.com' + component.emailSubject = 'Hello' + component.emailMessage = 'World' + jest + .spyOn(documentService, 'emailDocument') + .mockReturnValue(throwError(() => new Error('Unable to email document'))) + component.emailDocument() + expect(toastErrorSpy).toHaveBeenCalled() + + jest.spyOn(documentService, 'emailDocument').mockReturnValue(of(true)) + component.emailDocument() + expect(toastSuccessSpy).toHaveBeenCalled() + }) + + it('should close the dialog', () => { + const activeModal = TestBed.inject(NgbActiveModal) + const closeSpy = jest.spyOn(activeModal, 'close') + component.close() + expect(closeSpy).toHaveBeenCalled() + }) +}) diff --git a/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.ts b/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.ts new file mode 100644 index 000000000..ab8b9768b --- /dev/null +++ b/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.ts @@ -0,0 +1,77 @@ +import { Component, Input } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { DocumentService } from 'src/app/services/rest/document.service' +import { ToastService } from 'src/app/services/toast.service' +import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' + +@Component({ + selector: 'pngx-email-document-dialog', + templateUrl: './email-document-dialog.component.html', + styleUrl: './email-document-dialog.component.scss', + imports: [FormsModule, NgxBootstrapIconsModule], +}) +export class EmailDocumentDialogComponent extends LoadingComponentWithPermissions { + @Input() + title = $localize`Email Document` + + @Input() + documentId: number + + private _hasArchiveVersion: boolean = true + + @Input() + set hasArchiveVersion(value: boolean) { + this._hasArchiveVersion = value + this.useArchiveVersion = value + } + + get hasArchiveVersion(): boolean { + return this._hasArchiveVersion + } + + public useArchiveVersion: boolean = true + + public emailAddress: string = '' + public emailSubject: string = '' + public emailMessage: string = '' + + constructor( + private activeModal: NgbActiveModal, + private documentService: DocumentService, + private toastService: ToastService + ) { + super() + this.loading = false + } + + public emailDocument() { + this.loading = true + this.documentService + .emailDocument( + this.documentId, + this.emailAddress, + this.emailSubject, + this.emailMessage, + this.useArchiveVersion + ) + .subscribe({ + next: () => { + this.loading = false + this.emailAddress = '' + this.emailSubject = '' + this.emailMessage = '' + this.toastService.showInfo($localize`Email sent`) + }, + error: (e) => { + this.loading = false + this.toastService.showError($localize`Error emailing document`, e) + }, + }) + } + + public close() { + this.activeModal.close() + } +} diff --git a/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html new file mode 100644 index 000000000..fe3f9b9c3 --- /dev/null +++ b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html @@ -0,0 +1,68 @@ + + + diff --git a/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.scss b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.scss new file mode 100644 index 000000000..df5024ecd --- /dev/null +++ b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.scss @@ -0,0 +1,3 @@ +.copied-badge { + right: 15em; +} diff --git a/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.spec.ts b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.spec.ts similarity index 92% rename from src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.spec.ts rename to src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.spec.ts index b7b0305be..3f60b6733 100644 --- a/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.spec.ts +++ b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.spec.ts @@ -11,17 +11,18 @@ import { tick, } from '@angular/core/testing' import { By } from '@angular/platform-browser' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { of, throwError } from 'rxjs' import { FileVersion, ShareLink } from 'src/app/data/share-link' import { ShareLinkService } from 'src/app/services/rest/share-link.service' import { ToastService } from 'src/app/services/toast.service' import { environment } from 'src/environments/environment' -import { ShareLinksDropdownComponent } from './share-links-dropdown.component' +import { ShareLinksDialogComponent } from './share-links-dialog.component' -describe('ShareLinksDropdownComponent', () => { - let component: ShareLinksDropdownComponent - let fixture: ComponentFixture +describe('ShareLinksDialogComponent', () => { + let component: ShareLinksDialogComponent + let fixture: ComponentFixture let shareLinkService: ShareLinkService let toastService: ToastService let httpController: HttpTestingController @@ -30,16 +31,17 @@ describe('ShareLinksDropdownComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [ - ShareLinksDropdownComponent, + ShareLinksDialogComponent, NgxBootstrapIconsModule.pick(allIcons), ], providers: [ provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), + NgbActiveModal, ], }) - fixture = TestBed.createComponent(ShareLinksDropdownComponent) + fixture = TestBed.createComponent(ShareLinksDialogComponent) shareLinkService = TestBed.inject(ShareLinkService) toastService = TestBed.inject(ToastService) httpController = TestBed.inject(HttpTestingController) @@ -232,4 +234,11 @@ describe('ShareLinksDropdownComponent', () => { ] ).toBeTruthy() }) + + it('should support close', () => { + const activeModal = TestBed.inject(NgbActiveModal) + const closeSpy = jest.spyOn(activeModal, 'close') + component.close() + expect(closeSpy).toHaveBeenCalled() + }) }) diff --git a/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.ts similarity index 90% rename from src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts rename to src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.ts index 5e65eed73..19123f73e 100644 --- a/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts +++ b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.ts @@ -1,7 +1,7 @@ import { Clipboard } from '@angular/cdk/clipboard' import { Component, Input, OnInit } from '@angular/core' import { FormsModule, ReactiveFormsModule } from '@angular/forms' -import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { first } from 'rxjs' import { FileVersion, ShareLink } from 'src/app/data/share-link' @@ -10,17 +10,12 @@ import { ToastService } from 'src/app/services/toast.service' import { environment } from 'src/environments/environment' @Component({ - selector: 'pngx-share-links-dropdown', - templateUrl: './share-links-dropdown.component.html', - styleUrls: ['./share-links-dropdown.component.scss'], - imports: [ - FormsModule, - ReactiveFormsModule, - NgbDropdownModule, - NgxBootstrapIconsModule, - ], + selector: 'pngx-share-links-dialog', + templateUrl: './share-links-dialog.component.html', + styleUrls: ['./share-links-dialog.component.scss'], + imports: [FormsModule, ReactiveFormsModule, NgxBootstrapIconsModule], }) -export class ShareLinksDropdownComponent implements OnInit { +export class ShareLinksDialogComponent implements OnInit { EXPIRATION_OPTIONS = [ { label: $localize`1 day`, value: 1 }, { label: $localize`7 days`, value: 7 }, @@ -41,9 +36,6 @@ export class ShareLinksDropdownComponent implements OnInit { } } - @Input() - disabled: boolean = false - private _hasArchiveVersion: boolean = true @Input() @@ -67,6 +59,7 @@ export class ShareLinksDropdownComponent implements OnInit { useArchiveVersion: boolean = true constructor( + private activeModal: NgbActiveModal, private shareLinkService: ShareLinkService, private toastService: ToastService, private clipboard: Clipboard @@ -169,4 +162,8 @@ export class ShareLinksDropdownComponent implements OnInit { }, }) } + + close() { + this.activeModal.close() + } } diff --git a/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.html b/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.html deleted file mode 100644 index 08298abc7..000000000 --- a/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.html +++ /dev/null @@ -1,70 +0,0 @@ -
- - -
diff --git a/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.scss b/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.scss deleted file mode 100644 index 47e19d871..000000000 --- a/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.scss +++ /dev/null @@ -1,14 +0,0 @@ -.share-links-dropdown { - min-width: 350px; - - // correct position on mobile - @media (max-width: 575.98px) { - &.show { - margin-left: -175px !important; - } - } -} - -.copied-badge { - right: 7.5em; -} diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html index fc35bdb43..c99c35f01 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.html +++ b/src-ui/src/app/components/document-detail/document-detail.component.html @@ -81,7 +81,24 @@ (added)="addField($event)"> - + +
+ +
+ + @if (emailEnabled) { + + } +
+
+
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 612e883d8..eb34b94b4 100644 --- a/src-ui/src/main.ts +++ b/src-ui/src/main.ts @@ -113,6 +113,7 @@ import { questionCircle, scissors, search, + send, slashCircle, sliders2Vertical, sortAlphaDown, @@ -318,6 +319,7 @@ const icons = { questionCircle, scissors, search, + send, slashCircle, sliders2Vertical, sortAlphaDown, 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 cfceb5602..d7cfa5628 100644 --- a/src-ui/src/theme.scss +++ b/src-ui/src/theme.scss @@ -194,6 +194,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml, 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 72bf1f16c..728c83228 100644 --- a/src/documents/classifier.py +++ b/src/documents/classifier.py @@ -1,21 +1,21 @@ +from __future__ import annotations + import logging import pickle import re 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 @@ -37,7 +37,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 " @@ -102,6 +102,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 6c2214de2..d3b0ad3ce 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 @@ -40,6 +42,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 e94f4234a..f961c299b 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") @@ -1130,9 +1136,8 @@ class SavedViewSerializer(OwnedObjectSerializer): ): # i.e. check for 'custom_field_' prefix field_id = int(re.search(r"\d+", field)[0]) if not CustomField.objects.filter(id=field_id).exists(): - raise serializers.ValidationError( - f"Invalid field: {field}", - ) + # In case the field was deleted, just remove from the list + attrs["display_fields"].remove(field) elif field not in SavedView.DisplayFields.values: raise serializers.ValidationError( f"Invalid field: {field}", diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index ec0a29705..678619191 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") diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index 6247b0a6e..40c30f5bb 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 @@ -1910,7 +1911,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): ], ) - # Custom field not found + # Custom field not found, removed from list response = self.client.patch( f"/api/saved_views/{v1.id}/", { @@ -1922,7 +1923,9 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): }, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_200_OK) + v1.refresh_from_db() + self.assertNotIn(SavedView.DisplayFields.CUSTOM_FIELD % 99, v1.display_fields) def test_get_logs(self): log_data = "test\ntest2\n" @@ -2651,6 +2654,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 99cb9d76e..4ed9d8435 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -109,6 +109,7 @@ from documents.filters import PaperlessTaskFilterSet 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 @@ -1030,6 +1031,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( @@ -1148,7 +1200,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") 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()), [])