mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Just save this
[ci skip]
This commit is contained in:
		| @@ -0,0 +1,29 @@ | ||||
| <pdf-viewer [src]="pdfSrc" [render-text]="false" zoom="0.4" (after-load-complete)="pdfLoaded($event)"></pdf-viewer> | ||||
| <div class="modal-header"> | ||||
|   <h4 class="modal-title">{{ title }}</h4> | ||||
|   <button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button> | ||||
| </div> | ||||
| <div class="modal-body"> | ||||
|   <div cdkDropList (cdkDropListDropped)="drop($event)" cdkDropListOrientation="mixed" class="d-flex flex-wrap row-cols-5"> | ||||
|     @for (p of pages; track p.page; let i = $index) { | ||||
|       <div class="page-item p-2" cdkDrag> | ||||
|         <div class="btn-group mb-1"> | ||||
|           <button class="btn btn-sm btn-secondary" (click)="rotate(i)"><i-bs name="arrow-clockwise"></i-bs></button> | ||||
|           <button class="btn btn-sm btn-outline-secondary" (click)="toggleSplit(i)"><i-bs name="scissors"></i-bs></button> | ||||
|           <button class="btn btn-sm btn-danger" (click)="remove(i)"><i-bs name="trash"></i-bs></button> | ||||
|         </div> | ||||
|         <div class="pdf-viewer-container w-100 mt-3"> | ||||
|           <pdf-viewer [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [original-size]="false" [show-all]="false" [render-text]="false"></pdf-viewer> | ||||
|         </div> | ||||
|       </div> | ||||
|     } | ||||
|   </div> | ||||
| </div> | ||||
| <div class="modal-footer"> | ||||
|   <div class="form-check form-switch me-auto"> | ||||
|     <input class="form-check-input" type="checkbox" id="deleteSwitch" [(ngModel)]="deleteOriginal"> | ||||
|     <label class="form-check-label" for="deleteSwitch" i18n>Delete original after edit</label> | ||||
|   </div> | ||||
|   <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">{{ cancelBtnCaption }}</button> | ||||
|   <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button> | ||||
| </div> | ||||
| @@ -0,0 +1,9 @@ | ||||
| .pdf-viewer-container { | ||||
|     background-color: gray; | ||||
|     height: 120px; | ||||
|  | ||||
|     pdf-viewer { | ||||
|       width: 100%; | ||||
|       height: 100%; | ||||
|     } | ||||
|   } | ||||
| @@ -0,0 +1,36 @@ | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { provideHttpClientTesting } from '@angular/common/http/testing' | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { PDFEditorComponent } from './pdf-editor.component' | ||||
|  | ||||
| describe('PDFEditorComponent', () => { | ||||
|   let component: PDFEditorComponent | ||||
|   let fixture: ComponentFixture<PDFEditorComponent> | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
|       imports: [PDFEditorComponent, NgxBootstrapIconsModule.pick(allIcons)], | ||||
|       providers: [ | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
|         provideHttpClientTesting(), | ||||
|         { provide: NgbActiveModal, useValue: {} }, | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|     fixture = TestBed.createComponent(PDFEditorComponent) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   it('should rotate and reorder pages', () => { | ||||
|     component.pages = [ | ||||
|       { page: 1, rotate: 0, splitAfter: false }, | ||||
|       { page: 2, rotate: 0, splitAfter: false }, | ||||
|     ] | ||||
|     component.rotate(0) | ||||
|     expect(component.pages[0].rotate).toBe(90) | ||||
|     component.drop({ previousIndex: 0, currentIndex: 1 } as any) | ||||
|     expect(component.pages[0].page).toBe(2) | ||||
|   }) | ||||
| }) | ||||
| @@ -0,0 +1,90 @@ | ||||
| import { | ||||
|   CdkDragDrop, | ||||
|   DragDropModule, | ||||
|   moveItemInArray, | ||||
| } from '@angular/cdk/drag-drop' | ||||
| import { Component, OnInit, inject } from '@angular/core' | ||||
| import { FormsModule } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component' | ||||
|  | ||||
| interface PageOperation { | ||||
|   page: number | ||||
|   rotate: number | ||||
|   splitAfter: boolean | ||||
| } | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-pdf-editor', | ||||
|   templateUrl: './pdf-editor.component.html', | ||||
|   styleUrl: './pdf-editor.component.scss', | ||||
|   imports: [ | ||||
|     DragDropModule, | ||||
|     FormsModule, | ||||
|     PdfViewerModule, | ||||
|     NgxBootstrapIconsModule, | ||||
|   ], | ||||
| }) | ||||
| export class PDFEditorComponent | ||||
|   extends ConfirmDialogComponent | ||||
|   implements OnInit | ||||
| { | ||||
|   private documentService = inject(DocumentService) | ||||
|   activeModal = inject(NgbActiveModal) | ||||
|  | ||||
|   documentID: number | ||||
|   pages: PageOperation[] = [] | ||||
|   totalPages = 0 | ||||
|   deleteOriginal = false | ||||
|  | ||||
|   get pdfSrc(): string { | ||||
|     return this.documentService.getPreviewUrl(this.documentID) | ||||
|   } | ||||
|  | ||||
|   ngOnInit() {} | ||||
|  | ||||
|   pdfLoaded(pdf: PDFDocumentProxy) { | ||||
|     this.totalPages = pdf.numPages | ||||
|     this.pages = Array.from({ length: this.totalPages }, (_, i) => ({ | ||||
|       page: i + 1, | ||||
|       rotate: 0, | ||||
|       splitAfter: false, | ||||
|     })) | ||||
|   } | ||||
|  | ||||
|   rotate(i: number) { | ||||
|     this.pages[i].rotate = (this.pages[i].rotate + 90) % 360 | ||||
|   } | ||||
|  | ||||
|   remove(i: number) { | ||||
|     this.pages.splice(i, 1) | ||||
|   } | ||||
|  | ||||
|   toggleSplit(i: number) { | ||||
|     this.pages[i].splitAfter = !this.pages[i].splitAfter | ||||
|   } | ||||
|  | ||||
|   drop(event: CdkDragDrop<PageOperation[]>) { | ||||
|     moveItemInArray(this.pages, event.previousIndex, event.currentIndex) | ||||
|   } | ||||
|  | ||||
|   getOperations() { | ||||
|     const operations = this.pages.map((p, idx) => ({ | ||||
|       page: p.page, | ||||
|       rotate: p.rotate, | ||||
|       doc: this.computeDocIndex(idx), | ||||
|     })) | ||||
|     return operations | ||||
|   } | ||||
|  | ||||
|   private computeDocIndex(index: number): number { | ||||
|     let docIndex = 0 | ||||
|     for (let i = 0; i <= index; i++) { | ||||
|       if (this.pages[i].splitAfter && i < index) docIndex++ | ||||
|     } | ||||
|     return docIndex | ||||
|   } | ||||
| } | ||||
| @@ -66,6 +66,10 @@ | ||||
|         <i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container> | ||||
|       </button> | ||||
|  | ||||
|       <button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF"> | ||||
|         <i-bs name="pencil"></i-bs> <ng-container i18n>Edit PDF</ng-container> | ||||
|       </button> | ||||
|  | ||||
|       <button ngbDropdownItem (click)="deletePages()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1"> | ||||
|         <i-bs name="file-earmark-minus"></i-bs> <ng-container i18n>Delete page(s)</ng-container> | ||||
|       </button> | ||||
|   | ||||
| @@ -1235,6 +1235,29 @@ describe('DocumentDetailComponent', () => { | ||||
|     req.flush(true) | ||||
|   }) | ||||
|  | ||||
|   it('should support pdf editor', () => { | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((m) => (modal = m[0])) | ||||
|     initNormally() | ||||
|     component.editPdf() | ||||
|     expect(modal).not.toBeUndefined() | ||||
|     modal.componentInstance.documentID = doc.id | ||||
|     modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }] | ||||
|     modal.componentInstance.confirm() | ||||
|     const req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/bulk_edit/` | ||||
|     ) | ||||
|     expect(req.request.body).toEqual({ | ||||
|       documents: [doc.id], | ||||
|       method: 'edit_pdf', | ||||
|       parameters: { | ||||
|         operations: [{ page: 1, rotate: 0, doc: 0 }], | ||||
|         delete_original: false, | ||||
|       }, | ||||
|     }) | ||||
|     req.flush(true) | ||||
|   }) | ||||
|  | ||||
|   it('should support keyboard shortcuts', () => { | ||||
|     initNormally() | ||||
|  | ||||
|   | ||||
| @@ -102,6 +102,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 { PDFEditorComponent } from '../common/pdf-editor/pdf-editor.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' | ||||
| @@ -1430,6 +1431,45 @@ export class DocumentDetailComponent | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   editPdf() { | ||||
|     let modal = this.modalService.open(PDFEditorComponent, { | ||||
|       backdrop: 'static', | ||||
|       size: 'xl', | ||||
|       scrollable: true, | ||||
|     }) | ||||
|     modal.componentInstance.title = $localize`Edit PDF` | ||||
|     modal.componentInstance.btnCaption = $localize`Proceed` | ||||
|     modal.componentInstance.documentID = this.document.id | ||||
|     modal.componentInstance.confirmClicked | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe(() => { | ||||
|         modal.componentInstance.buttonsEnabled = false | ||||
|         this.documentsService | ||||
|           .bulkEdit([this.document.id], 'edit_pdf', { | ||||
|             operations: modal.componentInstance.getOperations(), | ||||
|             delete_original: modal.componentInstance.deleteOriginal, | ||||
|           }) | ||||
|           .pipe(first(), takeUntil(this.unsubscribeNotifier)) | ||||
|           .subscribe({ | ||||
|             next: () => { | ||||
|               this.toastService.showInfo( | ||||
|                 $localize`PDF edit operation for "${this.document.title}" will begin in the background.` | ||||
|               ) | ||||
|               modal.close() | ||||
|             }, | ||||
|             error: (error) => { | ||||
|               if (modal) { | ||||
|                 modal.componentInstance.buttonsEnabled = true | ||||
|               } | ||||
|               this.toastService.showError( | ||||
|                 $localize`Error executing PDF edit operation`, | ||||
|                 error | ||||
|               ) | ||||
|             }, | ||||
|           }) | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   deletePages() { | ||||
|     let modal = this.modalService.open(DeletePagesConfirmDialogComponent, { | ||||
|       backdrop: 'static', | ||||
|   | ||||
| @@ -497,6 +497,77 @@ def delete_pages(doc_ids: list[int], pages: list[int]) -> Literal["OK"]: | ||||
|     return "OK" | ||||
|  | ||||
|  | ||||
| def edit_pdf( | ||||
|     doc_ids: list[int], | ||||
|     operations: list[dict], | ||||
|     *, | ||||
|     delete_original: bool = False, | ||||
|     user: User | None = None, | ||||
| ) -> Literal["OK"]: | ||||
|     """ | ||||
|     Operations is a list of dictionaries describing the final PDF pages. | ||||
|     Each entry must contain the original page number in `page` and may | ||||
|     specify `rotate` in degrees and `doc` indicating the output | ||||
|     document index (for splitting). Pages omitted from the list are | ||||
|     discarded. | ||||
|     """ | ||||
|  | ||||
|     logger.info( | ||||
|         f"Editing PDF of document {doc_ids[0]} with {len(operations)} operations", | ||||
|     ) | ||||
|     doc = Document.objects.get(id=doc_ids[0]) | ||||
|     import pikepdf | ||||
|  | ||||
|     pdf_docs: list[pikepdf.Pdf] = [] | ||||
|  | ||||
|     try: | ||||
|         with pikepdf.open(doc.source_path) as src: | ||||
|             # prepare output documents | ||||
|             max_idx = max(op.get("doc", 0) for op in operations) | ||||
|             pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)] | ||||
|  | ||||
|             for op in operations: | ||||
|                 dst = pdf_docs[op.get("doc", 0)] | ||||
|                 page = src.pages[op["page"] - 1] | ||||
|                 dst.pages.append(page) | ||||
|                 if op.get("rotate"): | ||||
|                     dst.pages[-1].rotate(op["rotate"], relative=True) | ||||
|  | ||||
|             consume_tasks = [] | ||||
|             overrides: DocumentMetadataOverrides = ( | ||||
|                 DocumentMetadataOverrides().from_document(doc) | ||||
|             ) | ||||
|             if user is not None: | ||||
|                 overrides.owner_id = user.id | ||||
|  | ||||
|             for idx, pdf in enumerate(pdf_docs, start=1): | ||||
|                 filepath: Path = ( | ||||
|                     Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR)) | ||||
|                     / f"{doc.id}_edit_{idx}.pdf" | ||||
|                 ) | ||||
|                 pdf.remove_unreferenced_resources() | ||||
|                 pdf.save(filepath) | ||||
|                 consume_tasks.append( | ||||
|                     consume_file.s( | ||||
|                         ConsumableDocument( | ||||
|                             source=DocumentSource.ConsumeFolder, | ||||
|                             original_file=filepath, | ||||
|                         ), | ||||
|                         overrides, | ||||
|                     ), | ||||
|                 ) | ||||
|  | ||||
|             if delete_original: | ||||
|                 chord(header=consume_tasks, body=delete.si([doc.id])).delay() | ||||
|             else: | ||||
|                 group(consume_tasks).delay() | ||||
|  | ||||
|     except Exception as e: | ||||
|         logger.exception(f"Error editing document {doc.id}: {e}") | ||||
|  | ||||
|     return "OK" | ||||
|  | ||||
|  | ||||
| def reflect_doclinks( | ||||
|     document: Document, | ||||
|     field: CustomField, | ||||
|   | ||||
| @@ -1366,6 +1366,8 @@ class BulkEditSerializer( | ||||
|             return bulk_edit.split | ||||
|         elif method == "delete_pages": | ||||
|             return bulk_edit.delete_pages | ||||
|         elif method == "edit_pdf": | ||||
|             return bulk_edit.edit_pdf | ||||
|         else: | ||||
|             raise serializers.ValidationError("Unsupported method.") | ||||
|  | ||||
| @@ -1520,6 +1522,26 @@ class BulkEditSerializer( | ||||
|         else: | ||||
|             parameters["archive_fallback"] = False | ||||
|  | ||||
|     def _validate_parameters_edit_pdf(self, parameters): | ||||
|         if "operations" not in parameters: | ||||
|             raise serializers.ValidationError("operations not specified") | ||||
|         if not isinstance(parameters["operations"], list): | ||||
|             raise serializers.ValidationError("operations must be a list") | ||||
|         for op in parameters["operations"]: | ||||
|             if not isinstance(op, dict): | ||||
|                 raise serializers.ValidationError("invalid operation entry") | ||||
|             if "page" not in op or not isinstance(op["page"], int): | ||||
|                 raise serializers.ValidationError("page must be an integer") | ||||
|             if "rotate" in op and not isinstance(op["rotate"], int): | ||||
|                 raise serializers.ValidationError("rotate must be an integer") | ||||
|             if "doc" in op and not isinstance(op["doc"], int): | ||||
|                 raise serializers.ValidationError("doc must be an integer") | ||||
|         if "delete_original" in parameters: | ||||
|             if not isinstance(parameters["delete_original"], bool): | ||||
|                 raise serializers.ValidationError("delete_original must be a boolean") | ||||
|         else: | ||||
|             parameters["delete_original"] = False | ||||
|  | ||||
|     def validate(self, attrs): | ||||
|         method = attrs["method"] | ||||
|         parameters = attrs["parameters"] | ||||
| @@ -1554,6 +1576,12 @@ class BulkEditSerializer( | ||||
|             self._validate_parameters_delete_pages(parameters) | ||||
|         elif method == bulk_edit.merge: | ||||
|             self._validate_parameters_merge(parameters) | ||||
|         elif method == bulk_edit.edit_pdf: | ||||
|             if len(attrs["documents"]) > 1: | ||||
|                 raise serializers.ValidationError( | ||||
|                     "Edit PDF method only supports one document", | ||||
|                 ) | ||||
|             self._validate_parameters_edit_pdf(parameters) | ||||
|  | ||||
|         return attrs | ||||
|  | ||||
|   | ||||
| @@ -1369,6 +1369,60 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|         self.assertIn(b"pages must be a list of integers", response.content) | ||||
|  | ||||
|     @mock.patch("documents.serialisers.bulk_edit.edit_pdf") | ||||
|     def test_edit_pdf(self, m): | ||||
|         self.setup_mock(m, "edit_pdf") | ||||
|         response = self.client.post( | ||||
|             "/api/documents/bulk_edit/", | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "documents": [self.doc2.id], | ||||
|                     "method": "edit_pdf", | ||||
|                     "parameters": {"operations": [{"page": 1}]}, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||
|  | ||||
|         m.assert_called_once() | ||||
|         args, kwargs = m.call_args | ||||
|         self.assertCountEqual(args[0], [self.doc2.id]) | ||||
|         self.assertEqual(kwargs["operations"], [{"page": 1}]) | ||||
|         self.assertEqual(kwargs["user"], self.user) | ||||
|  | ||||
|     def test_edit_pdf_invalid_params(self): | ||||
|         response = self.client.post( | ||||
|             "/api/documents/bulk_edit/", | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "documents": [self.doc2.id, self.doc3.id], | ||||
|                     "method": "edit_pdf", | ||||
|                     "parameters": {"operations": [{"page": 1}]}, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|         self.assertIn(b"Edit PDF method only supports one document", response.content) | ||||
|  | ||||
|         response = self.client.post( | ||||
|             "/api/documents/bulk_edit/", | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "documents": [self.doc2.id], | ||||
|                     "method": "edit_pdf", | ||||
|                     "parameters": {}, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|         self.assertIn(b"operations not specified", response.content) | ||||
|  | ||||
|     @override_settings(AUDIT_LOG_ENABLED=True) | ||||
|     def test_bulk_edit_audit_log_enabled_simple_field(self): | ||||
|         """ | ||||
|   | ||||
| @@ -1321,6 +1321,7 @@ class BulkEditView(PassUserMixin): | ||||
|         "delete_pages": "checksum", | ||||
|         "split": None, | ||||
|         "merge": None, | ||||
|         "edit_pdf": None, | ||||
|         "reprocess": "checksum", | ||||
|     } | ||||
|  | ||||
| @@ -1339,6 +1340,7 @@ class BulkEditView(PassUserMixin): | ||||
|         if method in [ | ||||
|             bulk_edit.split, | ||||
|             bulk_edit.merge, | ||||
|             bulk_edit.edit_pdf, | ||||
|         ]: | ||||
|             parameters["user"] = user | ||||
|  | ||||
| @@ -1358,24 +1360,29 @@ class BulkEditView(PassUserMixin): | ||||
|  | ||||
|             # check ownership for methods that change original document | ||||
|             if ( | ||||
|                 has_perms | ||||
|                 and method | ||||
|                 in [ | ||||
|                     bulk_edit.set_permissions, | ||||
|                     bulk_edit.delete, | ||||
|                     bulk_edit.rotate, | ||||
|                     bulk_edit.delete_pages, | ||||
|                 ] | ||||
|             ) or ( | ||||
|                 method in [bulk_edit.merge, bulk_edit.split] | ||||
|                 and parameters["delete_originals"] | ||||
|                 ( | ||||
|                     has_perms | ||||
|                     and method | ||||
|                     in [ | ||||
|                         bulk_edit.set_permissions, | ||||
|                         bulk_edit.delete, | ||||
|                         bulk_edit.rotate, | ||||
|                         bulk_edit.delete_pages, | ||||
|                         bulk_edit.edit_pdf, | ||||
|                     ] | ||||
|                 ) | ||||
|                 or ( | ||||
|                     method in [bulk_edit.merge, bulk_edit.split] | ||||
|                     and parameters["delete_originals"] | ||||
|                 ) | ||||
|                 or (method == bulk_edit.edit_pdf and parameters["delete_original"]) | ||||
|             ): | ||||
|                 has_perms = user_is_owner_of_all_documents | ||||
|  | ||||
|             # check global add permissions for methods that create documents | ||||
|             if ( | ||||
|                 has_perms | ||||
|                 and method in [bulk_edit.split, bulk_edit.merge] | ||||
|                 and method in [bulk_edit.split, bulk_edit.merge, bulk_edit.edit_pdf] | ||||
|                 and not user.has_perm( | ||||
|                     "documents.add_document", | ||||
|                 ) | ||||
| @@ -1391,6 +1398,7 @@ class BulkEditView(PassUserMixin): | ||||
|                         method in [bulk_edit.merge, bulk_edit.split] | ||||
|                         and parameters["delete_originals"] | ||||
|                     ) | ||||
|                     or (method == bulk_edit.edit_pdf and parameters["delete_original"]) | ||||
|                 ) | ||||
|                 and not user.has_perm("documents.delete_document") | ||||
|             ): | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon