mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-01-02 14:28:14 -06:00
Compare commits
8 Commits
feature-pw
...
feature-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a29195792 | ||
|
|
bdd00498a1 | ||
|
|
92deebddd4 | ||
|
|
c7efcee3d6 | ||
|
|
72fd05501b | ||
|
|
a3c19b1e2d | ||
|
|
2e6458dbcc | ||
|
|
8471507115 |
4
.github/workflows/translate-strings.yml
vendored
4
.github/workflows/translate-strings.yml
vendored
@@ -12,9 +12,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
env:
|
||||||
|
GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||||
ref: ${{ github.head_ref }}
|
ref: ${{ env.GH_REF }}
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
|
|||||||
@@ -294,13 +294,6 @@ The following methods are supported:
|
|||||||
- `"delete_original": true` to delete the original documents after editing.
|
- `"delete_original": true` to delete the original documents after editing.
|
||||||
- `"update_document": true` to update the existing document with the edited PDF.
|
- `"update_document": true` to update the existing document with the edited PDF.
|
||||||
- `"include_metadata": true` to copy metadata from the original document to the edited document.
|
- `"include_metadata": true` to copy metadata from the original document to the edited document.
|
||||||
- `remove_password`
|
|
||||||
- Requires `parameters`:
|
|
||||||
- `"password": "PASSWORD_STRING"` The password to remove from the PDF documents.
|
|
||||||
- Optional `parameters`:
|
|
||||||
- `"update_document": true` to replace the existing document with the password-less PDF.
|
|
||||||
- `"delete_original": true` to delete the original document after editing.
|
|
||||||
- `"include_metadata": true` to copy metadata from the original document to the new password-less document.
|
|
||||||
- `merge`
|
- `merge`
|
||||||
- No additional `parameters` required.
|
- No additional `parameters` required.
|
||||||
- The ordering of the merged document is determined by the list of IDs.
|
- The ordering of the merged document is determined by the list of IDs.
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
<div class="modal-header">
|
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
@if (message) {
|
|
||||||
<p class="mb-3" [innerHTML]="message"></p>
|
|
||||||
}
|
|
||||||
<div class="btn-group mb-3" role="group">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
class="btn-check"
|
|
||||||
name="passwordRemoveMode"
|
|
||||||
id="removeReplace"
|
|
||||||
[(ngModel)]="updateDocument"
|
|
||||||
[value]="true"
|
|
||||||
(ngModelChange)="onUpdateDocumentChange($event)"
|
|
||||||
/>
|
|
||||||
<label class="btn btn-outline-primary btn-sm" for="removeReplace">
|
|
||||||
<i-bs name="pencil"></i-bs>
|
|
||||||
<span class="ms-2" i18n>Replace current document</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
class="btn-check"
|
|
||||||
name="passwordRemoveMode"
|
|
||||||
id="removeCreate"
|
|
||||||
[(ngModel)]="updateDocument"
|
|
||||||
[value]="false"
|
|
||||||
(ngModelChange)="onUpdateDocumentChange($event)"
|
|
||||||
/>
|
|
||||||
<label class="btn btn-outline-primary btn-sm" for="removeCreate">
|
|
||||||
<i-bs name="plus"></i-bs>
|
|
||||||
<span class="ms-2" i18n>Create new document</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
@if (!updateDocument) {
|
|
||||||
<div class="d-flex flex-column flex-md-row w-100 gap-3 align-items-center">
|
|
||||||
<div class="form-group d-flex">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="copyMetaRemove" [(ngModel)]="includeMetadata" />
|
|
||||||
<label class="form-check-label" for="copyMetaRemove" i18n> Copy metadata
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check ms-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="deleteOriginalRemove" [(ngModel)]="deleteOriginal" />
|
|
||||||
<label class="form-check-label" for="deleteOriginalRemove" i18n> Delete original</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer flex-nowrap gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn"
|
|
||||||
[class]="cancelBtnClass"
|
|
||||||
(click)="cancel()"
|
|
||||||
[disabled]="!buttonsEnabled"
|
|
||||||
>
|
|
||||||
<span class="d-inline-block" style="padding-bottom: 1px;">
|
|
||||||
{{cancelBtnCaption}}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn"
|
|
||||||
[class]="btnClass"
|
|
||||||
(click)="confirm()"
|
|
||||||
[disabled]="!confirmButtonEnabled || !buttonsEnabled"
|
|
||||||
>
|
|
||||||
{{btnCaption}}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } 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 { PasswordRemovalConfirmDialogComponent } from './password-removal-confirm-dialog.component'
|
|
||||||
|
|
||||||
describe('PasswordRemovalConfirmDialogComponent', () => {
|
|
||||||
let component: PasswordRemovalConfirmDialogComponent
|
|
||||||
let fixture: ComponentFixture<PasswordRemovalConfirmDialogComponent>
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
providers: [NgbActiveModal],
|
|
||||||
imports: [
|
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
PasswordRemovalConfirmDialogComponent,
|
|
||||||
],
|
|
||||||
}).compileComponents()
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(PasswordRemovalConfirmDialogComponent)
|
|
||||||
component = fixture.componentInstance
|
|
||||||
fixture.detectChanges()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should default to replacing the document', () => {
|
|
||||||
expect(component.updateDocument).toBe(true)
|
|
||||||
expect(
|
|
||||||
fixture.debugElement.query(By.css('#removeReplace')).nativeElement.checked
|
|
||||||
).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should allow creating a new document with metadata and delete toggle', () => {
|
|
||||||
component.onUpdateDocumentChange(false)
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(component.updateDocument).toBe(false)
|
|
||||||
expect(fixture.debugElement.query(By.css('#copyMetaRemove'))).not.toBeNull()
|
|
||||||
|
|
||||||
component.includeMetadata = false
|
|
||||||
component.deleteOriginal = true
|
|
||||||
component.onUpdateDocumentChange(true)
|
|
||||||
expect(component.updateDocument).toBe(true)
|
|
||||||
expect(component.includeMetadata).toBe(true)
|
|
||||||
expect(component.deleteOriginal).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should emit confirm when confirmed', () => {
|
|
||||||
let confirmed = false
|
|
||||||
component.confirmClicked.subscribe(() => (confirmed = true))
|
|
||||||
component.confirm()
|
|
||||||
expect(confirmed).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { Component, Input } from '@angular/core'
|
|
||||||
import { FormsModule } from '@angular/forms'
|
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'pngx-password-removal-confirm-dialog',
|
|
||||||
templateUrl: './password-removal-confirm-dialog.component.html',
|
|
||||||
styleUrls: ['./password-removal-confirm-dialog.component.scss'],
|
|
||||||
imports: [FormsModule, NgxBootstrapIconsModule],
|
|
||||||
})
|
|
||||||
export class PasswordRemovalConfirmDialogComponent extends ConfirmDialogComponent {
|
|
||||||
updateDocument: boolean = true
|
|
||||||
includeMetadata: boolean = true
|
|
||||||
deleteOriginal: boolean = false
|
|
||||||
|
|
||||||
@Input()
|
|
||||||
override title = $localize`Remove password protection`
|
|
||||||
|
|
||||||
@Input()
|
|
||||||
override message =
|
|
||||||
$localize`Create an unprotected copy or replace the existing file.`
|
|
||||||
|
|
||||||
@Input()
|
|
||||||
override btnCaption = $localize`Start`
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
onUpdateDocumentChange(updateDocument: boolean) {
|
|
||||||
this.updateDocument = updateDocument
|
|
||||||
if (this.updateDocument) {
|
|
||||||
this.deleteOriginal = false
|
|
||||||
this.includeMetadata = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -65,12 +65,6 @@
|
|||||||
<button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
|
<button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
|
||||||
<i-bs name="pencil"></i-bs> <ng-container i18n>PDF Editor</ng-container>
|
<i-bs name="pencil"></i-bs> <ng-container i18n>PDF Editor</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@if (userIsOwner && (requiresPassword || password)) {
|
|
||||||
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
|
|
||||||
<i-bs name="unlock"></i-bs> <ng-container i18n>Remove Password</ng-container>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ import { SettingsService } from 'src/app/services/settings.service'
|
|||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
|
||||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||||
import {
|
import {
|
||||||
DocumentDetailComponent,
|
DocumentDetailComponent,
|
||||||
@@ -1210,88 +1209,6 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(closeSpy).toHaveBeenCalled()
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support removing password protection from pdfs', () => {
|
|
||||||
let modal: NgbModalRef
|
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
|
||||||
initNormally()
|
|
||||||
component.password = 'secret'
|
|
||||||
component.removePassword()
|
|
||||||
const dialog =
|
|
||||||
modal.componentInstance as PasswordRemovalConfirmDialogComponent
|
|
||||||
dialog.updateDocument = false
|
|
||||||
dialog.includeMetadata = false
|
|
||||||
dialog.deleteOriginal = true
|
|
||||||
dialog.confirm()
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
|
||||||
)
|
|
||||||
expect(req.request.body).toEqual({
|
|
||||||
documents: [doc.id],
|
|
||||||
method: 'remove_password',
|
|
||||||
parameters: {
|
|
||||||
password: 'secret',
|
|
||||||
update_document: false,
|
|
||||||
include_metadata: false,
|
|
||||||
delete_original: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
req.flush(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should require the current password before removing it', () => {
|
|
||||||
initNormally()
|
|
||||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
|
||||||
component.requiresPassword = true
|
|
||||||
component.password = ''
|
|
||||||
|
|
||||||
component.removePassword()
|
|
||||||
|
|
||||||
expect(errorSpy).toHaveBeenCalled()
|
|
||||||
httpTestingController.expectNone(
|
|
||||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle failures when removing password protection', () => {
|
|
||||||
let modal: NgbModalRef
|
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
|
||||||
initNormally()
|
|
||||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
|
||||||
component.password = 'secret'
|
|
||||||
|
|
||||||
component.removePassword()
|
|
||||||
const dialog =
|
|
||||||
modal.componentInstance as PasswordRemovalConfirmDialogComponent
|
|
||||||
dialog.confirm()
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
|
||||||
)
|
|
||||||
req.error(new ErrorEvent('failed'))
|
|
||||||
|
|
||||||
expect(errorSpy).toHaveBeenCalled()
|
|
||||||
expect(component.networkActive).toBe(false)
|
|
||||||
expect(dialog.buttonsEnabled).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should refresh the document when removing password in update mode', () => {
|
|
||||||
let modal: NgbModalRef
|
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
|
||||||
const refreshSpy = jest.spyOn(openDocumentsService, 'refreshDocument')
|
|
||||||
initNormally()
|
|
||||||
component.password = 'secret'
|
|
||||||
|
|
||||||
component.removePassword()
|
|
||||||
const dialog =
|
|
||||||
modal.componentInstance as PasswordRemovalConfirmDialogComponent
|
|
||||||
dialog.confirm()
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
|
||||||
)
|
|
||||||
req.flush(true)
|
|
||||||
|
|
||||||
expect(refreshSpy).toHaveBeenCalledWith(doc.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support keyboard shortcuts', () => {
|
it('should support keyboard shortcuts', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ import { getFilenameFromContentDisposition } from 'src/app/utils/http'
|
|||||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||||
import * as UTIF from 'utif'
|
import * as UTIF from 'utif'
|
||||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
|
||||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||||
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||||
@@ -176,7 +175,6 @@ export enum ZoomSetting {
|
|||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
PdfViewerModule,
|
PdfViewerModule,
|
||||||
TextAreaComponent,
|
TextAreaComponent,
|
||||||
PasswordRemovalConfirmDialogComponent,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DocumentDetailComponent
|
export class DocumentDetailComponent
|
||||||
@@ -1430,63 +1428,6 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
removePassword() {
|
|
||||||
if (this.requiresPassword || !this.password) {
|
|
||||||
this.toastService.showError(
|
|
||||||
$localize`Please enter the current password before attempting to remove it.`
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const modal = this.modalService.open(
|
|
||||||
PasswordRemovalConfirmDialogComponent,
|
|
||||||
{
|
|
||||||
backdrop: 'static',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
modal.componentInstance.title = $localize`Remove password protection`
|
|
||||||
modal.componentInstance.message = $localize`Create an unprotected copy or replace the existing file.`
|
|
||||||
modal.componentInstance.btnCaption = $localize`Start`
|
|
||||||
|
|
||||||
modal.componentInstance.confirmClicked
|
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
|
||||||
.subscribe(() => {
|
|
||||||
const dialog =
|
|
||||||
modal.componentInstance as PasswordRemovalConfirmDialogComponent
|
|
||||||
dialog.buttonsEnabled = false
|
|
||||||
this.networkActive = true
|
|
||||||
this.documentsService
|
|
||||||
.bulkEdit([this.document.id], 'remove_password', {
|
|
||||||
password: this.password,
|
|
||||||
update_document: dialog.updateDocument,
|
|
||||||
include_metadata: dialog.includeMetadata,
|
|
||||||
delete_original: dialog.deleteOriginal,
|
|
||||||
})
|
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
|
||||||
.subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.toastService.showInfo(
|
|
||||||
$localize`Password removal operation for "${this.document.title}" will begin in the background.`
|
|
||||||
)
|
|
||||||
this.networkActive = false
|
|
||||||
modal.close()
|
|
||||||
if (!dialog.updateDocument && dialog.deleteOriginal) {
|
|
||||||
this.openDocumentService.closeDocument(this.document)
|
|
||||||
} else if (dialog.updateDocument) {
|
|
||||||
this.openDocumentService.refreshDocument(this.documentId)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: (error) => {
|
|
||||||
dialog.buttonsEnabled = true
|
|
||||||
this.networkActive = false
|
|
||||||
this.toastService.showError(
|
|
||||||
$localize`Error executing password removal operation`,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
printDocument() {
|
printDocument() {
|
||||||
const printUrl = this.documentsService.getDownloadUrl(
|
const printUrl = this.documentsService.getDownloadUrl(
|
||||||
this.document.id,
|
this.document.id,
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ import {
|
|||||||
threeDotsVertical,
|
threeDotsVertical,
|
||||||
trash,
|
trash,
|
||||||
uiRadios,
|
uiRadios,
|
||||||
unlock,
|
|
||||||
upcScan,
|
upcScan,
|
||||||
windowStack,
|
windowStack,
|
||||||
x,
|
x,
|
||||||
@@ -349,7 +348,6 @@ const icons = {
|
|||||||
threeDotsVertical,
|
threeDotsVertical,
|
||||||
trash,
|
trash,
|
||||||
uiRadios,
|
uiRadios,
|
||||||
unlock,
|
|
||||||
upcScan,
|
upcScan,
|
||||||
windowStack,
|
windowStack,
|
||||||
x,
|
x,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from pikepdf import Pdf
|
|||||||
from documents.converters import convert_from_tiff_to_pdf
|
from documents.converters import convert_from_tiff_to_pdf
|
||||||
from documents.data_models import ConsumableDocument
|
from documents.data_models import ConsumableDocument
|
||||||
from documents.data_models import DocumentMetadataOverrides
|
from documents.data_models import DocumentMetadataOverrides
|
||||||
|
from documents.models import Document
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.plugins.base import ConsumeTaskPlugin
|
from documents.plugins.base import ConsumeTaskPlugin
|
||||||
from documents.plugins.base import StopConsumeTaskError
|
from documents.plugins.base import StopConsumeTaskError
|
||||||
@@ -115,6 +116,24 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
|||||||
self._tiff_conversion_done = False
|
self._tiff_conversion_done = False
|
||||||
self.barcodes: list[Barcode] = []
|
self.barcodes: list[Barcode] = []
|
||||||
|
|
||||||
|
def _apply_detected_asn(self, detected_asn: int) -> None:
|
||||||
|
"""
|
||||||
|
Apply a detected ASN to metadata if allowed.
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
self.metadata.skip_asn_if_exists
|
||||||
|
and Document.global_objects.filter(
|
||||||
|
archive_serial_number=detected_asn,
|
||||||
|
).exists()
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
f"Found ASN in barcode {detected_asn} but skipping because it already exists.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Found ASN in barcode: {detected_asn}")
|
||||||
|
self.metadata.asn = detected_asn
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
# Some operations may use PIL, override pixel setting if needed
|
# Some operations may use PIL, override pixel setting if needed
|
||||||
maybe_override_pixel_limit()
|
maybe_override_pixel_limit()
|
||||||
@@ -186,13 +205,8 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
|||||||
|
|
||||||
# Update/overwrite an ASN if possible
|
# Update/overwrite an ASN if possible
|
||||||
# After splitting, as otherwise each split document gets the same ASN
|
# After splitting, as otherwise each split document gets the same ASN
|
||||||
if (
|
if self.settings.barcode_enable_asn and (located_asn := self.asn) is not None:
|
||||||
self.settings.barcode_enable_asn
|
self._apply_detected_asn(located_asn)
|
||||||
and not self.metadata.skip_asn
|
|
||||||
and (located_asn := self.asn) is not None
|
|
||||||
):
|
|
||||||
logger.info(f"Found ASN in barcode: {located_asn}")
|
|
||||||
self.metadata.asn = located_asn
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
self.temp_dir.cleanup()
|
self.temp_dir.cleanup()
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from celery import chain
|
|
||||||
from celery import chord
|
from celery import chord
|
||||||
from celery import group
|
from celery import group
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
@@ -38,6 +37,42 @@ if TYPE_CHECKING:
|
|||||||
logger: logging.Logger = logging.getLogger("paperless.bulk_edit")
|
logger: logging.Logger = logging.getLogger("paperless.bulk_edit")
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True)
|
||||||
|
def restore_archive_serial_numbers_task(
|
||||||
|
self,
|
||||||
|
backup: dict[int, int],
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
) -> None:
|
||||||
|
restore_archive_serial_numbers(backup)
|
||||||
|
|
||||||
|
|
||||||
|
def release_archive_serial_numbers(doc_ids: list[int]) -> dict[int, int]:
|
||||||
|
"""
|
||||||
|
Clears ASNs on documents that are about to be replaced so new documents
|
||||||
|
can be assigned ASNs without uniqueness collisions. Returns a backup map
|
||||||
|
of doc_id -> previous ASN for potential restoration.
|
||||||
|
"""
|
||||||
|
qs = Document.objects.filter(
|
||||||
|
id__in=doc_ids,
|
||||||
|
archive_serial_number__isnull=False,
|
||||||
|
).only("pk", "archive_serial_number")
|
||||||
|
backup = dict(qs.values_list("pk", "archive_serial_number"))
|
||||||
|
qs.update(archive_serial_number=None)
|
||||||
|
logger.info(f"Released archive serial numbers for documents {list(backup.keys())}")
|
||||||
|
return backup
|
||||||
|
|
||||||
|
|
||||||
|
def restore_archive_serial_numbers(backup: dict[int, int]) -> None:
|
||||||
|
"""
|
||||||
|
Restores ASNs using the provided backup map, intended for
|
||||||
|
rollback when replacement consumption fails.
|
||||||
|
"""
|
||||||
|
for doc_id, asn in backup.items():
|
||||||
|
Document.objects.filter(pk=doc_id).update(archive_serial_number=asn)
|
||||||
|
logger.info(f"Restored archive serial numbers for documents {list(backup.keys())}")
|
||||||
|
|
||||||
|
|
||||||
def set_correspondent(
|
def set_correspondent(
|
||||||
doc_ids: list[int],
|
doc_ids: list[int],
|
||||||
correspondent: Correspondent,
|
correspondent: Correspondent,
|
||||||
@@ -386,6 +421,7 @@ def merge(
|
|||||||
|
|
||||||
merged_pdf = pikepdf.new()
|
merged_pdf = pikepdf.new()
|
||||||
version: str = merged_pdf.pdf_version
|
version: str = merged_pdf.pdf_version
|
||||||
|
handoff_asn: int | None = None
|
||||||
# use doc_ids to preserve order
|
# use doc_ids to preserve order
|
||||||
for doc_id in doc_ids:
|
for doc_id in doc_ids:
|
||||||
doc = qs.get(id=doc_id)
|
doc = qs.get(id=doc_id)
|
||||||
@@ -401,6 +437,8 @@ def merge(
|
|||||||
version = max(version, pdf.pdf_version)
|
version = max(version, pdf.pdf_version)
|
||||||
merged_pdf.pages.extend(pdf.pages)
|
merged_pdf.pages.extend(pdf.pages)
|
||||||
affected_docs.append(doc.id)
|
affected_docs.append(doc.id)
|
||||||
|
if handoff_asn is None and doc.archive_serial_number is not None:
|
||||||
|
handoff_asn = doc.archive_serial_number
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
|
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
|
||||||
@@ -426,6 +464,8 @@ def merge(
|
|||||||
DocumentMetadataOverrides.from_document(metadata_document)
|
DocumentMetadataOverrides.from_document(metadata_document)
|
||||||
)
|
)
|
||||||
overrides.title = metadata_document.title + " (merged)"
|
overrides.title = metadata_document.title + " (merged)"
|
||||||
|
if metadata_document.archive_serial_number is not None:
|
||||||
|
handoff_asn = metadata_document.archive_serial_number
|
||||||
else:
|
else:
|
||||||
overrides = DocumentMetadataOverrides()
|
overrides = DocumentMetadataOverrides()
|
||||||
else:
|
else:
|
||||||
@@ -433,8 +473,11 @@ def merge(
|
|||||||
|
|
||||||
if user is not None:
|
if user is not None:
|
||||||
overrides.owner_id = user.id
|
overrides.owner_id = user.id
|
||||||
# Avoid copying or detecting ASN from merged PDFs to prevent collision
|
if not delete_originals:
|
||||||
overrides.skip_asn = True
|
overrides.skip_asn_if_exists = True
|
||||||
|
|
||||||
|
if delete_originals and handoff_asn is not None:
|
||||||
|
overrides.asn = handoff_asn
|
||||||
|
|
||||||
logger.info("Adding merged document to the task queue.")
|
logger.info("Adding merged document to the task queue.")
|
||||||
|
|
||||||
@@ -447,12 +490,20 @@ def merge(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if delete_originals:
|
if delete_originals:
|
||||||
|
backup = release_archive_serial_numbers(affected_docs)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Queueing removal of original documents after consumption of merged document",
|
"Queueing removal of original documents after consumption of merged document",
|
||||||
)
|
)
|
||||||
chain(consume_task, delete.si(affected_docs)).delay()
|
try:
|
||||||
else:
|
consume_task.apply_async(
|
||||||
consume_task.delay()
|
link=[delete.si(affected_docs)],
|
||||||
|
link_error=[restore_archive_serial_numbers_task.s(backup)],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
restore_archive_serial_numbers(backup)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
consume_task.delay()
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
@@ -494,6 +545,8 @@ def split(
|
|||||||
overrides.title = f"{doc.title} (split {idx + 1})"
|
overrides.title = f"{doc.title} (split {idx + 1})"
|
||||||
if user is not None:
|
if user is not None:
|
||||||
overrides.owner_id = user.id
|
overrides.owner_id = user.id
|
||||||
|
if not delete_originals:
|
||||||
|
overrides.skip_asn_if_exists = True
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Adding split document with pages {split_doc} to the task queue.",
|
f"Adding split document with pages {split_doc} to the task queue.",
|
||||||
)
|
)
|
||||||
@@ -508,10 +561,20 @@ def split(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if delete_originals:
|
if delete_originals:
|
||||||
|
backup = release_archive_serial_numbers([doc.id])
|
||||||
logger.info(
|
logger.info(
|
||||||
"Queueing removal of original document after consumption of the split documents",
|
"Queueing removal of original document after consumption of the split documents",
|
||||||
)
|
)
|
||||||
chord(header=consume_tasks, body=delete.si([doc.id])).delay()
|
try:
|
||||||
|
chord(
|
||||||
|
header=consume_tasks,
|
||||||
|
body=delete.si([doc.id]),
|
||||||
|
).apply_async(
|
||||||
|
link_error=[restore_archive_serial_numbers_task.s(backup)],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
restore_archive_serial_numbers(backup)
|
||||||
|
raise
|
||||||
else:
|
else:
|
||||||
group(consume_tasks).delay()
|
group(consume_tasks).delay()
|
||||||
|
|
||||||
@@ -614,7 +677,10 @@ def edit_pdf(
|
|||||||
)
|
)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
overrides.owner_id = user.id
|
overrides.owner_id = user.id
|
||||||
|
if not delete_original:
|
||||||
|
overrides.skip_asn_if_exists = True
|
||||||
|
if delete_original and len(pdf_docs) == 1:
|
||||||
|
overrides.asn = doc.archive_serial_number
|
||||||
for idx, pdf in enumerate(pdf_docs, start=1):
|
for idx, pdf in enumerate(pdf_docs, start=1):
|
||||||
filepath: Path = (
|
filepath: Path = (
|
||||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||||
@@ -633,7 +699,17 @@ def edit_pdf(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if delete_original:
|
if delete_original:
|
||||||
chord(header=consume_tasks, body=delete.si([doc.id])).delay()
|
backup = release_archive_serial_numbers([doc.id])
|
||||||
|
try:
|
||||||
|
chord(
|
||||||
|
header=consume_tasks,
|
||||||
|
body=delete.si([doc.id]),
|
||||||
|
).apply_async(
|
||||||
|
link_error=[restore_archive_serial_numbers_task.s(backup)],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
restore_archive_serial_numbers(backup)
|
||||||
|
raise
|
||||||
else:
|
else:
|
||||||
group(consume_tasks).delay()
|
group(consume_tasks).delay()
|
||||||
|
|
||||||
@@ -646,77 +722,6 @@ def edit_pdf(
|
|||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
def remove_password(
|
|
||||||
doc_ids: list[int],
|
|
||||||
password: str,
|
|
||||||
*,
|
|
||||||
update_document: bool = False,
|
|
||||||
delete_original: bool = False,
|
|
||||||
include_metadata: bool = True,
|
|
||||||
user: User | None = None,
|
|
||||||
) -> Literal["OK"]:
|
|
||||||
"""
|
|
||||||
Remove password protection from PDF documents.
|
|
||||||
"""
|
|
||||||
import pikepdf
|
|
||||||
|
|
||||||
for doc_id in doc_ids:
|
|
||||||
doc = Document.objects.get(id=doc_id)
|
|
||||||
try:
|
|
||||||
logger.info(
|
|
||||||
f"Attempting password removal from document {doc_ids[0]}",
|
|
||||||
)
|
|
||||||
with pikepdf.open(doc.source_path, password=password) as pdf:
|
|
||||||
temp_path = doc.source_path.with_suffix(".tmp.pdf")
|
|
||||||
pdf.remove_unreferenced_resources()
|
|
||||||
pdf.save(temp_path)
|
|
||||||
|
|
||||||
if update_document:
|
|
||||||
# replace the original document with the unprotected one
|
|
||||||
temp_path.replace(doc.source_path)
|
|
||||||
doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest()
|
|
||||||
doc.page_count = len(pdf.pages)
|
|
||||||
doc.save()
|
|
||||||
update_document_content_maybe_archive_file.delay(document_id=doc.id)
|
|
||||||
else:
|
|
||||||
consume_tasks = []
|
|
||||||
overrides = (
|
|
||||||
DocumentMetadataOverrides().from_document(doc)
|
|
||||||
if include_metadata
|
|
||||||
else DocumentMetadataOverrides()
|
|
||||||
)
|
|
||||||
if user is not None:
|
|
||||||
overrides.owner_id = user.id
|
|
||||||
|
|
||||||
filepath: Path = (
|
|
||||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
|
||||||
/ f"{doc.id}_unprotected.pdf"
|
|
||||||
)
|
|
||||||
temp_path.replace(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 removing password from document {doc.id}: {e}")
|
|
||||||
raise ValueError(
|
|
||||||
f"An error occurred while removing the password: {e}",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
return "OK"
|
|
||||||
|
|
||||||
|
|
||||||
def reflect_doclinks(
|
def reflect_doclinks(
|
||||||
document: Document,
|
document: Document,
|
||||||
field: CustomField,
|
field: CustomField,
|
||||||
|
|||||||
@@ -696,7 +696,7 @@ class ConsumerPlugin(
|
|||||||
pk=self.metadata.storage_path_id,
|
pk=self.metadata.storage_path_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.metadata.asn is not None and not self.metadata.skip_asn:
|
if self.metadata.asn is not None:
|
||||||
document.archive_serial_number = self.metadata.asn
|
document.archive_serial_number = self.metadata.asn
|
||||||
|
|
||||||
if self.metadata.owner_id:
|
if self.metadata.owner_id:
|
||||||
@@ -812,8 +812,8 @@ class ConsumerPreflightPlugin(
|
|||||||
"""
|
"""
|
||||||
Check that if override_asn is given, it is unique and within a valid range
|
Check that if override_asn is given, it is unique and within a valid range
|
||||||
"""
|
"""
|
||||||
if self.metadata.skip_asn or self.metadata.asn is None:
|
if self.metadata.asn is None:
|
||||||
# if skip is set or ASN is None
|
# if ASN is None
|
||||||
return
|
return
|
||||||
# Validate the range is above zero and less than uint32_t max
|
# Validate the range is above zero and less than uint32_t max
|
||||||
# otherwise, Whoosh can't handle it in the index
|
# otherwise, Whoosh can't handle it in the index
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class DocumentMetadataOverrides:
|
|||||||
change_users: list[int] | None = None
|
change_users: list[int] | None = None
|
||||||
change_groups: list[int] | None = None
|
change_groups: list[int] | None = None
|
||||||
custom_fields: dict | None = None
|
custom_fields: dict | None = None
|
||||||
skip_asn: bool = False
|
skip_asn_if_exists: bool = False
|
||||||
|
|
||||||
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
|
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
|
||||||
"""
|
"""
|
||||||
@@ -50,8 +50,8 @@ class DocumentMetadataOverrides:
|
|||||||
self.storage_path_id = other.storage_path_id
|
self.storage_path_id = other.storage_path_id
|
||||||
if other.owner_id is not None:
|
if other.owner_id is not None:
|
||||||
self.owner_id = other.owner_id
|
self.owner_id = other.owner_id
|
||||||
if other.skip_asn:
|
if other.skip_asn_if_exists:
|
||||||
self.skip_asn = True
|
self.skip_asn_if_exists = True
|
||||||
|
|
||||||
# merge
|
# merge
|
||||||
if self.tag_ids is None:
|
if self.tag_ids is None:
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ from django.core.exceptions import ValidationError
|
|||||||
from django.core.validators import DecimalValidator
|
from django.core.validators import DecimalValidator
|
||||||
from django.core.validators import EmailValidator
|
from django.core.validators import EmailValidator
|
||||||
from django.core.validators import MaxLengthValidator
|
from django.core.validators import MaxLengthValidator
|
||||||
|
from django.core.validators import MaxValueValidator
|
||||||
|
from django.core.validators import MinValueValidator
|
||||||
from django.core.validators import RegexValidator
|
from django.core.validators import RegexValidator
|
||||||
from django.core.validators import integer_validator
|
from django.core.validators import integer_validator
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
@@ -875,6 +877,13 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
|||||||
uri_validator(data["value"])
|
uri_validator(data["value"])
|
||||||
elif field.data_type == CustomField.FieldDataType.INT:
|
elif field.data_type == CustomField.FieldDataType.INT:
|
||||||
integer_validator(data["value"])
|
integer_validator(data["value"])
|
||||||
|
try:
|
||||||
|
value_int = int(data["value"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise serializers.ValidationError("Enter a valid integer.")
|
||||||
|
# Keep values within the PostgreSQL integer range
|
||||||
|
MinValueValidator(-2147483648)(value_int)
|
||||||
|
MaxValueValidator(2147483647)(value_int)
|
||||||
elif (
|
elif (
|
||||||
field.data_type == CustomField.FieldDataType.MONETARY
|
field.data_type == CustomField.FieldDataType.MONETARY
|
||||||
and data["value"] != ""
|
and data["value"] != ""
|
||||||
@@ -1421,7 +1430,6 @@ class BulkEditSerializer(
|
|||||||
"split",
|
"split",
|
||||||
"delete_pages",
|
"delete_pages",
|
||||||
"edit_pdf",
|
"edit_pdf",
|
||||||
"remove_password",
|
|
||||||
],
|
],
|
||||||
label="Method",
|
label="Method",
|
||||||
write_only=True,
|
write_only=True,
|
||||||
@@ -1497,8 +1505,6 @@ class BulkEditSerializer(
|
|||||||
return bulk_edit.delete_pages
|
return bulk_edit.delete_pages
|
||||||
elif method == "edit_pdf":
|
elif method == "edit_pdf":
|
||||||
return bulk_edit.edit_pdf
|
return bulk_edit.edit_pdf
|
||||||
elif method == "remove_password":
|
|
||||||
return bulk_edit.remove_password
|
|
||||||
else: # pragma: no cover
|
else: # pragma: no cover
|
||||||
# This will never happen as it is handled by the ChoiceField
|
# This will never happen as it is handled by the ChoiceField
|
||||||
raise serializers.ValidationError("Unsupported method.")
|
raise serializers.ValidationError("Unsupported method.")
|
||||||
@@ -1695,12 +1701,6 @@ class BulkEditSerializer(
|
|||||||
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
|
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
|
||||||
)
|
)
|
||||||
|
|
||||||
def validate_parameters_remove_password(self, parameters):
|
|
||||||
if "password" not in parameters:
|
|
||||||
raise serializers.ValidationError("password not specified")
|
|
||||||
if not isinstance(parameters["password"], str):
|
|
||||||
raise serializers.ValidationError("password must be a string")
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
method = attrs["method"]
|
method = attrs["method"]
|
||||||
parameters = attrs["parameters"]
|
parameters = attrs["parameters"]
|
||||||
@@ -1741,8 +1741,6 @@ class BulkEditSerializer(
|
|||||||
"Edit PDF method only supports one document",
|
"Edit PDF method only supports one document",
|
||||||
)
|
)
|
||||||
self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
|
self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
|
||||||
elif method == bulk_edit.remove_password:
|
|
||||||
self.validate_parameters_remove_password(parameters)
|
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|||||||
@@ -1582,58 +1582,6 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(b"out of bounds", response.content)
|
self.assertIn(b"out of bounds", response.content)
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.remove_password")
|
|
||||||
def test_remove_password(self, m):
|
|
||||||
self.setup_mock(m, "remove_password")
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/bulk_edit/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"documents": [self.doc2.id],
|
|
||||||
"method": "remove_password",
|
|
||||||
"parameters": {"password": "secret", "update_document": True},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
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["password"], "secret")
|
|
||||||
self.assertTrue(kwargs["update_document"])
|
|
||||||
self.assertEqual(kwargs["user"], self.user)
|
|
||||||
|
|
||||||
def test_remove_password_invalid_params(self):
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/bulk_edit/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"documents": [self.doc2.id],
|
|
||||||
"method": "remove_password",
|
|
||||||
"parameters": {},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
||||||
self.assertIn(b"password not specified", response.content)
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/bulk_edit/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"documents": [self.doc2.id],
|
|
||||||
"method": "remove_password",
|
|
||||||
"parameters": {"password": 123},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
||||||
self.assertIn(b"password must be a string", response.content)
|
|
||||||
|
|
||||||
@override_settings(AUDIT_LOG_ENABLED=True)
|
@override_settings(AUDIT_LOG_ENABLED=True)
|
||||||
def test_bulk_edit_audit_log_enabled_simple_field(self):
|
def test_bulk_edit_audit_log_enabled_simple_field(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1664,6 +1664,44 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
|
|
||||||
self.consume_file_mock.assert_not_called()
|
self.consume_file_mock.assert_not_called()
|
||||||
|
|
||||||
|
def test_patch_document_integer_custom_field_out_of_range(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An integer custom field
|
||||||
|
- A document
|
||||||
|
WHEN:
|
||||||
|
- Patching the document with an integer value exceeding PostgreSQL's range
|
||||||
|
THEN:
|
||||||
|
- HTTP 400 is returned (validation catches the overflow)
|
||||||
|
- No custom field instance is created
|
||||||
|
"""
|
||||||
|
cf_int = CustomField.objects.create(
|
||||||
|
name="intfield",
|
||||||
|
data_type=CustomField.FieldDataType.INT,
|
||||||
|
)
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Doc",
|
||||||
|
checksum="123",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"/api/documents/{doc.pk}/",
|
||||||
|
{
|
||||||
|
"custom_fields": [
|
||||||
|
{
|
||||||
|
"field": cf_int.pk,
|
||||||
|
"value": 2**31, # overflow for PostgreSQL integer fields
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn("custom_fields", response.data)
|
||||||
|
self.assertEqual(CustomFieldInstance.objects.count(), 0)
|
||||||
|
|
||||||
def test_upload_with_webui_source(self):
|
def test_upload_with_webui_source(self):
|
||||||
"""
|
"""
|
||||||
GIVEN: A document with a source file
|
GIVEN: A document with a source file
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import hashlib
|
|
||||||
import shutil
|
import shutil
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -603,23 +602,21 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
expected_filename,
|
expected_filename,
|
||||||
)
|
)
|
||||||
self.assertEqual(consume_file_args[1].title, None)
|
self.assertEqual(consume_file_args[1].title, None)
|
||||||
self.assertTrue(consume_file_args[1].skip_asn)
|
# No metadata_document_id, delete_originals False, so ASN should be None
|
||||||
|
self.assertIsNone(consume_file_args[1].asn)
|
||||||
|
|
||||||
# With metadata_document_id overrides
|
# With metadata_document_id overrides
|
||||||
result = bulk_edit.merge(doc_ids, metadata_document_id=metadata_document_id)
|
result = bulk_edit.merge(doc_ids, metadata_document_id=metadata_document_id)
|
||||||
consume_file_args, _ = mock_consume_file.call_args
|
consume_file_args, _ = mock_consume_file.call_args
|
||||||
self.assertEqual(consume_file_args[1].title, "B (merged)")
|
self.assertEqual(consume_file_args[1].title, "B (merged)")
|
||||||
self.assertEqual(consume_file_args[1].created, self.doc2.created)
|
self.assertEqual(consume_file_args[1].created, self.doc2.created)
|
||||||
self.assertTrue(consume_file_args[1].skip_asn)
|
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.delete.si")
|
@mock.patch("documents.bulk_edit.delete.si")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
@mock.patch("documents.bulk_edit.chain")
|
|
||||||
def test_merge_and_delete_originals(
|
def test_merge_and_delete_originals(
|
||||||
self,
|
self,
|
||||||
mock_chain,
|
|
||||||
mock_consume_file,
|
mock_consume_file,
|
||||||
mock_delete_documents,
|
mock_delete_documents,
|
||||||
):
|
):
|
||||||
@@ -633,6 +630,12 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
- Document deletion task should be called
|
- Document deletion task should be called
|
||||||
"""
|
"""
|
||||||
doc_ids = [self.doc1.id, self.doc2.id, self.doc3.id]
|
doc_ids = [self.doc1.id, self.doc2.id, self.doc3.id]
|
||||||
|
self.doc1.archive_serial_number = 101
|
||||||
|
self.doc2.archive_serial_number = 102
|
||||||
|
self.doc3.archive_serial_number = 103
|
||||||
|
self.doc1.save()
|
||||||
|
self.doc2.save()
|
||||||
|
self.doc3.save()
|
||||||
|
|
||||||
result = bulk_edit.merge(doc_ids, delete_originals=True)
|
result = bulk_edit.merge(doc_ids, delete_originals=True)
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
@@ -643,7 +646,8 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
mock_consume_file.assert_called()
|
mock_consume_file.assert_called()
|
||||||
mock_delete_documents.assert_called()
|
mock_delete_documents.assert_called()
|
||||||
mock_chain.assert_called_once()
|
consume_sig = mock_consume_file.return_value
|
||||||
|
consume_sig.apply_async.assert_called_once()
|
||||||
|
|
||||||
consume_file_args, _ = mock_consume_file.call_args
|
consume_file_args, _ = mock_consume_file.call_args
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -651,7 +655,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
expected_filename,
|
expected_filename,
|
||||||
)
|
)
|
||||||
self.assertEqual(consume_file_args[1].title, None)
|
self.assertEqual(consume_file_args[1].title, None)
|
||||||
self.assertTrue(consume_file_args[1].skip_asn)
|
self.assertEqual(consume_file_args[1].asn, 101)
|
||||||
|
|
||||||
delete_documents_args, _ = mock_delete_documents.call_args
|
delete_documents_args, _ = mock_delete_documents.call_args
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -659,6 +663,92 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
doc_ids,
|
doc_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.doc1.refresh_from_db()
|
||||||
|
self.doc2.refresh_from_db()
|
||||||
|
self.doc3.refresh_from_db()
|
||||||
|
self.assertIsNone(self.doc1.archive_serial_number)
|
||||||
|
self.assertIsNone(self.doc2.archive_serial_number)
|
||||||
|
self.assertIsNone(self.doc3.archive_serial_number)
|
||||||
|
|
||||||
|
@mock.patch("documents.bulk_edit.delete.si")
|
||||||
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
|
def test_merge_and_delete_originals_restore_on_failure(
|
||||||
|
self,
|
||||||
|
mock_consume_file,
|
||||||
|
mock_delete_documents,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing documents
|
||||||
|
WHEN:
|
||||||
|
- Merge action with deleting documents is called with 1 document
|
||||||
|
- Error occurs when queuing consume file task
|
||||||
|
THEN:
|
||||||
|
- Archive serial numbers are restored
|
||||||
|
"""
|
||||||
|
doc_ids = [self.doc1.id]
|
||||||
|
self.doc1.archive_serial_number = 111
|
||||||
|
self.doc1.save()
|
||||||
|
sig = mock.Mock()
|
||||||
|
sig.apply_async.side_effect = Exception("boom")
|
||||||
|
mock_consume_file.return_value = sig
|
||||||
|
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
bulk_edit.merge(doc_ids, delete_originals=True)
|
||||||
|
|
||||||
|
self.doc1.refresh_from_db()
|
||||||
|
self.assertEqual(self.doc1.archive_serial_number, 111)
|
||||||
|
|
||||||
|
@mock.patch("documents.bulk_edit.delete.si")
|
||||||
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
|
def test_merge_and_delete_originals_metadata_handoff(
|
||||||
|
self,
|
||||||
|
mock_consume_file,
|
||||||
|
mock_delete_documents,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing documents with ASNs
|
||||||
|
WHEN:
|
||||||
|
- Merge with delete_originals=True and metadata_document_id set
|
||||||
|
THEN:
|
||||||
|
- Handoff ASN uses metadata document ASN
|
||||||
|
"""
|
||||||
|
doc_ids = [self.doc1.id, self.doc2.id]
|
||||||
|
self.doc1.archive_serial_number = 101
|
||||||
|
self.doc2.archive_serial_number = 202
|
||||||
|
self.doc1.save()
|
||||||
|
self.doc2.save()
|
||||||
|
|
||||||
|
result = bulk_edit.merge(
|
||||||
|
doc_ids,
|
||||||
|
metadata_document_id=self.doc2.id,
|
||||||
|
delete_originals=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result, "OK")
|
||||||
|
|
||||||
|
consume_file_args, _ = mock_consume_file.call_args
|
||||||
|
self.assertEqual(consume_file_args[1].asn, 202)
|
||||||
|
|
||||||
|
def test_restore_archive_serial_numbers_task(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing document with no archive serial number
|
||||||
|
WHEN:
|
||||||
|
- Restore archive serial number task is called with backup data
|
||||||
|
THEN:
|
||||||
|
- Document archive serial number is restored
|
||||||
|
"""
|
||||||
|
self.doc1.archive_serial_number = 444
|
||||||
|
self.doc1.save()
|
||||||
|
Document.objects.filter(pk=self.doc1.id).update(archive_serial_number=None)
|
||||||
|
|
||||||
|
backup = {self.doc1.id: 444}
|
||||||
|
bulk_edit.restore_archive_serial_numbers_task(backup)
|
||||||
|
|
||||||
|
self.doc1.refresh_from_db()
|
||||||
|
self.assertEqual(self.doc1.archive_serial_number, 444)
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
def test_merge_with_archive_fallback(self, mock_consume_file):
|
def test_merge_with_archive_fallback(self, mock_consume_file):
|
||||||
"""
|
"""
|
||||||
@@ -727,6 +817,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(mock_consume_file.call_count, 2)
|
self.assertEqual(mock_consume_file.call_count, 2)
|
||||||
consume_file_args, _ = mock_consume_file.call_args
|
consume_file_args, _ = mock_consume_file.call_args
|
||||||
self.assertEqual(consume_file_args[1].title, "B (split 2)")
|
self.assertEqual(consume_file_args[1].title, "B (split 2)")
|
||||||
|
self.assertIsNone(consume_file_args[1].asn)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
|
|
||||||
@@ -751,6 +842,8 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
"""
|
"""
|
||||||
doc_ids = [self.doc2.id]
|
doc_ids = [self.doc2.id]
|
||||||
pages = [[1, 2], [3]]
|
pages = [[1, 2], [3]]
|
||||||
|
self.doc2.archive_serial_number = 200
|
||||||
|
self.doc2.save()
|
||||||
|
|
||||||
result = bulk_edit.split(doc_ids, pages, delete_originals=True)
|
result = bulk_edit.split(doc_ids, pages, delete_originals=True)
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
@@ -768,6 +861,42 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
doc_ids,
|
doc_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.doc2.refresh_from_db()
|
||||||
|
self.assertIsNone(self.doc2.archive_serial_number)
|
||||||
|
|
||||||
|
@mock.patch("documents.bulk_edit.delete.si")
|
||||||
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
|
@mock.patch("documents.bulk_edit.chord")
|
||||||
|
def test_split_restore_on_failure(
|
||||||
|
self,
|
||||||
|
mock_chord,
|
||||||
|
mock_consume_file,
|
||||||
|
mock_delete_documents,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing documents
|
||||||
|
WHEN:
|
||||||
|
- Split action with deleting documents is called with 1 document and 2 page groups
|
||||||
|
- Error occurs when queuing chord task
|
||||||
|
THEN:
|
||||||
|
- Archive serial numbers are restored
|
||||||
|
"""
|
||||||
|
doc_ids = [self.doc2.id]
|
||||||
|
pages = [[1, 2]]
|
||||||
|
self.doc2.archive_serial_number = 222
|
||||||
|
self.doc2.save()
|
||||||
|
|
||||||
|
sig = mock.Mock()
|
||||||
|
sig.apply_async.side_effect = Exception("boom")
|
||||||
|
mock_chord.return_value = sig
|
||||||
|
|
||||||
|
result = bulk_edit.split(doc_ids, pages, delete_originals=True)
|
||||||
|
self.assertEqual(result, "OK")
|
||||||
|
|
||||||
|
self.doc2.refresh_from_db()
|
||||||
|
self.assertEqual(self.doc2.archive_serial_number, 222)
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.delay")
|
@mock.patch("documents.tasks.consume_file.delay")
|
||||||
@mock.patch("pikepdf.Pdf.save")
|
@mock.patch("pikepdf.Pdf.save")
|
||||||
def test_split_with_errors(self, mock_save_pdf, mock_consume_file):
|
def test_split_with_errors(self, mock_save_pdf, mock_consume_file):
|
||||||
@@ -968,10 +1097,49 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
mock_chord.return_value.delay.return_value = None
|
mock_chord.return_value.delay.return_value = None
|
||||||
doc_ids = [self.doc2.id]
|
doc_ids = [self.doc2.id]
|
||||||
operations = [{"page": 1}, {"page": 2}]
|
operations = [{"page": 1}, {"page": 2}]
|
||||||
|
self.doc2.archive_serial_number = 250
|
||||||
|
self.doc2.save()
|
||||||
|
|
||||||
result = bulk_edit.edit_pdf(doc_ids, operations, delete_original=True)
|
result = bulk_edit.edit_pdf(doc_ids, operations, delete_original=True)
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_chord.assert_called_once()
|
mock_chord.assert_called_once()
|
||||||
|
consume_file_args, _ = mock_consume_file.call_args
|
||||||
|
self.assertEqual(consume_file_args[1].asn, 250)
|
||||||
|
self.doc2.refresh_from_db()
|
||||||
|
self.assertIsNone(self.doc2.archive_serial_number)
|
||||||
|
|
||||||
|
@mock.patch("documents.bulk_edit.delete.si")
|
||||||
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
|
@mock.patch("documents.bulk_edit.chord")
|
||||||
|
def test_edit_pdf_restore_on_failure(
|
||||||
|
self,
|
||||||
|
mock_chord,
|
||||||
|
mock_consume_file,
|
||||||
|
mock_delete_documents,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing document
|
||||||
|
WHEN:
|
||||||
|
- edit_pdf is called with delete_original=True
|
||||||
|
- Error occurs when queuing chord task
|
||||||
|
THEN:
|
||||||
|
- Archive serial numbers are restored
|
||||||
|
"""
|
||||||
|
doc_ids = [self.doc2.id]
|
||||||
|
operations = [{"page": 1}]
|
||||||
|
self.doc2.archive_serial_number = 333
|
||||||
|
self.doc2.save()
|
||||||
|
|
||||||
|
sig = mock.Mock()
|
||||||
|
sig.apply_async.side_effect = Exception("boom")
|
||||||
|
mock_chord.return_value = sig
|
||||||
|
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
bulk_edit.edit_pdf(doc_ids, operations, delete_original=True)
|
||||||
|
|
||||||
|
self.doc2.refresh_from_db()
|
||||||
|
self.assertEqual(self.doc2.archive_serial_number, 333)
|
||||||
|
|
||||||
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.delay")
|
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.delay")
|
||||||
def test_edit_pdf_with_update_document(self, mock_update_document):
|
def test_edit_pdf_with_update_document(self, mock_update_document):
|
||||||
@@ -1067,147 +1235,3 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
bulk_edit.edit_pdf(doc_ids, operations, update_document=True)
|
bulk_edit.edit_pdf(doc_ids, operations, update_document=True)
|
||||||
mock_group.assert_not_called()
|
mock_group.assert_not_called()
|
||||||
mock_consume_file.assert_not_called()
|
mock_consume_file.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
|
|
||||||
@mock.patch("pikepdf.open")
|
|
||||||
def test_remove_password_update_document(self, mock_open, mock_update_document):
|
|
||||||
doc = self.doc1
|
|
||||||
original_checksum = doc.checksum
|
|
||||||
|
|
||||||
fake_pdf = mock.MagicMock()
|
|
||||||
fake_pdf.pages = [mock.Mock(), mock.Mock(), mock.Mock()]
|
|
||||||
|
|
||||||
def save_side_effect(target_path):
|
|
||||||
Path(target_path).write_bytes(b"new pdf content")
|
|
||||||
|
|
||||||
fake_pdf.save.side_effect = save_side_effect
|
|
||||||
mock_open.return_value.__enter__.return_value = fake_pdf
|
|
||||||
|
|
||||||
result = bulk_edit.remove_password(
|
|
||||||
[doc.id],
|
|
||||||
password="secret",
|
|
||||||
update_document=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
|
||||||
mock_open.assert_called_once_with(doc.source_path, password="secret")
|
|
||||||
fake_pdf.remove_unreferenced_resources.assert_called_once()
|
|
||||||
doc.refresh_from_db()
|
|
||||||
self.assertNotEqual(doc.checksum, original_checksum)
|
|
||||||
expected_checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest()
|
|
||||||
self.assertEqual(doc.checksum, expected_checksum)
|
|
||||||
self.assertEqual(doc.page_count, len(fake_pdf.pages))
|
|
||||||
mock_update_document.assert_called_once_with(document_id=doc.id)
|
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.chord")
|
|
||||||
@mock.patch("documents.bulk_edit.group")
|
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
|
||||||
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
|
||||||
@mock.patch("pikepdf.open")
|
|
||||||
def test_remove_password_creates_consumable_document(
|
|
||||||
self,
|
|
||||||
mock_open,
|
|
||||||
mock_mkdtemp,
|
|
||||||
mock_consume_file,
|
|
||||||
mock_group,
|
|
||||||
mock_chord,
|
|
||||||
):
|
|
||||||
doc = self.doc2
|
|
||||||
temp_dir = self.dirs.scratch_dir / "remove-password"
|
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
mock_mkdtemp.return_value = str(temp_dir)
|
|
||||||
|
|
||||||
fake_pdf = mock.MagicMock()
|
|
||||||
fake_pdf.pages = [mock.Mock(), mock.Mock()]
|
|
||||||
|
|
||||||
def save_side_effect(target_path):
|
|
||||||
Path(target_path).write_bytes(b"password removed")
|
|
||||||
|
|
||||||
fake_pdf.save.side_effect = save_side_effect
|
|
||||||
mock_open.return_value.__enter__.return_value = fake_pdf
|
|
||||||
mock_group.return_value.delay.return_value = None
|
|
||||||
|
|
||||||
user = User.objects.create(username="owner")
|
|
||||||
|
|
||||||
result = bulk_edit.remove_password(
|
|
||||||
[doc.id],
|
|
||||||
password="secret",
|
|
||||||
include_metadata=False,
|
|
||||||
update_document=False,
|
|
||||||
delete_original=False,
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
|
||||||
mock_open.assert_called_once_with(doc.source_path, password="secret")
|
|
||||||
mock_consume_file.assert_called_once()
|
|
||||||
consume_args, _ = mock_consume_file.call_args
|
|
||||||
consumable_document = consume_args[0]
|
|
||||||
overrides = consume_args[1]
|
|
||||||
expected_path = temp_dir / f"{doc.id}_unprotected.pdf"
|
|
||||||
self.assertTrue(expected_path.exists())
|
|
||||||
self.assertEqual(
|
|
||||||
Path(consumable_document.original_file).resolve(),
|
|
||||||
expected_path.resolve(),
|
|
||||||
)
|
|
||||||
self.assertEqual(overrides.owner_id, user.id)
|
|
||||||
mock_group.assert_called_once_with([mock_consume_file.return_value])
|
|
||||||
mock_group.return_value.delay.assert_called_once()
|
|
||||||
mock_chord.assert_not_called()
|
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.delete")
|
|
||||||
@mock.patch("documents.bulk_edit.chord")
|
|
||||||
@mock.patch("documents.bulk_edit.group")
|
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
|
||||||
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
|
||||||
@mock.patch("pikepdf.open")
|
|
||||||
def test_remove_password_deletes_original(
|
|
||||||
self,
|
|
||||||
mock_open,
|
|
||||||
mock_mkdtemp,
|
|
||||||
mock_consume_file,
|
|
||||||
mock_group,
|
|
||||||
mock_chord,
|
|
||||||
mock_delete,
|
|
||||||
):
|
|
||||||
doc = self.doc2
|
|
||||||
temp_dir = self.dirs.scratch_dir / "remove-password-delete"
|
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
mock_mkdtemp.return_value = str(temp_dir)
|
|
||||||
|
|
||||||
fake_pdf = mock.MagicMock()
|
|
||||||
fake_pdf.pages = [mock.Mock(), mock.Mock()]
|
|
||||||
|
|
||||||
def save_side_effect(target_path):
|
|
||||||
Path(target_path).write_bytes(b"password removed")
|
|
||||||
|
|
||||||
fake_pdf.save.side_effect = save_side_effect
|
|
||||||
mock_open.return_value.__enter__.return_value = fake_pdf
|
|
||||||
mock_chord.return_value.delay.return_value = None
|
|
||||||
|
|
||||||
result = bulk_edit.remove_password(
|
|
||||||
[doc.id],
|
|
||||||
password="secret",
|
|
||||||
include_metadata=False,
|
|
||||||
update_document=False,
|
|
||||||
delete_original=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
|
||||||
mock_open.assert_called_once_with(doc.source_path, password="secret")
|
|
||||||
mock_consume_file.assert_called_once()
|
|
||||||
mock_group.assert_not_called()
|
|
||||||
mock_chord.assert_called_once()
|
|
||||||
mock_chord.return_value.delay.assert_called_once()
|
|
||||||
mock_delete.si.assert_called_once_with([doc.id])
|
|
||||||
|
|
||||||
@mock.patch("pikepdf.open")
|
|
||||||
def test_remove_password_open_failure(self, mock_open):
|
|
||||||
mock_open.side_effect = RuntimeError("wrong password")
|
|
||||||
|
|
||||||
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
|
||||||
with self.assertRaises(ValueError) as exc:
|
|
||||||
bulk_edit.remove_password([self.doc1.id], password="secret")
|
|
||||||
|
|
||||||
self.assertIn("wrong password", str(exc.exception))
|
|
||||||
self.assertIn("Error removing password from document", cm.output[0])
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from django.test import override_settings
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from guardian.core import ObjectPermissionChecker
|
from guardian.core import ObjectPermissionChecker
|
||||||
|
|
||||||
|
from documents.barcodes import BarcodePlugin
|
||||||
from documents.consumer import ConsumerError
|
from documents.consumer import ConsumerError
|
||||||
from documents.data_models import DocumentMetadataOverrides
|
from documents.data_models import DocumentMetadataOverrides
|
||||||
from documents.data_models import DocumentSource
|
from documents.data_models import DocumentSource
|
||||||
@@ -412,14 +413,6 @@ class TestConsumer(
|
|||||||
self.assertEqual(document.archive_serial_number, 123)
|
self.assertEqual(document.archive_serial_number, 123)
|
||||||
self._assert_first_last_send_progress()
|
self._assert_first_last_send_progress()
|
||||||
|
|
||||||
def testMetadataOverridesSkipAsnPropagation(self):
|
|
||||||
overrides = DocumentMetadataOverrides()
|
|
||||||
incoming = DocumentMetadataOverrides(skip_asn=True)
|
|
||||||
|
|
||||||
overrides.update(incoming)
|
|
||||||
|
|
||||||
self.assertTrue(overrides.skip_asn)
|
|
||||||
|
|
||||||
def testOverrideTitlePlaceholders(self):
|
def testOverrideTitlePlaceholders(self):
|
||||||
c = Correspondent.objects.create(name="Correspondent Name")
|
c = Correspondent.objects.create(name="Correspondent Name")
|
||||||
dt = DocumentType.objects.create(name="DocType Name")
|
dt = DocumentType.objects.create(name="DocType Name")
|
||||||
@@ -1240,3 +1233,46 @@ class PostConsumeTestCase(DirectoriesMixin, GetConsumerMixin, TestCase):
|
|||||||
r"sample\.pdf: Error while executing post-consume script: Command '\[.*\]' returned non-zero exit status \d+\.",
|
r"sample\.pdf: Error while executing post-consume script: Command '\[.*\]' returned non-zero exit status \d+\.",
|
||||||
):
|
):
|
||||||
consumer.run_post_consume_script(doc)
|
consumer.run_post_consume_script(doc)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMetadataOverrides(TestCase):
|
||||||
|
def test_update_skip_asn_if_exists(self):
|
||||||
|
base = DocumentMetadataOverrides()
|
||||||
|
incoming = DocumentMetadataOverrides(skip_asn_if_exists=True)
|
||||||
|
base.update(incoming)
|
||||||
|
self.assertTrue(base.skip_asn_if_exists)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBarcodeApplyDetectedASN(TestCase):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing Documents with ASN 123
|
||||||
|
WHEN:
|
||||||
|
- A BarcodePlugin which detected an ASN
|
||||||
|
THEN:
|
||||||
|
- If skip_asn_if_exists is set, and ASN exists, do not set ASN
|
||||||
|
- If skip_asn_if_exists is set, and ASN does not exist, set ASN
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_apply_detected_asn_skips_existing_when_flag_set(self):
|
||||||
|
doc = Document.objects.create(
|
||||||
|
checksum="X1",
|
||||||
|
title="D1",
|
||||||
|
archive_serial_number=123,
|
||||||
|
)
|
||||||
|
metadata = DocumentMetadataOverrides(skip_asn_if_exists=True)
|
||||||
|
plugin = BarcodePlugin(
|
||||||
|
input_doc=mock.Mock(),
|
||||||
|
metadata=metadata,
|
||||||
|
status_mgr=mock.Mock(),
|
||||||
|
base_tmp_dir=tempfile.gettempdir(),
|
||||||
|
task_id="test-task",
|
||||||
|
)
|
||||||
|
|
||||||
|
plugin._apply_detected_asn(123)
|
||||||
|
self.assertIsNone(plugin.metadata.asn)
|
||||||
|
|
||||||
|
doc.hard_delete()
|
||||||
|
|
||||||
|
plugin._apply_detected_asn(123)
|
||||||
|
self.assertEqual(plugin.metadata.asn, 123)
|
||||||
|
|||||||
@@ -1504,7 +1504,6 @@ class BulkEditView(PassUserMixin):
|
|||||||
"merge": None,
|
"merge": None,
|
||||||
"edit_pdf": "checksum",
|
"edit_pdf": "checksum",
|
||||||
"reprocess": "checksum",
|
"reprocess": "checksum",
|
||||||
"remove_password": "checksum",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
permission_classes = (IsAuthenticated,)
|
permission_classes = (IsAuthenticated,)
|
||||||
@@ -1523,7 +1522,6 @@ class BulkEditView(PassUserMixin):
|
|||||||
bulk_edit.split,
|
bulk_edit.split,
|
||||||
bulk_edit.merge,
|
bulk_edit.merge,
|
||||||
bulk_edit.edit_pdf,
|
bulk_edit.edit_pdf,
|
||||||
bulk_edit.remove_password,
|
|
||||||
]:
|
]:
|
||||||
parameters["user"] = user
|
parameters["user"] = user
|
||||||
|
|
||||||
@@ -1552,7 +1550,6 @@ class BulkEditView(PassUserMixin):
|
|||||||
bulk_edit.rotate,
|
bulk_edit.rotate,
|
||||||
bulk_edit.delete_pages,
|
bulk_edit.delete_pages,
|
||||||
bulk_edit.edit_pdf,
|
bulk_edit.edit_pdf,
|
||||||
bulk_edit.remove_password,
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
or (
|
or (
|
||||||
@@ -1569,7 +1566,7 @@ class BulkEditView(PassUserMixin):
|
|||||||
and (
|
and (
|
||||||
method in [bulk_edit.split, bulk_edit.merge]
|
method in [bulk_edit.split, bulk_edit.merge]
|
||||||
or (
|
or (
|
||||||
method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
|
method == bulk_edit.edit_pdf
|
||||||
and not parameters["update_document"]
|
and not parameters["update_document"]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2025-12-24 05:27+0000\n"
|
"POT-Creation-Date: 2025-12-29 14:49+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -1219,35 +1219,35 @@ msgstr ""
|
|||||||
msgid "workflow runs"
|
msgid "workflow runs"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:640
|
#: documents/serialisers.py:642
|
||||||
msgid "Invalid color."
|
msgid "Invalid color."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1826
|
#: documents/serialisers.py:1835
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "File type %(type)s not supported"
|
msgid "File type %(type)s not supported"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1870
|
#: documents/serialisers.py:1879
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field id must be an integer: %(id)s"
|
msgid "Custom field id must be an integer: %(id)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1877
|
#: documents/serialisers.py:1886
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field with id %(id)s does not exist"
|
msgid "Custom field with id %(id)s does not exist"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1894 documents/serialisers.py:1904
|
#: documents/serialisers.py:1903 documents/serialisers.py:1913
|
||||||
msgid ""
|
msgid ""
|
||||||
"Custom fields must be a list of integers or an object mapping ids to values."
|
"Custom fields must be a list of integers or an object mapping ids to values."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1899
|
#: documents/serialisers.py:1908
|
||||||
msgid "Some custom fields don't exist or were specified twice."
|
msgid "Some custom fields don't exist or were specified twice."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2014
|
#: documents/serialisers.py:2023
|
||||||
msgid "Invalid variable detected."
|
msgid "Invalid variable detected."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user