From c122c60d3fb970fd6bc75a918169485da062af15 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 21 Feb 2025 08:44:03 -0800 Subject: [PATCH 01/10] Feature: email document button (#8950) --- src-ui/messages.xlf | 231 +++++++++++------- .../email-document-dialog.component.html | 32 +++ .../email-document-dialog.component.scss | 0 .../email-document-dialog.component.spec.ts | 72 ++++++ .../email-document-dialog.component.ts | 77 ++++++ .../share-links-dialog.component.html | 68 ++++++ .../share-links-dialog.component.scss | 3 + .../share-links-dialog.component.spec.ts} | 21 +- .../share-links-dialog.component.ts} | 25 +- .../share-links-dropdown.component.html | 70 ------ .../share-links-dropdown.component.scss | 14 -- .../document-detail.component.html | 19 +- .../document-detail.component.spec.ts | 14 ++ .../document-detail.component.ts | 24 +- .../services/rest/document.service.spec.ts | 15 ++ .../src/app/services/rest/document.service.ts | 15 ++ src-ui/src/main.ts | 2 + src/documents/tests/test_api_documents.py | 148 +++++++++++ src/documents/views.py | 53 ++++ 19 files changed, 714 insertions(+), 189 deletions(-) create mode 100644 src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.html create mode 100644 src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.scss create mode 100644 src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.ts create mode 100644 src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html create mode 100644 src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.scss rename src-ui/src/app/components/common/{share-links-dropdown/share-links-dropdown.component.spec.ts => share-links-dialog/share-links-dialog.component.spec.ts} (92%) rename src-ui/src/app/components/common/{share-links-dropdown/share-links-dropdown.component.ts => share-links-dialog/share-links-dialog.component.ts} (90%) delete mode 100644 src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.html delete mode 100644 src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.scss diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 7d42085c3..ae9abe847 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 @@ -4288,7 +4288,7 @@ src/app/components/document-detail/document-detail.component.html - 288 + 305 @@ -4390,6 +4390,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 @@ -5061,6 +5065,62 @@ 229 + + 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 @@ -5082,8 +5142,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 @@ -5543,8 +5603,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 @@ -5585,8 +5645,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 @@ -5765,103 +5825,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 @@ -6379,25 +6439,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 @@ -6420,21 +6487,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 @@ -6461,7 +6528,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 @@ -6488,7 +6555,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 @@ -6511,7 +6578,7 @@ Default src/app/components/document-detail/document-detail.component.html - 122 + 139 src/app/components/manage/saved-views/saved-views.component.html @@ -6522,14 +6589,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 @@ -6540,119 +6607,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 @@ -6949,11 +7016,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/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 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, Date: Tue, 25 Feb 2025 13:50:15 -0800 Subject: [PATCH 10/10] Fix: prune invalid custom fields (#9224) --- src/documents/serialisers.py | 5 ++--- src/documents/tests/test_api_documents.py | 6 ++++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index a486fe241..5f3b310c2 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1136,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/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index 28261b392..40c30f5bb 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -1911,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}/", { @@ -1923,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"