Compare commits

..
Author SHA1 Message Date
shamoon 3508877a33 Update api.md 2026-11-14 12:31:08 -08:00
shamoon fea012d5c0 Apply to the rest of the operations 2026-11-14 12:30:48 -08:00
shamoon e2aa45da4e Fix a couple dispatched renames 2026-11-14 12:21:25 -08:00
shamoon 5ad0773446 Buld this parent task into the model, dispatched. Hook up for reprocess 2026-11-14 12:16:02 -08:00
shamoon 13202f52ad Raise reprocess errors 2026-11-14 11:54:40 -08:00
shamoon 9a9d4284d7 Couple more things for sonar 2026-11-14 11:51:42 -08:00
shamoon 6043dd1c81 Be more careful about how we handle errors wrt open docs 2026-11-14 11:51:42 -08:00
shamoon 058e27e3ef Reprot more accurate notification for tasks with follow-ups 2026-11-14 11:51:42 -08:00
shamoon 6f5a192967 Propagate these errors now so they make it to the task 2026-11-14 11:51:41 -08:00
shamoon bb775a16c1 Move the merge as version heirarchy checks to the bulk edit method 2026-11-14 11:51:41 -08:00
shamoon 6d26d3f5ca Sonar loves a readonly class member 2026-11-14 10:53:49 -08:00
shamoon 2131feac73 Random test cleanup stuff 2026-11-14 09:36:10 -08:00
shamoon e2705768b4 Make the popups more helpful 2026-11-14 09:34:01 -08:00
shamoon ce3c4e0855 Yay make the task list useful 2026-11-14 09:34:01 -08:00
shamoon 3a241e51d9 Better info for bulk tasks 2026-11-14 09:34:01 -08:00
shamoon 020a2c8fc0 Dont show view task on success popups 2026-11-14 09:34:00 -08:00
shamoon b533bfe4b5 We need a tasks notification service 2026-11-14 09:34:00 -08:00
shamoon 813dfe487f We have to make this generic-er 2026-11-14 09:34:00 -08:00
shamoon 557afa6454 Move doc detail actions over 2026-11-14 09:33:59 -08:00
shamoon c72473f7bc Fix delete and undo 2026-11-14 09:33:59 -08:00
shamoon 7d79b544ae use the queued operation pathway across teh frontend 2026-11-14 09:33:58 -08:00
shamoon a9fef2b931 Update the api docs 2026-11-14 09:33:58 -08:00
shamoon adbd7561d4 Tests for the actions 2026-11-14 09:33:58 -08:00
shamoon 9f694cdd2d queue the dedicated document actions 2026-11-14 09:33:57 -08:00
shamoon 7d78716069 and add some 2026-11-14 09:33:57 -08:00
shamoon 9523d158a8 Fix the old tests 2026-11-14 09:33:57 -08:00
shamoon 91316df9b7 Move the other bulk edit actions through the new taks tracking route 2026-11-14 09:33:56 -08:00
shamoon 7e67013c42 Ensure from_webui 2026-11-14 09:33:56 -08:00
shamoon 2a0c1249fa Raise error on trash deletion so it can be tracked 2026-11-14 09:33:56 -08:00
45 changed files with 2552 additions and 437 deletions

No files matched your search

+8 -2
View File
@@ -285,8 +285,14 @@ The API supports various bulk-editing operations which are executed asynchronous
### Documents ### Documents
For bulk operations on documents, use the endpoint `/api/documents/bulk_edit/` which accepts Document bulk operations return `{"result":"OK","task_id":"…"}` when queued.
a json payload of the format: Check `/api/tasks/?task_id={uuid}` for the outcome, including any follow-up processing,
deletion, or recovery. Failed operations may leave partial changes applied.
For operations with child tasks, `result_data` includes `succeeded_count` and
`failed_count` (task counts). Merges report skipped inputs in `failed_document_ids`.
The `/api/documents/bulk_edit/` endpoint accepts:
```json ```json
{ {
@@ -175,7 +175,13 @@
</div> </div>
</td> </td>
<td class="overflow-auto name-col"> <td class="overflow-auto name-col">
<div>{{ taskDisplayName(task) }}</div> @let documentCount = taskDocumentCount(task);
<div>
{{ taskDisplayName(task) }}
@if (documentCount !== null && documentCount > 1) {
<small class="text-muted ms-1" i18n>({documentCount, plural, =1 {1 document} other {{{ documentCount }} documents}})</small>
}
</div>
<div class="small text-muted"> <div class="small text-muted">
@if (taskShowsSeparateTypeLabel(task)) { @if (taskShowsSeparateTypeLabel(task)) {
<span class="me-2">{{ task.task_type_display }}</span> <span class="me-2">{{ task.task_type_display }}</span>
@@ -895,6 +895,117 @@ describe('TasksComponent', () => {
expect(sanityTaskRow.nativeElement.textContent).toContain('System') expect(sanityTaskRow.nativeElement.textContent).toContain('System')
}) })
it.each([
['modify_tags', 12, 'Bulk operation: Update tags', '(12 documents)'],
['run_workflow', 1, 'Run workflow', null],
['delete', 3, 'Bulk operation: Move to trash', '(3 documents)'],
['reprocess', 2, 'Bulk operation: Document reprocessing', '(2 documents)'],
])(
'shows the operation, document count and outcome for %s',
(operation, count, label, summary) => {
const task: PaperlessTask = {
...tasks[4],
task_type:
operation === 'delete'
? PaperlessTaskType.BulkDelete
: PaperlessTaskType.BulkUpdate,
task_type_display:
operation === 'delete' ? 'Bulk Delete' : 'Bulk Update',
trigger_source: PaperlessTaskTriggerSource.WebUI,
trigger_source_display: 'Web UI',
input_data: { operation, document_count: count },
result_data: { operation, document_count: count },
}
component.setSection(TaskSection.Completed)
flushTaskLists(25, 1, [task])
fixture.detectChanges()
const name = fixture.nativeElement.querySelector('tbody .name-col')
expect(name.textContent).toContain(label)
const countLabel = name.querySelector('small.text-muted')
if (summary) {
expect(countLabel.textContent).toContain(summary)
} else {
expect(countLabel).toBeNull()
expect(name.textContent).not.toContain('Bulk operation:')
}
expect(name.textContent).not.toContain('·')
expect(name.textContent).toContain('Web UI')
expect(name.textContent).not.toContain(task.task_type_display)
expect(
name.closest('tr').querySelector('.results-col').textContent
).toContain(
[
'reprocess',
'rotate',
'merge',
'split',
'delete_pages',
'edit_pdf',
'remove_password',
].includes(operation)
? 'Submitted for processing'
: 'Completed'
)
}
)
it('shows operation context while work is pending without reporting completion', () => {
const task = {
...tasks[2],
task_type: PaperlessTaskType.BulkUpdate,
task_type_display: 'Bulk Update',
input_data: { operation: 'reprocess', document_count: 1 },
}
component.setSection(TaskSection.InProgress)
flushTaskLists(25, 1, [task])
fixture.detectChanges()
const name = fixture.nativeElement.querySelector('tbody .name-col')
expect(name.textContent).toContain('Document reprocessing')
expect(name.textContent).not.toContain('Bulk operation:')
expect(name.querySelector('small')).toBeNull()
expect(component.taskResultMessage(task)).toBeNull()
})
it.each([{}, { operation: 'future_operation' }])(
'preserves the fallback for legacy or unknown operation data: %j',
(input_data) => {
const task = {
...tasks[4],
task_type: PaperlessTaskType.BulkUpdate,
task_type_display: 'Bulk Update',
input_data,
result_data: {},
}
expect(component.taskDisplayName(task)).toBe('Bulk Update')
expect(component.taskDocumentCount(task)).toBeNull()
expect(component.taskResultMessage(task)).toBeNull()
}
)
it.each([-1, 1.5, '2', null, undefined])(
'does not display an invalid document count: %s',
(document_count) => {
expect(
component.taskDocumentCount({
...tasks[4],
input_data: { operation: 'modify_tags', document_count },
})
).toBeNull()
}
)
it('retains error details for failed operation tasks', () => {
expect(
component.taskResultMessage({
...tasks[0],
input_data: { operation: 'modify_tags', document_count: 12 },
result_data: { error_message: 'Insufficient permissions' },
})
).toBe('Insufficient permissions')
})
it('should filter tasks by result', () => { it('should filter tasks by result', () => {
component.setSection(TaskSection.NeedsAttention) component.setSection(TaskSection.NeedsAttention)
component.filterTargetID = 1 component.filterTargetID = 1
@@ -33,6 +33,10 @@ import { IfPermissionsDirective } from 'src/app/directives/if-permissions.direct
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { TasksService } from 'src/app/services/tasks.service' import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import {
documentOperationHasFollowUpTasks,
getDocumentOperationLabel,
} from 'src/app/utils/document-operation'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@@ -414,11 +418,30 @@ export class TasksComponent
} }
taskDisplayName(task: PaperlessTask): string { taskDisplayName(task: PaperlessTask): string {
return task.input_data?.filename?.toString() || task.task_type_display const filename = task.input_data?.filename?.toString()
if (filename) return filename
const operation = this.taskOperationLabel(task)
const count = this.taskDocumentCount(task)
if (operation && count !== null && count > 1) {
return $localize`Bulk operation: ${operation}`
}
return operation || task.task_type_display
} }
taskShowsSeparateTypeLabel(task: PaperlessTask): boolean { taskShowsSeparateTypeLabel(task: PaperlessTask): boolean {
return this.taskDisplayName(task) !== task.task_type_display return (
!this.taskOperationLabel(task) &&
this.taskDisplayName(task) !== task.task_type_display
)
}
private taskOperationLabel(task: PaperlessTask): string | null {
return getDocumentOperationLabel(task.input_data?.operation)
}
taskDocumentCount(task: PaperlessTask): number | null {
const count = task.input_data?.document_count
return Number.isInteger(count) && count >= 0 ? count : null
} }
taskResultMessage(task: PaperlessTask): string | null { taskResultMessage(task: PaperlessTask): string | null {
@@ -446,6 +469,15 @@ export class TasksComponent
return errorMessage return errorMessage
} }
if (
task.status === PaperlessTaskStatus.Success &&
this.taskOperationLabel(task)
) {
return documentOperationHasFollowUpTasks(task.input_data?.operation)
? $localize`Submitted for processing`
: $localize`Completed`
}
return null return null
} }
@@ -33,6 +33,7 @@ import { RemoteVersionService } from 'src/app/services/rest/remote-version.servi
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SearchService } from 'src/app/services/rest/search.service' import { SearchService } from 'src/app/services/rest/search.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { TaskNotificationService } from 'src/app/services/task-notification.service'
import { TasksService } from 'src/app/services/tasks.service' import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service' import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
@@ -198,14 +199,14 @@ describe('AppFrameComponent', () => {
fixture.detectChanges() fixture.detectChanges()
}) })
it('refreshes and notifies on completion without client-side task tracking', () => { it('refreshes and updates the task notification on completion', () => {
const list = TestBed.inject(DocumentListViewService) const list = TestBed.inject(DocumentListViewService)
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {}) const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {}) jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
const tasksReload = jest const tasksReload = jest
.spyOn(tasksService, 'reload') .spyOn(tasksService, 'reload')
.mockImplementation(() => {}) .mockImplementation(() => {})
const success = jest.spyOn(toastService, 'showSuccess') const success = jest.spyOn(toastService, 'show')
const refresh = jest const refresh = jest
.spyOn(openDocumentsService, 'refreshDocument') .spyOn(openDocumentsService, 'refreshDocument')
.mockImplementation(() => {}) .mockImplementation(() => {})
@@ -230,7 +231,12 @@ describe('AppFrameComponent', () => {
expect(refresh).toHaveBeenCalledWith(document.id) expect(refresh).toHaveBeenCalledWith(document.id)
expect(maybeRefreshSpy).toHaveBeenCalledTimes(1) expect(maybeRefreshSpy).toHaveBeenCalledTimes(1)
expect(tasksReload).toHaveBeenCalledTimes(1) expect(tasksReload).toHaveBeenCalledTimes(1)
expect(success).toHaveBeenCalledWith('Bulk operation completed.') expect(success).toHaveBeenCalledWith(
expect.objectContaining({
id: 'task-bulk-task',
content: "Operation 'Set permissions' completed.",
})
)
fixture.destroy() fixture.destroy()
bulkEvents.next({ bulkEvents.next({
task_id: 'bulk-task', task_id: 'bulk-task',
@@ -245,14 +251,14 @@ describe('AppFrameComponent', () => {
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {}) const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {}) jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
jest.spyOn(tasksService, 'reload').mockImplementation(() => {}) jest.spyOn(tasksService, 'reload').mockImplementation(() => {})
const error = jest.spyOn(toastService, 'showError') const error = jest.spyOn(toastService, 'show')
const success = jest.spyOn(toastService, 'showSuccess') const success = jest.spyOn(toastService, 'showSuccess')
for (const status of [ for (const status of [
PaperlessTaskStatus.Failure, PaperlessTaskStatus.Failure,
PaperlessTaskStatus.Revoked, PaperlessTaskStatus.Revoked,
]) { ]) {
bulkEvents.next({ bulkEvents.next({
task_id: 'bulk-task', task_id: `${status}-task`,
operation: 'set_permissions', operation: 'set_permissions',
status, status,
error: error:
@@ -262,11 +268,36 @@ describe('AppFrameComponent', () => {
}) })
} }
expect(reload).toHaveBeenCalledTimes(2) expect(reload).toHaveBeenCalledTimes(2)
expect(error).toHaveBeenCalledWith(expect.any(String), 'Could not update')
expect(error).toHaveBeenCalledWith( expect(error).toHaveBeenCalledWith(
'Bulk operation revoked.', expect.objectContaining({
'Task expired before execution.' content:
"Operation 'Set permissions' failed. Some changes may already have been applied.",
error: 'Could not update',
})
) )
expect(error).toHaveBeenCalledWith(
expect.objectContaining({
content: "Operation 'Set permissions' revoked.",
error: 'Task expired before execution.',
})
)
expect(success).not.toHaveBeenCalled()
})
it('passes delete completion to the shared task notification handler', () => {
const success = jest.spyOn(toastService, 'showSuccess')
const notifications = TestBed.inject(TaskNotificationService)
const handleStatus = jest.spyOn(notifications, 'complete')
bulkEvents.next({
task_id: 'delete-1',
operation: 'delete',
status: PaperlessTaskStatus.Success,
})
expect(handleStatus).toHaveBeenCalledWith({
task_id: 'delete-1',
operation: 'delete',
status: PaperlessTaskStatus.Success,
})
expect(success).not.toHaveBeenCalled() expect(success).not.toHaveBeenCalled()
}) })
@@ -57,6 +57,7 @@ import {
} from 'src/app/services/rest/remote-version.service' } from 'src/app/services/rest/remote-version.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { TaskNotificationService } from 'src/app/services/task-notification.service'
import { TasksService } from 'src/app/services/tasks.service' import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service' import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
@@ -115,6 +116,7 @@ export class AppFrameComponent
settingsService = inject(SettingsService) settingsService = inject(SettingsService)
tasksService = inject(TasksService) tasksService = inject(TasksService)
private readonly toastService = inject(ToastService) private readonly toastService = inject(ToastService)
private readonly taskNotifications = inject(TaskNotificationService)
private modalService = inject(NgbModal) private modalService = inject(NgbModal)
permissionsService = inject(PermissionsService) permissionsService = inject(PermissionsService)
private djangoMessagesService = inject(DjangoMessagesService) private djangoMessagesService = inject(DjangoMessagesService)
@@ -182,7 +184,7 @@ export class AppFrameComponent
), ),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(({ status, error }) => { .subscribe((result) => {
this.documentList.reload(null, false) this.documentList.reload(null, false)
this.documentList.reduceSelectionToFilter() this.documentList.reduceSelectionToFilter()
// maybe someday we can optimize to only refresh affected documents // maybe someday we can optimize to only refresh affected documents
@@ -198,16 +200,7 @@ export class AppFrameComponent
) { ) {
this.tasksService.reload() this.tasksService.reload()
} }
if (status === PaperlessTaskStatus.Success) { this.taskNotifications.complete(result)
this.toastService.showSuccess($localize`Bulk operation completed.`)
} else if (status === PaperlessTaskStatus.Revoked) {
this.toastService.showError($localize`Bulk operation revoked.`, error)
} else {
this.toastService.showError(
$localize`Bulk operation failed. Some changes may already have been applied.`,
error
)
}
}) })
this.lastScrollY = window.scrollY this.lastScrollY = window.scrollY
@@ -7,6 +7,7 @@
background-color: var(--pngx-toast-bg); background-color: var(--pngx-toast-bg);
color: var(--bs-body-color); color: var(--bs-body-color);
overflow: hidden; overflow: hidden;
transition: background-color .18s ease, border-color .18s ease;
&.toast-success { &.toast-success {
--pngx-toast-accent: var(--pngx-toast-success); --pngx-toast-accent: var(--pngx-toast-success);
@@ -23,6 +24,13 @@
.toast-icon { .toast-icon {
color: var(--pngx-toast-accent); color: var(--pngx-toast-accent);
transition: color .18s ease;
}
@media (prefers-reduced-motion: reduce) {
.toast, .toast-icon {
transition: none;
}
} }
.toast-content { .toast-content {
@@ -65,6 +65,36 @@ describe('ToastComponent', () => {
expect(component.delayRemaining()).toEqual(3000) expect(component.delayRemaining()).toEqual(3000)
}) })
it('updates its color and icon in place and restarts the countdown', () => {
jest.useFakeTimers()
fixture.componentRef.setInput('toast', {
id: 'task-1',
content: 'Queued',
delay: 5000,
})
fixture.detectChanges()
component.onShown(component.toast)
jest.advanceTimersByTime(4500)
const element = fixture.nativeElement.querySelector('ngb-toast')
fixture.componentRef.setInput('toast', {
id: 'task-1',
content: 'Completed',
delay: 5000,
level: ToastLevel.Success,
})
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('ngb-toast')).toBe(element)
expect(element.classList).toContain('toast-success')
expect(element.querySelector('[name="check-circle"]')).not.toBeNull()
expect(component.delayRemaining()).toBe(5000)
const hide = jest.spyOn(component.ngbToast, 'hide')
jest.advanceTimersByTime(1000)
expect(hide).not.toHaveBeenCalled()
jest.advanceTimersByTime(4200)
expect(hide).toHaveBeenCalled()
jest.useRealTimers()
})
it('should hide the toast when the countdown ends', () => { it('should hide the toast when the countdown ends', () => {
jest.useFakeTimers() jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 } const toast = { content: 'Info', delay: 3000 }
@@ -5,8 +5,10 @@ import {
DestroyRef, DestroyRef,
EventEmitter, EventEmitter,
Input, Input,
OnChanges,
OnInit, OnInit,
Output, Output,
SimpleChanges,
ViewChild, ViewChild,
inject, inject,
signal, signal,
@@ -34,10 +36,10 @@ import { Toast, ToastLevel } from 'src/app/services/toast.service'
styleUrl: './toast.component.scss', styleUrl: './toast.component.scss',
providers: [DatePipe, CustomDatePipe], providers: [DatePipe, CustomDatePipe],
}) })
export class ToastComponent implements OnInit { export class ToastComponent implements OnInit, OnChanges {
private clipboard = inject(Clipboard) private readonly clipboard = inject(Clipboard)
private customDatePipe = inject(CustomDatePipe) private readonly customDatePipe = inject(CustomDatePipe)
private destroyRef = inject(DestroyRef) private readonly destroyRef = inject(DestroyRef)
readonly copied = signal(false) readonly copied = signal(false)
readonly ToastLevel = ToastLevel readonly ToastLevel = ToastLevel
readonly delayRemaining = signal<number>(null) readonly delayRemaining = signal<number>(null)
@@ -69,6 +71,12 @@ export class ToastComponent implements OnInit {
if (this.autohide) this.delayRemaining.set(this.toast.delay) if (this.autohide) this.delayRemaining.set(this.toast.delay)
} }
ngOnChanges(changes: SimpleChanges): void {
if (changes.toast && !changes.toast.firstChange && this.autohide) {
this.onShown(this.toast)
}
}
get relativeTimestamp(): string { get relativeTimestamp(): string {
return this.customDatePipe.transform(this.toast.timestamp, 'relative') return this.customDatePipe.transform(this.toast.timestamp, 'relative')
} }
@@ -68,9 +68,9 @@ describe('ToastsComponent', () => {
toasts.forEach((t) => toastSubject.next(t)) toasts.forEach((t) => toastSubject.next(t))
expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1']) expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1'])
// showing a visible toast again moves it to the top without duplicating // Updating a visible toast preserves its position without duplicating it.
toastSubject.next(toasts[1]) toastSubject.next(toasts[1])
expect(component.toasts().map((t) => t.id)).toEqual(['1', '3', '2']) expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1'])
}) })
it('should remove a popup when it hides without closing it', () => { it('should remove a popup when it hides without closing it', () => {
@@ -32,10 +32,11 @@ export class ToastsComponent {
return return
} }
this.toasts.update((toasts) => this.toasts.update((toasts) =>
[toast, ...toasts.filter((t) => t.id !== toast.id)].slice( toasts.some((existing) => existing.id === toast.id)
0, ? toasts.map((existing) =>
MAX_VISIBLE_TOASTS existing.id === toast.id ? toast : existing
) )
: [toast, ...toasts].slice(0, MAX_VISIBLE_TOASTS)
) )
}) })
} }
@@ -1013,30 +1013,45 @@ describe('DocumentDetailComponent', () => {
]) ])
}) })
it('should support reprocess, confirm and close modal after started', () => { it('should queue reprocess, show the task link and close the modal', () => {
jest.useFakeTimers()
initNormally() initNormally()
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
reprocessSpy.mockReturnValue(of(true)) reprocessSpy.mockReturnValue(of({ result: 'OK', task_id: 'reprocess-1' }))
let openModal: NgbModalRef let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const modalSpy = jest.spyOn(modalService, 'open') const modalSpy = jest.spyOn(modalService, 'open')
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'show')
component.reprocess() component.reprocess()
const modalCloseSpy = jest.spyOn(openModal, 'close') const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next() openModal.componentInstance.confirmClicked.next()
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false) expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false)
expect(modalSpy).toHaveBeenCalled() expect(modalSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled() expect(toastSpy).not.toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled()
jest.advanceTimersByTime(400)
expect(toastSpy).toHaveBeenCalledWith(
expect.objectContaining({
content: 'Document reprocessing queued.',
actionName: 'View task',
})
)
toastSpy.mock.calls.at(-1)[0].action()
expect(router.navigate).toHaveBeenCalledWith(['/tasks'], {
queryParams: { task_id: 'reprocess-1' },
})
expect(modalCloseSpy).toHaveBeenCalled()
jest.useRealTimers()
}) })
it('should run a workflow on the document, confirm and close modal', () => { it('should run a workflow on the document, confirm and close modal', () => {
jest.useFakeTimers()
initNormally() initNormally()
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit') const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
bulkEditSpy.mockReturnValue(of(true)) bulkEditSpy.mockReturnValue(of({ result: 'OK', task_id: 'workflow-1' }))
let openModal: NgbModalRef let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'show')
component.runWorkflow() component.runWorkflow()
expect(openModal.componentInstance.singleDocument).toBe(true) expect(openModal.componentInstance.singleDocument).toBe(true)
expect(openModal.componentInstance.force).toBe(true) expect(openModal.componentInstance.force).toBe(true)
@@ -1048,14 +1063,18 @@ describe('DocumentDetailComponent', () => {
'run_workflow', 'run_workflow',
{ workflow: 5, filter_documents: false } { workflow: 5, filter_documents: false }
) )
expect(toastSpy).toHaveBeenCalled() jest.advanceTimersByTime(400)
expect(toastSpy).toHaveBeenCalledWith(
expect.objectContaining({ content: 'Workflow queued.' })
)
expect(modalCloseSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled()
jest.useRealTimers()
}) })
it('should apply workflow filters when the single-document checkbox is cleared', () => { it('should apply workflow filters when the single-document checkbox is cleared', () => {
initNormally() initNormally()
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit') const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
bulkEditSpy.mockReturnValue(of(true)) bulkEditSpy.mockReturnValue(of({ result: 'OK', task_id: 'workflow-2' }))
let openModal: NgbModalRef let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
component.runWorkflow() component.runWorkflow()
@@ -1072,7 +1091,7 @@ describe('DocumentDetailComponent', () => {
it('should pass remote OCR choice when reprocessing', () => { it('should pass remote OCR choice when reprocessing', () => {
initNormally() initNormally()
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
reprocessSpy.mockReturnValue(of(true)) reprocessSpy.mockReturnValue(of({ result: 'OK', task_id: 'reprocess-2' }))
let openModal: NgbModalRef let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
component.reprocess() component.reprocess()
@@ -2001,10 +2020,12 @@ describe('DocumentDetailComponent', () => {
}) })
it('should support pdf editor, handle error', () => { it('should support pdf editor, handle error', () => {
jest.useFakeTimers()
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0])) modalService.activeInstances.subscribe((m) => (modal = m[0]))
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument') const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
const errorSpy = jest.spyOn(toastService, 'showError') const errorSpy = jest.spyOn(toastService, 'showError')
const queuedToastSpy = jest.spyOn(toastService, 'show')
initNormally() initNormally()
component.selectedVersionId.set(10) component.selectedVersionId.set(10)
component.editPdf() component.editPdf()
@@ -2023,6 +2044,7 @@ describe('DocumentDetailComponent', () => {
update_document: false, update_document: false,
include_metadata: true, include_metadata: true,
source_mode: 'explicit_selection', source_mode: 'explicit_selection',
from_webui: true,
}) })
req.error(new ErrorEvent('failed')) req.error(new ErrorEvent('failed'))
expect(errorSpy).toHaveBeenCalled() expect(errorSpy).toHaveBeenCalled()
@@ -2035,13 +2057,21 @@ describe('DocumentDetailComponent', () => {
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/edit_pdf/` `${environment.apiBaseUrl}documents/edit_pdf/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'edit-pdf-1' })
expect(closeSpy).toHaveBeenCalled() jest.advanceTimersByTime(400)
expect(closeSpy).not.toHaveBeenCalled()
expect(queuedToastSpy).toHaveBeenCalledWith(
expect.objectContaining({ content: 'PDF edit queued.' })
)
jest.useRealTimers()
}) })
it('should support removing password protection from pdfs', () => { it('should support removing password protection from pdfs', () => {
jest.useFakeTimers()
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0])) modalService.activeInstances.subscribe((m) => (modal = m[0]))
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
const queuedToastSpy = jest.spyOn(toastService, 'show')
initNormally() initNormally()
component.selectedVersionId.set(10) component.selectedVersionId.set(10)
component.password = 'secret' component.password = 'secret'
@@ -2062,8 +2092,15 @@ describe('DocumentDetailComponent', () => {
include_metadata: false, include_metadata: false,
delete_original: true, delete_original: true,
source_mode: 'explicit_selection', source_mode: 'explicit_selection',
from_webui: true,
}) })
req.flush(true) req.flush({ result: 'OK', task_id: 'remove-password-1' })
jest.advanceTimersByTime(400)
expect(closeSpy).not.toHaveBeenCalled()
expect(queuedToastSpy).toHaveBeenCalledWith(
expect.objectContaining({ content: 'Password removal queued.' })
)
jest.useRealTimers()
}) })
it('should require the current password before removing it', () => { it('should require the current password before removing it', () => {
@@ -2115,9 +2152,9 @@ describe('DocumentDetailComponent', () => {
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/remove_password/` `${environment.apiBaseUrl}documents/remove_password/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'remove-password-2' })
expect(refreshSpy).toHaveBeenCalledWith(doc.id) expect(refreshSpy).not.toHaveBeenCalled()
}) })
it('should support keyboard shortcuts', () => { it('should support keyboard shortcuts', () => {
@@ -97,6 +97,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service' import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { TaskNotificationService } from 'src/app/services/task-notification.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service' import { TrashService } from 'src/app/services/trash.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service' import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
@@ -226,6 +227,7 @@ export class DocumentDetailComponent
private documentListViewService = inject(DocumentListViewService) private documentListViewService = inject(DocumentListViewService)
private documentTitlePipe = inject(DocumentTitlePipe) private documentTitlePipe = inject(DocumentTitlePipe)
private toastService = inject(ToastService) private toastService = inject(ToastService)
private taskNotifications = inject(TaskNotificationService)
private trashService = inject(TrashService) private trashService = inject(TrashService)
private settings = inject(SettingsService) private settings = inject(SettingsService)
private storagePathService = inject(StoragePathService) private storagePathService = inject(StoragePathService)
@@ -1558,9 +1560,11 @@ export class DocumentDetailComponent
modal.componentInstance.remoteOcr modal.componentInstance.remoteOcr
) )
.subscribe({ .subscribe({
next: () => { next: ({ task_id }) => {
this.toastService.showInfo( this.taskNotifications.showQueued(
$localize`Reprocess operation for "${this.document().title}" will begin in the background.` task_id,
$localize`Document reprocessing queued.`,
{ content: $localize`Document submitted for reprocessing.` }
) )
if (modal) { if (modal) {
modal.close() modal.close()
@@ -1597,9 +1601,13 @@ export class DocumentDetailComponent
filter_documents: !modal.componentInstance.force, filter_documents: !modal.componentInstance.force,
}) })
.subscribe({ .subscribe({
next: () => { next: ({ task_id }) => {
this.toastService.showInfo( this.taskNotifications.showQueued(
$localize`Workflow will run on "${this.document().title}" in the background.` task_id,
$localize`Workflow queued.`,
{
content: $localize`Workflow completed.`,
}
) )
modal.close() modal.close()
}, },
@@ -1989,14 +1997,15 @@ export class DocumentDetailComponent
}) })
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: () => { next: ({ task_id }) => {
this.toastService.showInfo( this.taskNotifications.showQueued(
$localize`PDF edit operation for "${this.document().title}" will begin in the background.` task_id,
$localize`PDF edit queued.`,
{
content: $localize`PDF edit submitted for processing.`,
}
) )
modal.close() modal.close()
if (modal.componentInstance.deleteOriginal) {
this.openDocumentService.closeDocument(this.document())
}
}, },
error: (error) => { error: (error) => {
if (modal) { if (modal) {
@@ -2046,17 +2055,16 @@ export class DocumentDetailComponent
}) })
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: () => { next: ({ task_id }) => {
this.toastService.showInfo( this.taskNotifications.showQueued(
$localize`Password removal operation for "${this.document().title}" will begin in the background.` task_id,
$localize`Password removal queued.`,
{
content: $localize`Password removal submitted for processing.`,
}
) )
this.networkActive.set(false) this.networkActive.set(false)
modal.close() modal.close()
if (!dialog.updateDocument && dialog.deleteOriginal) {
this.openDocumentService.closeDocument(this.document())
} else if (dialog.updateDocument) {
this.openDocumentService.refreshDocument(this.documentId())
}
}, },
error: (error) => { error: (error) => {
dialog.buttonsEnabled.set(true) dialog.buttonsEnabled.set(true)
@@ -2,6 +2,7 @@ import { DatePipe } from '@angular/common'
import { SimpleChange, signal } from '@angular/core' import { SimpleChange, signal } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
import { Router } from '@angular/router'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject, of, throwError } from 'rxjs' import { Subject, of, throwError } from 'rxjs'
import { DocumentVersionInfo } from 'src/app/data/document' import { DocumentVersionInfo } from 'src/app/data/document'
@@ -33,7 +34,7 @@ describe('DocumentVersionDropdownComponent', () => {
> >
> >
let toastService: jest.Mocked< let toastService: jest.Mocked<
Pick<ToastService, 'showError' | 'showInfo' | 'showSuccess'> Pick<ToastService, 'show' | 'showError' | 'showInfo' | 'showSuccess'>
> >
let finished$: Subject<{ taskId: string }> let finished$: Subject<{ taskId: string }>
let failed$: Subject<{ taskId: string; message?: string }> let failed$: Subject<{ taskId: string; message?: string }>
@@ -54,6 +55,7 @@ describe('DocumentVersionDropdownComponent', () => {
} }
modalService = { open: jest.fn() } modalService = { open: jest.fn() }
toastService = { toastService = {
show: jest.fn(),
showError: jest.fn(), showError: jest.fn(),
showInfo: jest.fn(), showInfo: jest.fn(),
showSuccess: jest.fn(), showSuccess: jest.fn(),
@@ -88,6 +90,10 @@ describe('DocumentVersionDropdownComponent', () => {
provide: PermissionsService, provide: PermissionsService,
useValue: permissionsService, useValue: permissionsService,
}, },
{
provide: Router,
useValue: { navigate: jest.fn() },
},
{ {
provide: WebsocketStatusService, provide: WebsocketStatusService,
useValue: { useValue: {
@@ -377,7 +383,8 @@ describe('DocumentVersionDropdownComponent', () => {
expect(component.versionLabelDraft).toEqual('') expect(component.versionLabelDraft).toEqual('')
}) })
it('addExistingDocumentAsVersion should merge with a label and refresh versions', () => { it('addExistingDocumentAsVersion should queue with a label and wait to refresh versions', () => {
jest.useFakeTimers()
const confirmClicked = new Subject<number>() const confirmClicked = new Subject<number>()
const modal = { const modal = {
componentInstance: { componentInstance: {
@@ -388,14 +395,9 @@ describe('DocumentVersionDropdownComponent', () => {
close: jest.fn(), close: jest.fn(),
} }
modalService.open.mockReturnValue(modal as any) modalService.open.mockReturnValue(modal as any)
documentService.mergeDocumentsAsVersions.mockReturnValue(of({} as any)) documentService.mergeDocumentsAsVersions.mockReturnValue(
// Newest first, as the API returns them. The merged document has a lower id of({ result: 'OK', task_id: 'versions-1' })
// than the root, which is the whole point of merging an existing document. )
const versions: DocumentVersionInfo[] = [
{ id: 2, is_root: false, checksum: 'cccc' },
{ id: 3, is_root: true, checksum: 'aaaa' },
]
documentService.getVersions.mockReturnValue(of({ id: 3, versions } as any))
component.newVersionLabel = ' Imported ' component.newVersionLabel = ' Imported '
const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit') const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit')
const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit') const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit')
@@ -410,11 +412,18 @@ describe('DocumentVersionDropdownComponent', () => {
'Imported' 'Imported'
) )
expect(documentService.updateVersionLabel).not.toHaveBeenCalled() expect(documentService.updateVersionLabel).not.toHaveBeenCalled()
expect(documentService.getVersions).toHaveBeenCalledWith(3) expect(documentService.getVersions).not.toHaveBeenCalled()
expect(versionsEmitSpy).toHaveBeenCalledWith(versions) expect(versionsEmitSpy).not.toHaveBeenCalled()
expect(selectedEmitSpy).toHaveBeenCalledWith(2) expect(selectedEmitSpy).not.toHaveBeenCalled()
expect(component.newVersionLabel).toEqual('') expect(component.newVersionLabel).toEqual('')
expect(modal.close).toHaveBeenCalled() expect(modal.close).toHaveBeenCalled()
expect(toastService.showSuccess).toHaveBeenCalled() jest.advanceTimersByTime(400)
expect(toastService.show).toHaveBeenCalledWith(
expect.objectContaining({
content: 'Existing document queued to be added as a version.',
actionName: 'View task',
})
)
jest.useRealTimers()
}) })
}) })
@@ -28,6 +28,7 @@ import { DocumentVersionInfo } from 'src/app/data/document'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { TaskNotificationService } from 'src/app/services/task-notification.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { import {
UploadState, UploadState,
@@ -77,6 +78,7 @@ export class DocumentVersionDropdownComponent
private readonly toastService = inject(ToastService) private readonly toastService = inject(ToastService)
private readonly websocketStatusService = inject(WebsocketStatusService) private readonly websocketStatusService = inject(WebsocketStatusService)
private readonly modalService = inject(NgbModal) private readonly modalService = inject(NgbModal)
private readonly taskNotifications = inject(TaskNotificationService)
private readonly destroy$ = new Subject<void>() private readonly destroy$ = new Subject<void>()
private readonly documentChange$ = new Subject<void>() private readonly documentChange$ = new Subject<void>()
@@ -305,23 +307,19 @@ export class DocumentVersionDropdownComponent
versionLabel versionLabel
) )
.pipe( .pipe(
switchMap(() => this.documentsService.getVersions(this.documentId)),
first(), first(),
finalize(() => dialog.buttonsEnabled.set(true)), finalize(() => dialog.buttonsEnabled.set(true)),
takeUntil(this.destroy$), takeUntil(this.destroy$),
takeUntil(this.documentChange$) takeUntil(this.documentChange$)
) )
.subscribe({ .subscribe({
next: (document) => { next: ({ task_id }) => {
if (document?.versions?.length) {
this.versionsUpdated.emit(document.versions)
// The API returns versions newest first
this.versionSelected.emit(document.versions[0].id)
}
this.newVersionLabel = '' this.newVersionLabel = ''
modal.close() modal.close()
this.toastService.showSuccess( this.taskNotifications.showQueued(
$localize`Existing document added as a version.` task_id,
$localize`Existing document queued to be added as a version.`,
{ content: $localize`Existing document added as a version.` }
) )
}, },
error: (error) => { error: (error) => {
@@ -14,7 +14,8 @@ import { Correspondent } from 'src/app/data/correspondent'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
import { FILTER_TITLE } from 'src/app/data/filter-rule-type' import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
import { Results } from 'src/app/data/results' import { PaperlessTaskStatus } from 'src/app/data/paperless-task'
import { Results, SelectionData } from 'src/app/data/results'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger' import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
@@ -28,10 +29,7 @@ import {
import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { import { DocumentService } from 'src/app/services/rest/document.service'
DocumentService,
SelectionData,
} from 'src/app/services/rest/document.service'
import { GroupService } from 'src/app/services/rest/group.service' import { GroupService } from 'src/app/services/rest/group.service'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service' import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
@@ -39,6 +37,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service' import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { TaskNotificationService } from 'src/app/services/task-notification.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service' import { TrashService } from 'src/app/services/trash.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
@@ -1267,14 +1266,15 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/` `${environment.apiBaseUrl}documents/delete/`
) )
const notifySpy = jest const trackSpy = jest
.spyOn(trashService, 'notifyMovedToTrash') .spyOn(trashService, 'trackMovedToTrash')
.mockImplementation(() => {}) .mockImplementation(() => {})
req.flush(true) req.flush({ result: 'OK', task_id: 'delete-1' })
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
from_webui: true,
}) })
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [3, 4]) expect(trackSpy).toHaveBeenCalledWith('delete-1', 2, [3, 4])
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
@@ -1291,9 +1291,9 @@ describe('BulkEditorComponent', () => {
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/` `${environment.apiBaseUrl}documents/delete/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'delete-2' })
// ids aren't known when all matching documents are selected, so no undo // ids aren't known when all matching documents are selected, so no undo
expect(notifySpy).toHaveBeenLastCalledWith(expect.any(String), null) expect(trackSpy).toHaveBeenLastCalledWith('delete-2', 0, null)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
@@ -1373,10 +1373,11 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/reprocess/` `${environment.apiBaseUrl}documents/reprocess/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'reprocess-1' })
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
remote_ocr: false, remote_ocr: false,
from_webui: true,
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
@@ -1387,6 +1388,7 @@ describe('BulkEditorComponent', () => {
}) })
it('should support rotate', () => { it('should support rotate', () => {
const toast = jest.spyOn(toastService, 'show')
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0])) modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
@@ -1407,11 +1409,20 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/rotate/` `${environment.apiBaseUrl}documents/rotate/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'rotate-1' })
TestBed.inject(TaskNotificationService).complete({
task_id: 'rotate-1',
operation: 'rotate',
status: PaperlessTaskStatus.Success,
})
expect(toast.mock.calls.at(-1)[0].content).toBe(
"Bulk operation 'Rotate documents' submitted for processing."
)
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
degrees: 90, degrees: 90,
source_mode: 'latest_version', source_mode: 'latest_version',
from_webui: true,
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
@@ -1449,10 +1460,11 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/merge/` `${environment.apiBaseUrl}documents/merge/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'merge-1' })
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
metadata_document_id: 3, metadata_document_id: 3,
from_webui: true,
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
@@ -1467,11 +1479,12 @@ describe('BulkEditorComponent', () => {
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/merge/` `${environment.apiBaseUrl}documents/merge/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'merge-2' })
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
metadata_document_id: 3, metadata_document_id: 3,
delete_originals: true, delete_originals: true,
from_webui: true,
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
@@ -1488,11 +1501,12 @@ describe('BulkEditorComponent', () => {
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/merge/` `${environment.apiBaseUrl}documents/merge/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'merge-3' })
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
metadata_document_id: 3, metadata_document_id: 3,
archive_fallback: true, archive_fallback: true,
from_webui: true,
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
@@ -1504,6 +1518,7 @@ describe('BulkEditorComponent', () => {
}) })
it('should support merging documents as versions', () => { it('should support merging documents as versions', () => {
jest.useFakeTimers()
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0])) modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
@@ -1531,8 +1546,8 @@ describe('BulkEditorComponent', () => {
.mockReturnValue(true) .mockReturnValue(true)
const mergeAsVersionsSpy = jest const mergeAsVersionsSpy = jest
.spyOn(documentService, 'mergeDocumentsAsVersions') .spyOn(documentService, 'mergeDocumentsAsVersions')
.mockReturnValue(of(true)) .mockReturnValue(of({ result: 'OK', task_id: 'versions-1' }))
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess') const toastSpy = jest.spyOn(toastService, 'show')
fixture.detectChanges() fixture.detectChanges()
component.mergeSelectedAsVersions() component.mergeSelectedAsVersions()
@@ -1548,9 +1563,21 @@ describe('BulkEditorComponent', () => {
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) )
expect(documentListViewService.selected.size).toEqual(0) expect(documentListViewService.selected.size).toEqual(0)
expect(toastSuccessSpy).toHaveBeenCalledWith( jest.advanceTimersByTime(400)
'Documents merged as versions.' expect(toastSpy).toHaveBeenCalledWith(
expect.objectContaining({
content: "Bulk operation 'Merge as versions' queued.",
})
) )
TestBed.inject(TaskNotificationService).complete({
task_id: 'versions-1',
operation: 'merge_as_versions',
status: PaperlessTaskStatus.Success,
})
expect(toastSpy.mock.calls.at(-1)[0].content).toBe(
"Bulk operation 'Merge as versions' completed."
)
jest.useRealTimers()
}) })
it('should not report success when merging documents as versions fails', () => { it('should not report success when merging documents as versions fails', () => {
@@ -1641,12 +1668,22 @@ describe('BulkEditorComponent', () => {
) )
}) })
it.each([true, false])( it.each<[boolean, number]>([
'shows queued feedback and gates the task link on permission (%s)', [true, 2],
(canViewTasks) => { [false, 2],
[true, 1],
])(
'names queued work and gates the task link (permission %s, documents %s)',
(canViewTasks, count) => {
jest.useFakeTimers()
jest jest
.spyOn(permissionsService, 'currentUserCan') .spyOn(permissionsService, 'currentUserCan')
.mockReturnValue(canViewTasks) .mockReturnValue(canViewTasks)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(
new Set(Array.from({ length: count }, (_, index) => index + 3))
)
const queued = jest.spyOn(toastService, 'show') const queued = jest.spyOn(toastService, 'show')
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((instances) => { modalService.activeInstances.subscribe((instances) => {
@@ -1664,8 +1701,11 @@ describe('BulkEditorComponent', () => {
) )
req.flush({ result: 'OK', task_id: 'queued-operation' }) req.flush({ result: 'OK', task_id: 'queued-operation' })
expect(close).toHaveBeenCalledTimes(1) expect(close).toHaveBeenCalledTimes(1)
expect(queued).not.toHaveBeenCalled()
jest.advanceTimersByTime(400)
const notification = queued.mock.calls.at(-1)[0] const notification = queued.mock.calls.at(-1)[0]
expect(notification.content).toBe('Bulk operation queued.') const prefix = count > 1 ? 'Bulk operation' : 'Operation'
expect(notification.content).toBe(`${prefix} \'Set permissions\' queued.`)
if (canViewTasks) { if (canViewTasks) {
expect(notification.actionName).toBe('View task') expect(notification.actionName).toBe('View task')
notification.action() notification.action()
@@ -1679,6 +1719,15 @@ describe('BulkEditorComponent', () => {
expect(reload).not.toHaveBeenCalled() expect(reload).not.toHaveBeenCalled()
expect(documentListViewService.selected.size).toBe(0) expect(documentListViewService.selected.size).toBe(0)
expect(documentListViewService.allSelected).toBe(false) expect(documentListViewService.allSelected).toBe(false)
TestBed.inject(TaskNotificationService).complete({
task_id: 'queued-operation',
operation: 'set_permissions',
status: PaperlessTaskStatus.Success,
})
const completed = queued.mock.calls.at(-1)[0]
expect(completed.content).toBe(`${prefix} \'Set permissions\' completed.`)
expect(completed.action).toBeUndefined()
jest.useRealTimers()
} }
) )
@@ -1759,7 +1808,6 @@ describe('BulkEditorComponent', () => {
{ id: 2, name: 'Tag 2' }, { id: 2, name: 'Tag 2' },
], ],
count: 2, count: 2,
all: [1, 2],
} }
const modalInstance = { const modalInstance = {
@@ -1802,7 +1850,6 @@ describe('BulkEditorComponent', () => {
{ id: 2, name: 'Correspondent 2' }, { id: 2, name: 'Correspondent 2' },
], ],
count: 2, count: 2,
all: [1, 2],
} }
const modalInstance = { const modalInstance = {
@@ -1851,7 +1898,6 @@ describe('BulkEditorComponent', () => {
{ id: 2, name: 'Document Type 2' }, { id: 2, name: 'Document Type 2' },
], ],
count: 2, count: 2,
all: [1, 2],
} }
const modalInstance = { const modalInstance = {
@@ -1897,7 +1943,6 @@ describe('BulkEditorComponent', () => {
{ id: 2, name: 'Storage Path 2' }, { id: 2, name: 'Storage Path 2' },
], ],
count: 2, count: 2,
all: [1, 2],
} }
const modalInstance = { const modalInstance = {
@@ -1951,7 +1996,6 @@ describe('BulkEditorComponent', () => {
}, },
], ],
count: 2, count: 2,
all: [1, 2],
} }
const modalInstance = { const modalInstance = {
@@ -19,25 +19,18 @@ import {
NgbModalRef, NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import { import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
first,
map,
Observable,
Subject,
switchMap,
takeUntil,
tap,
} from 'rxjs'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component' import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { BulkOperationResponse } from 'src/app/data/bulk-operation'
import { CustomField } from 'src/app/data/custom-field' import { CustomField } from 'src/app/data/custom-field'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
import { SelectionDataItem } from 'src/app/data/results' import { SelectionDataItem } from 'src/app/data/results'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { Workflow } from 'src/app/data/workflow' import { Workflow } from 'src/app/data/workflow'
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { import {
PermissionAction, PermissionAction,
PermissionsService, PermissionsService,
@@ -52,14 +45,18 @@ import {
DocumentService, DocumentService,
MergeDocumentsRequest, MergeDocumentsRequest,
} from 'src/app/services/rest/document.service' } from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service' import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { TaskNotificationService } from 'src/app/services/task-notification.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service' import { TrashService } from 'src/app/services/trash.service'
import {
documentOperationHasFollowUpTasks,
getDocumentOperationLabel,
} from 'src/app/utils/document-operation'
import { flattenTags } from 'src/app/utils/flatten-tags' import { flattenTags } from 'src/app/utils/flatten-tags'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params' import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component' import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
@@ -84,7 +81,6 @@ import { PermissionsDialogComponent } from '../../common/permissions-dialog/perm
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component' import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component' import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
@Component({ @Component({
selector: 'pngx-bulk-editor', selector: 'pngx-bulk-editor',
@@ -110,13 +106,12 @@ export class BulkEditorComponent
private documentService = inject(DocumentService) private documentService = inject(DocumentService)
private trashService = inject(TrashService) private trashService = inject(TrashService)
private modalService = inject(NgbModal) private modalService = inject(NgbModal)
private openDocumentService = inject(OpenDocumentsService)
private settings = inject(SettingsService) private settings = inject(SettingsService)
private toastService = inject(ToastService) private toastService = inject(ToastService)
private taskNotifications = inject(TaskNotificationService)
private storagePathService = inject(StoragePathService) private storagePathService = inject(StoragePathService)
private customFieldService = inject(CustomFieldsService) private customFieldService = inject(CustomFieldsService)
public readonly permissionService = inject(PermissionsService) public readonly permissionService = inject(PermissionsService)
private savedViewService = inject(SavedViewService)
private readonly shareLinkBundleService = inject(ShareLinkBundleService) private readonly shareLinkBundleService = inject(ShareLinkBundleService)
private readonly workflowService = inject(WorkflowService) private readonly workflowService = inject(WorkflowService)
private readonly router = inject(Router) private readonly router = inject(Router)
@@ -357,70 +352,62 @@ export class BulkEditorComponent
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args) .bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: ({ task_id }) => this.handleOperationQueued(modal, task_id), next: ({ task_id }) =>
this.handleOperationQueued(modal, task_id, method),
error: (error) => this.handleOperationError(modal, error), error: (error) => this.handleOperationError(modal, error),
}) })
} }
private handleOperationQueued(modal: NgbModalRef, taskId: string) { private handleOperationQueued(
modal: NgbModalRef,
taskId: string,
operation: string,
onQueued?: (taskId: string) => void
) {
const bulk = this.getSelectionSize() > 1
modal?.close() modal?.close()
this.list.selectNone() this.list.selectNone()
const canViewTasks = this.permissionService.currentUserCan( if (onQueued) {
PermissionAction.View, onQueued(taskId)
PermissionType.PaperlessTask } else {
const label = getDocumentOperationLabel(operation)
const submittedMessage = bulk
? $localize`Bulk operation '${label}' submitted for processing.`
: $localize`Operation '${label}' submitted for processing.`
const completedMessage = bulk
? $localize`Bulk operation '${label}' completed.`
: $localize`Operation '${label}' completed.`
this.taskNotifications.showQueued(
taskId,
bulk
? $localize`Bulk operation '${label}' queued.`
: $localize`Operation '${label}' queued.`,
{
content: documentOperationHasFollowUpTasks(operation)
? submittedMessage
: completedMessage,
}
) )
this.toastService.show({
content: $localize`Bulk operation queued.`,
delay: 5000,
...(canViewTasks
? {
actionName: $localize`View task`,
action: () =>
this.router.navigate(['/tasks'], {
queryParams: { task_id: taskId },
}),
} }
: {}),
})
} }
private executeDocumentAction( private executeDocumentAction(
modal: NgbModalRef, modal: NgbModalRef,
request: Observable<any>, operation: string,
options: { clearSelection?: boolean; successMessage?: string } = {} request: Observable<BulkOperationResponse>,
onQueued?: (taskId: string) => void
) { ) {
if (modal) { if (modal) {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled.set(false)
} }
request.pipe(first()).subscribe({ request.pipe(first()).subscribe({
next: () => { next: ({ task_id }) => {
this.handleOperationSuccess(modal, options.clearSelection ?? false) this.handleOperationQueued(modal, task_id, operation, onQueued)
if (options.successMessage) {
this.toastService.showSuccess(options.successMessage)
}
}, },
error: (error) => this.handleOperationError(modal, error), error: (error) => this.handleOperationError(modal, error),
}) })
} }
private handleOperationSuccess(
modal: NgbModalRef,
clearSelection: boolean = false
) {
if (clearSelection) {
this.list.selected.clear()
}
this.list.reload()
this.list.reduceSelectionToFilter()
this.list.selected.forEach((id) => {
this.openDocumentService.refreshDocument(id)
})
this.savedViewService.maybeRefreshDocumentCounts()
if (modal) {
modal.close()
}
}
private handleOperationError(modal: NgbModalRef, error: any) { private handleOperationError(modal: NgbModalRef, error: any) {
if (modal) { if (modal) {
modal.componentInstance.buttonsEnabled.set(true) modal.componentInstance.buttonsEnabled.set(true)
@@ -988,16 +975,9 @@ export class BulkEditorComponent
const count = this.getSelectionSize() const count = this.getSelectionSize()
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
this.documentService 'delete',
.deleteDocuments(this.getSelectionQuery()) this.documentService.deleteDocuments(this.getSelectionQuery()),
.pipe( (taskId) => this.trashService.trackMovedToTrash(taskId, count, documents)
tap(() =>
this.trashService.notifyMovedToTrash(
$localize`Moved ${count} document(s) to the trash`,
documents
)
)
)
) )
} }
@@ -1038,6 +1018,7 @@ export class BulkEditorComponent
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled.set(false)
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
'reprocess',
this.documentService.reprocessDocuments( this.documentService.reprocessDocuments(
this.getSelectionQuery(), this.getSelectionQuery(),
modal.componentInstance.remoteOcr modal.componentInstance.remoteOcr
@@ -1078,6 +1059,7 @@ export class BulkEditorComponent
rotateDialog.buttonsEnabled.set(false) rotateDialog.buttonsEnabled.set(false)
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
'rotate',
this.documentService.rotateDocuments( this.documentService.rotateDocuments(
this.getSelectionQuery(), this.getSelectionQuery(),
rotateDialog.degrees rotateDialog.degrees
@@ -1111,11 +1093,8 @@ export class BulkEditorComponent
mergeDialog.buttonsEnabled.set(false) mergeDialog.buttonsEnabled.set(false)
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args), 'merge',
{ clearSelection: !!args.delete_originals } this.documentService.mergeDocuments(mergeDialog.documentIDs(), args)
)
this.toastService.showInfo(
$localize`Merged document will be queued for consumption.`
) )
}) })
} }
@@ -1137,14 +1116,11 @@ export class BulkEditorComponent
.subscribe(() => { .subscribe(() => {
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
'merge_as_versions',
this.documentService.mergeDocumentsAsVersions( this.documentService.mergeDocumentsAsVersions(
mergeDialog.documentIDs(), mergeDialog.documentIDs(),
mergeDialog.rootDocumentID() mergeDialog.rootDocumentID()
), )
{
clearSelection: true,
successMessage: $localize`Documents merged as versions.`,
}
) )
}) })
} }
@@ -1169,7 +1145,7 @@ export class BulkEditorComponent
dialog.selection = this.getSelectionQuery() dialog.selection = this.getSelectionQuery()
dialog.selectionCount = this.getSelectionSize() dialog.selectionCount = this.getSelectionSize()
dialog.succeeded.subscribe(({ task_id }) => dialog.succeeded.subscribe(({ task_id }) =>
this.handleOperationQueued(null, task_id) this.handleOperationQueued(null, task_id, 'modify_custom_fields')
) )
dialog.failed.subscribe((error) => { dialog.failed.subscribe((error) => {
this.toastService.showError( this.toastService.showError(
@@ -240,7 +240,7 @@ describe('OpenDocumentsService', () => {
expect(refreshed.tags).toEqual([4]) expect(refreshed.tags).toEqual([4])
}) })
it('should handle error on refresh documents', () => { it('keeps documents open after a network error during refresh', () => {
subscriptions.push( subscriptions.push(
openDocumentsService.openDocument(documents[1]).subscribe() openDocumentsService.openDocument(documents[1]).subscribe()
) )
@@ -250,7 +250,63 @@ describe('OpenDocumentsService', () => {
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.error(new ErrorEvent('timeout')) req.error(new ErrorEvent('timeout'))
expect(openDocumentsService.getOpenDocument(documents[1].id)).toBe(
documents[1]
)
})
it.each([500, 403])(
'keeps documents open after HTTP %s during refresh',
(status) => {
const doc = { ...documents[0] }
openDocumentsService.openDocument(doc).subscribe()
openDocumentsService.refreshDocument(doc.id)
httpTestingController
.expectOne(
`${environment.apiBaseUrl}documents/${doc.id}/?full_perms=true`
)
.flush({}, { status, statusText: 'Error' })
expect(openDocumentsService.getOpenDocument(doc.id)).toBe(doc)
}
)
it('preserves unsaved edits when a refreshed document no longer exists', () => {
const doc = { ...documents[0], title: 'Unsaved title' }
openDocumentsService.openDocument(doc).subscribe()
openDocumentsService.setDirty(doc, true, { title: doc.title })
openDocumentsService.refreshDocument(doc.id)
httpTestingController
.expectOne(
`${environment.apiBaseUrl}documents/${doc.id}/?full_perms=true`
)
.flush({}, { status: 404, statusText: 'Not Found' })
expect(openDocumentsService.getOpenDocument(doc.id)).toBe(doc)
expect(openDocumentsService.isDirty(doc)).toBe(true)
expect(
JSON.parse(sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS))[0]
.title
).toBe('Unsaved title')
})
it('removes only the missing document when tab positions change during refresh', () => {
const first = { ...documents[0] }
const second = { ...documents[1] }
openDocumentsService.openDocument(first).subscribe()
openDocumentsService.openDocument(second).subscribe()
openDocumentsService.refreshDocument(first.id)
openDocumentsService.refreshDocument(second.id)
const firstRequest = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/${first.id}/?full_perms=true`
)
const secondRequest = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/${second.id}/?full_perms=true`
)
openDocumentsService.closeDocument(second).subscribe()
firstRequest.flush({}, { status: 404, statusText: 'Not Found' })
expect(openDocumentsService.getOpenDocuments()).toHaveLength(0) expect(openDocumentsService.getOpenDocuments()).toHaveLength(0)
openDocumentsService.openDocument(first).subscribe()
secondRequest.flush({}, { status: 404, statusText: 'Not Found' })
expect(openDocumentsService.getOpenDocuments()).toEqual([first])
}) })
it('should log error on sessionStorage save', () => { it('should log error on sessionStorage save', () => {
@@ -1,3 +1,4 @@
import { HttpErrorResponse } from '@angular/common/http'
import { Injectable, inject, signal } from '@angular/core' import { Injectable, inject, signal } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Observable, Subject, of } from 'rxjs' import { Observable, Subject, of } from 'rxjs'
@@ -61,7 +62,12 @@ export class OpenDocumentsService {
Object.assign(openDoc, doc, unsavedEdits) Object.assign(openDoc, doc, unsavedEdits)
this.save() this.save()
}, },
error: () => { error: (error: HttpErrorResponse) => {
if (error.status !== 404) return
const index = this.openDocuments.findIndex((doc) => doc.id === id)
if (index === -1 || this.isDirty(this.openDocuments[index])) return
this.openDocuments.splice(index, 1) this.openDocuments.splice(index, 1)
this.save() this.save()
}, },
@@ -290,6 +290,7 @@ describe(`DocumentService`, () => {
expect(req.request.method).toEqual('POST') expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: ids, documents: ids,
from_webui: true,
}) })
}) })
@@ -303,6 +304,7 @@ describe(`DocumentService`, () => {
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: ids, documents: ids,
remote_ocr: false, remote_ocr: false,
from_webui: true,
}) })
}) })
@@ -317,6 +319,7 @@ describe(`DocumentService`, () => {
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: ids, documents: ids,
remote_ocr: true, remote_ocr: true,
from_webui: true,
}) })
}) })
@@ -331,6 +334,7 @@ describe(`DocumentService`, () => {
documents: ids, documents: ids,
degrees: 90, degrees: 90,
source_mode: 'latest_version', source_mode: 'latest_version',
from_webui: true,
}) })
}) })
@@ -346,6 +350,7 @@ describe(`DocumentService`, () => {
documents: ids, documents: ids,
metadata_document_id: 1, metadata_document_id: 1,
delete_originals: true, delete_originals: true,
from_webui: true,
}) })
}) })
@@ -359,6 +364,7 @@ describe(`DocumentService`, () => {
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: ids, documents: ids,
root_document_id: 2, root_document_id: 2,
from_webui: true,
}) })
}) })
@@ -374,6 +380,7 @@ describe(`DocumentService`, () => {
documents: ids, documents: ids,
root_document_id: 2, root_document_id: 2,
version_label: 'Imported', version_label: 'Imported',
from_webui: true,
}) })
}) })
@@ -388,6 +395,7 @@ describe(`DocumentService`, () => {
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: ids, documents: ids,
operations: [{ page: 1, rotate: 90, doc: 0 }], operations: [{ page: 1, rotate: 90, doc: 0 }],
from_webui: true,
}) })
}) })
@@ -403,6 +411,7 @@ describe(`DocumentService`, () => {
documents: ids, documents: ids,
password: 'secret', password: 'secret',
update_document: true, update_document: true,
from_webui: true,
}) })
}) })
@@ -351,19 +351,24 @@ export class DocumentService extends AbstractPaperlessService<Document> {
} }
deleteDocuments(selection: DocumentSelectionQuery) { deleteDocuments(selection: DocumentSelectionQuery) {
return this.http.post(this.getResourceUrl(null, 'delete'), { return this.http.post<BulkOperationResponse>(
...selection, this.getResourceUrl(null, 'delete'),
}) { ...selection, from_webui: true }
)
} }
reprocessDocuments( reprocessDocuments(
selection: DocumentSelectionQuery, selection: DocumentSelectionQuery,
remoteOcr: boolean = false remoteOcr: boolean = false
) { ) {
return this.http.post(this.getResourceUrl(null, 'reprocess'), { return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'reprocess'),
{
...selection, ...selection,
remote_ocr: remoteOcr, remote_ocr: remoteOcr,
}) from_webui: true,
}
)
} }
rotateDocuments( rotateDocuments(
@@ -371,18 +376,26 @@ export class DocumentService extends AbstractPaperlessService<Document> {
degrees: number, degrees: number,
sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION
) { ) {
return this.http.post(this.getResourceUrl(null, 'rotate'), { return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'rotate'),
{
...selection, ...selection,
degrees, degrees,
source_mode: sourceMode, source_mode: sourceMode,
}) from_webui: true,
}
)
} }
mergeDocuments(ids: number[], request: MergeDocumentsRequest = {}) { mergeDocuments(ids: number[], request: MergeDocumentsRequest = {}) {
return this.http.post(this.getResourceUrl(null, 'merge'), { return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'merge'),
{
documents: ids, documents: ids,
...request, ...request,
}) from_webui: true,
}
)
} }
mergeDocumentsAsVersions( mergeDocumentsAsVersions(
@@ -390,28 +403,40 @@ export class DocumentService extends AbstractPaperlessService<Document> {
rootDocumentId: number, rootDocumentId: number,
versionLabel?: string versionLabel?: string
) { ) {
return this.http.post(this.getResourceUrl(null, 'merge_as_versions'), { return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'merge_as_versions'),
{
documents: ids, documents: ids,
root_document_id: rootDocumentId, root_document_id: rootDocumentId,
...(versionLabel ? { version_label: versionLabel } : {}), ...(versionLabel ? { version_label: versionLabel } : {}),
}) from_webui: true,
}
)
} }
editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) { editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) {
return this.http.post(this.getResourceUrl(null, 'edit_pdf'), { return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'edit_pdf'),
{
documents: ids, documents: ids,
...request, ...request,
}) from_webui: true,
}
)
} }
removePasswordDocuments( removePasswordDocuments(
ids: number[], ids: number[],
request: RemovePasswordDocumentsRequest request: RemovePasswordDocumentsRequest
) { ) {
return this.http.post(this.getResourceUrl(null, 'remove_password'), { return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'remove_password'),
{
documents: ids, documents: ids,
...request, ...request,
}) from_webui: true,
}
)
} }
getSelectionData( getSelectionData(
@@ -0,0 +1,186 @@
import { TestBed } from '@angular/core/testing'
import { Router } from '@angular/router'
import { BulkOperationStatus } from '../data/bulk-operation'
import { PaperlessTaskStatus } from '../data/paperless-task'
import { PermissionsService } from './permissions.service'
import { TaskNotificationService } from './task-notification.service'
import { Toast, ToastLevel, ToastService } from './toast.service'
describe('TaskNotificationService', () => {
let service: TaskNotificationService
let toastService: ToastService
let popups: Toast[]
let notifications: Toast[]
const navigate = jest.fn()
const currentUserCan = jest.fn()
const result = (
status = PaperlessTaskStatus.Success
): BulkOperationStatus => ({
task_id: 'task-1',
operation: 'run_workflow',
status,
})
beforeEach(() => {
jest.useFakeTimers()
currentUserCan.mockReturnValue(true)
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: { navigate } },
{ provide: PermissionsService, useValue: { currentUserCan } },
],
})
service = TestBed.inject(TaskNotificationService)
toastService = TestBed.inject(ToastService)
popups = []
notifications = []
toastService.showToast.subscribe((toast) => popups.push(toast))
toastService
.getToasts()
.subscribe((toasts) => (notifications = [...toasts]))
})
afterEach(() => {
jest.clearAllTimers()
jest.useRealTimers()
})
it('replaces a queued notification with its result, removing the task link on success', () => {
service.showQueued('task-1', 'Workflow queued.', {
content: 'Workflow completed.',
})
jest.advanceTimersByTime(399)
expect(popups).toHaveLength(0)
jest.advanceTimersByTime(1)
expect(popups[0].content).toBe('Workflow queued.')
expect(popups[0].actionName).toBe('View task')
popups[0].action()
expect(navigate).toHaveBeenCalledWith(['/tasks'], {
queryParams: { task_id: 'task-1' },
})
service.complete(result())
expect(notifications).toHaveLength(1)
expect(popups[1]).toEqual(
expect.objectContaining({
id: popups[0].id,
content: 'Workflow completed.',
level: ToastLevel.Success,
})
)
expect(popups[1].action).toBeUndefined()
expect(popups[1].actionName).toBeUndefined()
})
it('shows only the result when a task completes during the grace period', () => {
service.showQueued('task-1', 'Queued')
jest.advanceTimersByTime(100)
service.complete(result())
jest.advanceTimersByTime(1000)
expect(popups.map((toast) => toast.content)).toEqual([
"Operation 'Run workflow' completed.",
])
})
it.each([
'reprocess',
'rotate',
'merge',
'split',
'delete_pages',
'edit_pdf',
'remove_password',
])('reports %s as submitted rather than completed', (operation) => {
service.showQueued('task-1', 'Queued')
service.complete({ ...result(), operation })
expect(notifications[0].content).toContain('submitted for processing.')
expect(notifications[0].content).not.toContain('completed')
expect(notifications[0].action).toBeUndefined()
})
it('does not show a stale queued state when completion beats the HTTP response', () => {
service.complete(result())
service.showQueued('task-1', 'Workflow queued.', {
content: 'Workflow completed.',
})
jest.advanceTimersByTime(1000)
expect(popups.every((toast) => toast.level === ToastLevel.Success)).toBe(
true
)
expect(notifications).toHaveLength(1)
expect(notifications[0].content).toBe('Workflow completed.')
expect(notifications[0].action).toBeUndefined()
})
it.each([PaperlessTaskStatus.Failure, PaperlessTaskStatus.Revoked])(
'replaces the queued state with an error for %s',
(status) => {
service.showQueued('task-1', 'Queued', {
content: 'Success',
actionName: 'Undo',
})
jest.advanceTimersByTime(400)
service.complete({ ...result(status), error: 'Task failed' })
expect(notifications).toHaveLength(1)
expect(notifications[0]).toEqual(
expect.objectContaining({
level: ToastLevel.Error,
error: 'Task failed',
actionName: 'View task',
})
)
}
)
it('ignores nonterminal and duplicate outcomes', () => {
service.showQueued('task-1', 'Queued')
service.complete(result(PaperlessTaskStatus.Started))
expect(popups).toHaveLength(0)
service.complete(result())
service.complete(result())
expect(popups).toHaveLength(1)
})
it('keeps concurrent task notifications separate', () => {
service.showQueued('task-1', 'First queued')
service.showQueued('task-2', 'Second queued')
jest.advanceTimersByTime(400)
service.complete(result())
expect(notifications).toHaveLength(2)
expect(notifications.map((toast) => toast.content)).toEqual([
'Second queued',
"Operation 'Run workflow' completed.",
])
})
it('omits task links without task viewing permission', () => {
currentUserCan.mockReturnValue(false)
service.showQueued('task-1', 'Queued')
jest.advanceTimersByTime(400)
service.complete(result())
expect(popups.every((toast) => !toast.action && !toast.actionName)).toBe(
true
)
})
it('identifies concurrent operations even when completion arrives before their queue responses', () => {
service.complete({
task_id: 'tags',
operation: 'modify_tags',
status: PaperlessTaskStatus.Success,
})
service.complete({
task_id: 'correspondent',
operation: 'set_correspondent',
status: PaperlessTaskStatus.Success,
})
expect(notifications.map((toast) => toast.content)).toEqual([
"Operation 'Set correspondent' completed.",
"Operation 'Update tags' completed.",
])
})
it('keeps the generic fallback for unknown operation names', () => {
service.complete({ ...result(), operation: 'future_operation' })
expect(notifications[0].content).toBe('Operation completed.')
})
})
@@ -0,0 +1,128 @@
import { inject, Injectable, OnDestroy } from '@angular/core'
import { Router } from '@angular/router'
import { BulkOperationStatus } from '../data/bulk-operation'
import { PaperlessTaskStatus } from '../data/paperless-task'
import {
documentOperationHasFollowUpTasks,
getDocumentOperationLabel,
} from '../utils/document-operation'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from './permissions.service'
import { Toast, ToastLevel, ToastService } from './toast.service'
interface PendingNotification {
success: Partial<Toast>
timer: ReturnType<typeof setTimeout>
}
@Injectable({ providedIn: 'root' })
export class TaskNotificationService implements OnDestroy {
private readonly toastService = inject(ToastService)
private readonly router = inject(Router)
private readonly permissionsService = inject(PermissionsService)
private readonly pending = new Map<string, PendingNotification>()
private readonly completed = new Map<string, BulkOperationStatus>()
ngOnDestroy() {
this.pending.forEach(({ timer }) => clearTimeout(timer))
}
showQueued(taskId: string, content: string, success: Partial<Toast> = {}) {
const completed = this.completed.get(taskId)
if (completed) {
this.showResult(completed, success)
return
}
if (this.pending.has(taskId)) return
this.pending.set(taskId, {
success,
// short delay in case the task finishes super fast
timer: setTimeout(() => this.show(taskId, { content, delay: 5000 }), 400),
})
}
complete(result: BulkOperationStatus) {
if (
![
PaperlessTaskStatus.Success,
PaperlessTaskStatus.Failure,
PaperlessTaskStatus.Revoked,
].includes(result.status) ||
this.completed.has(result.task_id)
)
return
this.completed.set(result.task_id, result)
// Retain recent outcomes in case task finishes quickly
if (this.completed.size > 100) {
this.completed.delete(this.completed.keys().next().value)
}
const pending = this.pending.get(result.task_id)
if (pending) {
clearTimeout(pending.timer)
this.pending.delete(result.task_id)
}
// Trash needs the caller's document IDs to offer Undo on success.
if (
pending ||
result.operation !== 'delete' ||
result.status !== PaperlessTaskStatus.Success
) {
this.showResult(result, pending?.success)
}
}
private showResult(
result: BulkOperationStatus,
success: Partial<Toast> = {}
) {
const succeeded = result.status === PaperlessTaskStatus.Success
const operation = getDocumentOperationLabel(result.operation)
let content: string
if (succeeded) {
let operationText = $localize`Operation`
if (operation) operationText += ` '${operation}'`
const resultText = documentOperationHasFollowUpTasks(result.operation)
? $localize`submitted for processing`
: $localize`completed`
content = `${operationText} ${resultText}.`
} else if (result.status === PaperlessTaskStatus.Revoked) {
content = operation
? $localize`Operation '${operation}' revoked.`
: $localize`Operation revoked.`
} else {
content = operation
? $localize`Operation '${operation}' failed. Some changes may already have been applied.`
: $localize`Operation failed. Some changes may already have been applied.`
}
this.show(result.task_id, {
content,
delay: succeeded ? 5000 : 10000,
level: succeeded ? ToastLevel.Success : ToastLevel.Error,
...(succeeded ? success : { error: result.error }),
})
}
private show(taskId: string, toast: Toast) {
const canViewTasks = this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.PaperlessTask
)
this.toastService.show({
id: `task-${taskId}`,
...(canViewTasks && toast.level !== ToastLevel.Success
? {
actionName: $localize`View task`,
action: () =>
this.router.navigate(['/tasks'], {
queryParams: { task_id: taskId },
}),
}
: {}),
...toast,
})
}
}
@@ -38,6 +38,29 @@ describe('ToastService', () => {
}) })
}) })
it('updates a notification by ID without duplicating or moving it', () => {
let notifications: Toast[] = []
toastService
.getToasts()
.subscribe((toasts) => (notifications = [...toasts]))
toastService.show({ id: 'task-1', content: 'Queued', delay: 5000 })
toastService.show({
id: 'other',
content: 'Another notification',
delay: 5000,
})
toastService.show({
id: 'task-1',
content: 'Completed',
delay: 5000,
level: ToastLevel.Success,
})
expect(notifications.map((toast) => toast.content)).toEqual([
'Another notification',
'Completed',
])
})
it('parses error string to object on show', () => { it('parses error string to object on show', () => {
const toast = { const toast = {
title: 'Title', title: 'Title',
+7
View File
@@ -75,7 +75,14 @@ export class ToastService {
if (persistent) { if (persistent) {
// the dropdown is open, so it's seen as it arrives // the dropdown is open, so it's seen as it arrives
toast.read = !!this._suppressPopupToasts toast.read = !!this._suppressPopupToasts
const index = this.toasts.findIndex(
(existing) => existing.id === toast.id
)
if (index === -1) {
this.toasts.unshift(toast) this.toasts.unshift(toast)
} else {
this.toasts[index] = toast
}
this.toastsSubject.next(this.toasts) this.toastsSubject.next(this.toasts)
} }
// Popups are suppressed while the dropdown is open // Popups are suppressed while the dropdown is open
+51 -1
View File
@@ -5,8 +5,10 @@ import {
HttpTestingController, HttpTestingController,
} from '@angular/common/http/testing' } from '@angular/common/http/testing'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { PaperlessTaskStatus } from '../data/paperless-task'
import { DocumentListViewService } from './document-list-view.service' import { DocumentListViewService } from './document-list-view.service'
import { Toast, ToastService } from './toast.service' import { TaskNotificationService } from './task-notification.service'
import { Toast, ToastLevel, ToastService } from './toast.service'
import { TrashService } from './trash.service' import { TrashService } from './trash.service'
describe('TrashService', () => { describe('TrashService', () => {
@@ -105,4 +107,52 @@ describe('TrashService', () => {
expect(toast.content).toEqual('Moved 500 document(s) to the trash') expect(toast.content).toEqual('Moved 500 document(s) to the trash')
expect(toast.action).toBeUndefined() expect(toast.action).toBeUndefined()
}) })
it('offers Undo only after the matching delete task succeeds', () => {
let toasts: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
service.trackMovedToTrash('delete-1', 2, [1, 2])
expect(toasts).toHaveLength(0)
const notifications = TestBed.inject(TaskNotificationService)
notifications.complete({
task_id: 'other-delete',
operation: 'delete',
status: PaperlessTaskStatus.Success,
})
expect(toasts).toHaveLength(0)
notifications.complete({
task_id: 'delete-1',
operation: 'delete',
status: PaperlessTaskStatus.Success,
})
expect(toasts).toHaveLength(1)
expect(toasts[0].actionName).toBe('Undo')
toasts[0].action()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}trash/`
)
expect(req.request.body).toEqual({ action: 'restore', documents: [1, 2] })
})
it('handles completion before the queue response and does not offer Undo on failure', () => {
let toasts: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
const notifications = TestBed.inject(TaskNotificationService)
notifications.complete({
task_id: 'delete-1',
operation: 'delete',
status: PaperlessTaskStatus.Success,
})
service.trackMovedToTrash('delete-1', 1, [1])
expect(toasts[0].actionName).toBe('Undo')
service.trackMovedToTrash('delete-2', 1, [2])
notifications.complete({
task_id: 'delete-2',
operation: 'delete',
status: PaperlessTaskStatus.Failure,
})
expect(toasts).toHaveLength(2)
expect(toasts[0].level).toBe(ToastLevel.Error)
expect(toasts[0].actionName).not.toBe('Undo')
})
}) })
+32 -5
View File
@@ -5,7 +5,8 @@ import { environment } from 'src/environments/environment'
import { Document } from '../data/document' import { Document } from '../data/document'
import { Results } from '../data/results' import { Results } from '../data/results'
import { DocumentListViewService } from './document-list-view.service' import { DocumentListViewService } from './document-list-view.service'
import { ToastLevel, ToastService } from './toast.service' import { TaskNotificationService } from './task-notification.service'
import { Toast, ToastLevel, ToastService } from './toast.service'
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -14,6 +15,24 @@ export class TrashService {
private http = inject(HttpClient) private http = inject(HttpClient)
private toastService = inject(ToastService) private toastService = inject(ToastService)
private documentListViewService = inject(DocumentListViewService) private documentListViewService = inject(DocumentListViewService)
private taskNotifications = inject(TaskNotificationService)
public trackMovedToTrash(
taskId: string,
count: number,
documents?: number[]
) {
this.taskNotifications.showQueued(
taskId,
count > 1
? $localize`Bulk move to trash queued.`
: $localize`Move to trash queued.`,
this.movedToTrashToast(
$localize`Moved ${count} document(s) to the trash`,
documents
)
)
}
public getTrash(page: number = 1): Observable<Results<Document>> { public getTrash(page: number = 1): Observable<Results<Document>> {
const httpParams = new HttpParams().set('page', page.toString()) const httpParams = new HttpParams().set('page', page.toString())
@@ -43,14 +62,22 @@ export class TrashService {
* Shows a toast with an Undo action when possible * Shows a toast with an Undo action when possible
*/ */
public notifyMovedToTrash(content: string, documents?: number[]) { public notifyMovedToTrash(content: string, documents?: number[]) {
this.toastService.show(this.movedToTrashToast(content, documents))
}
private movedToTrashToast(content: string, documents?: number[]): Toast {
const canUndo = documents?.length > 0 const canUndo = documents?.length > 0
this.toastService.show({ return {
content, content,
delay: 10000, delay: 10000,
level: ToastLevel.Success, level: ToastLevel.Success,
actionName: canUndo ? $localize`Undo` : undefined, ...(canUndo
action: canUndo ? () => this.undoMoveToTrash(documents) : undefined, ? {
}) actionName: $localize`Undo`,
action: () => this.undoMoveToTrash(documents),
}
: {}),
}
} }
private undoMoveToTrash(documents: number[]) { private undoMoveToTrash(documents: number[]) {
@@ -0,0 +1,11 @@
import { getDocumentOperationLabel } from './document-operation'
describe('Document Operation Utils', () => {
it('should retrieve the document operation label correctly for an existing operation, null otherwise', () => {
const result = getDocumentOperationLabel('modify_custom_fields')
expect(result).toBe('Update custom fields')
const resultNull = getDocumentOperationLabel('non_existing_operation')
expect(resultNull).toBeNull()
})
})
@@ -0,0 +1,38 @@
const OPERATION_LABELS: Record<string, string> = {
set_correspondent: $localize`Set correspondent`,
set_document_type: $localize`Set document type`,
set_storage_path: $localize`Set storage path`,
add_tag: $localize`Add tag`,
remove_tag: $localize`Remove tag`,
modify_tags: $localize`Update tags`,
modify_custom_fields: $localize`Update custom fields`,
set_permissions: $localize`Set permissions`,
delete: $localize`Move to trash`,
reprocess: $localize`Document reprocessing`,
run_workflow: $localize`Run workflow`,
rotate: $localize`Rotate documents`,
merge: $localize`Merge documents`,
merge_as_versions: $localize`Merge as versions`,
split: $localize`Split documents`,
delete_pages: $localize`Delete pages`,
edit_pdf: $localize`Edit PDF`,
remove_password: $localize`Remove password`,
}
export function getDocumentOperationLabel(operation?: string): string | null {
const label = operation ? OPERATION_LABELS[operation] : null
return label ?? null
}
// These operations queue processing in separate tasks
export function documentOperationHasFollowUpTasks(operation?: string): boolean {
return [
'reprocess',
'rotate',
'merge',
'split',
'delete_pages',
'edit_pdf',
'remove_password',
].includes(operation ?? '')
}
+115 -31
View File
@@ -39,6 +39,8 @@ from documents.models import Tag
from documents.permissions import PermissionsSpec from documents.permissions import PermissionsSpec
from documents.permissions import set_permissions_for_objects from documents.permissions import set_permissions_for_objects
from documents.plugins.helpers import DocumentsStatusManager from documents.plugins.helpers import DocumentsStatusManager
from documents.task_tracking import queue_consumption
from documents.task_tracking import queue_processing_tasks
from documents.tasks import bulk_update_documents from documents.tasks import bulk_update_documents
from documents.tasks import consume_file from documents.tasks import consume_file
from documents.tasks import remove_document_from_index from documents.tasks import remove_document_from_index
@@ -427,11 +429,17 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
"Detected a possible incompatible database column. See https://docs.paperless-ngx.com/troubleshooting/#convert-uuid-field", "Detected a possible incompatible database column. See https://docs.paperless-ngx.com/troubleshooting/#convert-uuid-field",
) )
logger.error(f"Error deleting documents: {e!s}") logger.error(f"Error deleting documents: {e!s}")
raise
return "OK" return "OK"
def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]: def reprocess(
doc_ids: list[int],
*,
remote_ocr: bool = False,
parent_task_id: str | None = None,
) -> Literal["OK"]:
""" """
Re-run parsing for the given documents. Re-run parsing for the given documents.
@@ -456,6 +464,16 @@ def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
.values_list("source_id", flat=True) .values_list("source_id", flat=True)
.distinct() .distinct()
) )
if parent_task_id is not None:
from documents.task_tracking import queue_reprocessing_tasks
queue_reprocessing_tasks(
parent_task_id,
list(source_ids),
remote_ocr=remote_ocr,
)
return "OK"
for source_id in source_ids: for source_id in source_ids:
update_document_content_maybe_archive_file.apply_async( update_document_content_maybe_archive_file.apply_async(
kwargs={"document_id": source_id, "remote_ocr": remote_ocr}, kwargs={"document_id": source_id, "remote_ocr": remote_ocr},
@@ -474,9 +492,9 @@ def run_workflow(
""" """
Manually run a single workflow against the given documents. Manually run a single workflow against the given documents.
Done asynchronously via a Celery task. Called from the tracked bulk operation task.
""" """
run_workflows_for_documents.delay( run_workflows_for_documents(
document_ids=doc_ids, document_ids=doc_ids,
workflow_id=workflow, workflow_id=workflow,
filter_documents=filter_documents, filter_documents=filter_documents,
@@ -520,6 +538,7 @@ def rotate(
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION, source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
user: User | None = None, user: User | None = None,
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI, trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
parent_task_id: str | None = None,
) -> Literal["OK"]: ) -> Literal["OK"]:
logger.info( logger.info(
f"Attempting to rotate {len(doc_ids)} documents by {degrees} degrees.", f"Attempting to rotate {len(doc_ids)} documents by {degrees} degrees.",
@@ -557,22 +576,22 @@ def rotate(
if user is not None: if user is not None:
overrides.actor_id = user.id overrides.actor_id = user.id
consume_file.apply_async( queue_consumption(
kwargs={ input_doc=ConsumableDocument(
"input_doc": ConsumableDocument(
source=DocumentSource.ConsumeFolder, source=DocumentSource.ConsumeFolder,
original_file=filepath, original_file=filepath,
root_document_id=pair.root_doc.id, root_document_id=pair.root_doc.id,
), ),
"overrides": overrides, overrides=overrides,
}, trigger_source=trigger_source,
headers={"trigger_source": trigger_source}, parent_task_id=parent_task_id,
) )
logger.info( logger.info(
f"Queued new rotated version for document {pair.root_doc.id} by {degrees} degrees", f"Queued new rotated version for document {pair.root_doc.id} by {degrees} degrees",
) )
except Exception as e: except Exception as e:
logger.exception(f"Error rotating document {pair.root_doc.id}: {e}") logger.exception(f"Error rotating document {pair.root_doc.id}: {e}")
raise
return "OK" return "OK"
@@ -586,13 +605,15 @@ def merge(
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION, source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
user: User | None = None, user: User | None = None,
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI, trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
) -> Literal["OK"]: parent_task_id: str | None = None,
) -> Literal["OK"] | dict:
logger.info( logger.info(
f"Attempting to merge {len(doc_ids)} documents into a single document.", f"Attempting to merge {len(doc_ids)} documents into a single document.",
) )
qs = Document.objects.select_related("root_document").filter(id__in=doc_ids) qs = Document.objects.select_related("root_document").filter(id__in=doc_ids)
docs_by_id = {doc.id: doc for doc in qs} docs_by_id = {doc.id: doc for doc in qs}
affected_docs: list[int] = [] affected_docs: list[int] = []
failed_ids: list[int] = []
handoff_asn: int | None = None handoff_asn: int | None = None
with pdf_ops.PdfMerger() as merger: with pdf_ops.PdfMerger() as merger:
# use doc_ids to preserve order # use doc_ids to preserve order
@@ -618,11 +639,14 @@ def merge(
if handoff_asn is None and doc.archive_serial_number is not None: if handoff_asn is None and doc.archive_serial_number is not None:
handoff_asn = doc.archive_serial_number handoff_asn = doc.archive_serial_number
except Exception as e: except Exception as e:
failed_ids.append(doc.id)
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}",
) )
if len(affected_docs) == 0: if len(affected_docs) == 0:
logger.warning("No documents were merged") logger.warning("No documents were merged")
if parent_task_id is not None:
raise ValueError("None of the selected documents could be merged.")
return "OK" return "OK"
filepath = ( filepath = (
@@ -665,6 +689,23 @@ def merge(
overrides=overrides, overrides=overrides,
).set(headers={"trigger_source": trigger_source}) ).set(headers={"trigger_source": trigger_source})
if parent_task_id is not None:
backup = (
release_archive_serial_numbers(affected_docs) if delete_originals else {}
)
queue_processing_tasks(
parent_task_id,
[consume_task],
delete_ids=affected_docs if delete_originals else None,
asn_backup=backup,
)
if failed_ids:
return {
"failed_document_ids": failed_ids,
"error_message": f"Documents {failed_ids} were excluded from the merge.",
}
return "OK"
if delete_originals: if delete_originals:
backup = release_archive_serial_numbers(affected_docs) backup = release_archive_serial_numbers(affected_docs)
logger.info( logger.info(
@@ -701,7 +742,15 @@ def merge_as_versions(
) )
documents_by_id = {document.id: document for document in documents} documents_by_id = {document.id: document for document in documents}
if any(document.root_document_id is not None for document in documents):
raise ValueError("Only top-level documents can be merged as versions.")
source_ids = [doc_id for doc_id in doc_ids if doc_id != root_document_id] source_ids = [doc_id for doc_id in doc_ids if doc_id != root_document_id]
if Document.global_objects.filter(root_document_id__in=source_ids).exists():
raise ValueError(
"Documents with existing versions cannot be merged into another document.",
)
root_document = documents_by_id[root_document_id] root_document = documents_by_id[root_document_id]
next_version_index = ( next_version_index = (
Document.global_objects.filter( Document.global_objects.filter(
@@ -801,6 +850,7 @@ def split(
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION, source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
user: User | None = None, user: User | None = None,
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI, trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
parent_task_id: str | None = None,
) -> Literal["OK"]: ) -> Literal["OK"]:
logger.info( logger.info(
f"Attempting to split document {doc_ids[0]} into {len(pages)} documents", f"Attempting to split document {doc_ids[0]} into {len(pages)} documents",
@@ -844,7 +894,17 @@ def split(
).set(headers={"trigger_source": trigger_source}), ).set(headers={"trigger_source": trigger_source}),
) )
if delete_originals: if parent_task_id is not None:
backup = (
release_archive_serial_numbers([doc.id]) if delete_originals else {}
)
queue_processing_tasks(
parent_task_id,
consume_tasks,
delete_ids=[doc.id] if delete_originals else None,
asn_backup=backup,
)
elif delete_originals:
backup = release_archive_serial_numbers([doc.id]) 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",
@@ -864,6 +924,7 @@ def split(
except Exception as e: except Exception as e:
logger.exception(f"Error splitting document {doc.id}: {e}") logger.exception(f"Error splitting document {doc.id}: {e}")
raise
return "OK" return "OK"
@@ -875,6 +936,7 @@ def delete_pages(
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION, source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
user: User | None = None, user: User | None = None,
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI, trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
parent_task_id: str | None = None,
) -> Literal["OK"]: ) -> Literal["OK"]:
logger.info( logger.info(
f"Attempting to delete pages {pages} from {len(doc_ids)} documents", f"Attempting to delete pages {pages} from {len(doc_ids)} documents",
@@ -893,22 +955,22 @@ def delete_pages(
overrides = DocumentMetadataOverrides().from_document(pair.root_doc) overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
if user is not None: if user is not None:
overrides.actor_id = user.id overrides.actor_id = user.id
consume_file.apply_async( queue_consumption(
kwargs={ input_doc=ConsumableDocument(
"input_doc": ConsumableDocument(
source=DocumentSource.ConsumeFolder, source=DocumentSource.ConsumeFolder,
original_file=filepath, original_file=filepath,
root_document_id=pair.root_doc.id, root_document_id=pair.root_doc.id,
), ),
"overrides": overrides, overrides=overrides,
}, trigger_source=trigger_source,
headers={"trigger_source": trigger_source}, parent_task_id=parent_task_id,
) )
logger.info( logger.info(
f"Queued new version for document {pair.root_doc.id} after deleting pages {pages}", f"Queued new version for document {pair.root_doc.id} after deleting pages {pages}",
) )
except Exception as e: except Exception as e:
logger.exception(f"Error deleting pages from document {pair.root_doc.id}: {e}") logger.exception(f"Error deleting pages from document {pair.root_doc.id}: {e}")
raise
return "OK" return "OK"
@@ -923,6 +985,7 @@ def edit_pdf(
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION, source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
user: User | None = None, user: User | None = None,
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI, trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
parent_task_id: str | None = None,
) -> Literal["OK"]: ) -> Literal["OK"]:
""" """
Operations is a list of dictionaries describing the final PDF pages. Operations is a list of dictionaries describing the final PDF pages.
@@ -967,16 +1030,15 @@ def edit_pdf(
if user is not None: if user is not None:
overrides.owner_id = user.id overrides.owner_id = user.id
overrides.actor_id = user.id overrides.actor_id = user.id
consume_file.apply_async( queue_consumption(
kwargs={ input_doc=ConsumableDocument(
"input_doc": ConsumableDocument(
source=DocumentSource.ConsumeFolder, source=DocumentSource.ConsumeFolder,
original_file=filepath, original_file=filepath,
root_document_id=pair.root_doc.id, root_document_id=pair.root_doc.id,
), ),
"overrides": overrides, overrides=overrides,
}, trigger_source=trigger_source,
headers={"trigger_source": trigger_source}, parent_task_id=parent_task_id,
) )
else: else:
version_filepaths = pdf_ops.build_pdfs( version_filepaths = pdf_ops.build_pdfs(
@@ -1016,7 +1078,17 @@ def edit_pdf(
).set(headers={"trigger_source": trigger_source}), ).set(headers={"trigger_source": trigger_source}),
) )
if delete_original: if parent_task_id is not None:
backup = (
release_archive_serial_numbers([doc.id]) if delete_original else {}
)
queue_processing_tasks(
parent_task_id,
consume_tasks,
delete_ids=[doc.id] if delete_original else None,
asn_backup=backup,
)
elif delete_original:
backup = release_archive_serial_numbers([doc.id]) backup = release_archive_serial_numbers([doc.id])
try: try:
chord( chord(
@@ -1050,6 +1122,7 @@ def remove_password(
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION, source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
user: User | None = None, user: User | None = None,
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI, trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
parent_task_id: str | None = None,
source_paths_by_id: Mapping[int, Path] | None = None, source_paths_by_id: Mapping[int, Path] | None = None,
) -> Literal["OK"]: ) -> Literal["OK"]:
""" """
@@ -1092,16 +1165,15 @@ def remove_password(
if user is not None: if user is not None:
overrides.owner_id = user.id overrides.owner_id = user.id
overrides.actor_id = user.id overrides.actor_id = user.id
consume_file.apply_async( queue_consumption(
kwargs={ input_doc=ConsumableDocument(
"input_doc": ConsumableDocument(
source=DocumentSource.ConsumeFolder, source=DocumentSource.ConsumeFolder,
original_file=filepath, original_file=filepath,
root_document_id=pair.root_doc.id, root_document_id=pair.root_doc.id,
), ),
"overrides": overrides, overrides=overrides,
}, trigger_source=trigger_source,
headers={"trigger_source": trigger_source}, parent_task_id=parent_task_id,
) )
else: else:
consume_tasks = [] consume_tasks = []
@@ -1124,7 +1196,19 @@ def remove_password(
).set(headers={"trigger_source": trigger_source}), ).set(headers={"trigger_source": trigger_source}),
) )
if delete_original: if parent_task_id is not None:
backup = (
release_archive_serial_numbers([doc.id])
if delete_original
else {}
)
queue_processing_tasks(
parent_task_id,
consume_tasks,
delete_ids=[doc.id] if delete_original else None,
asn_backup=backup,
)
elif delete_original:
chord( chord(
header=consume_tasks, header=consume_tasks,
body=delete.si([doc.id]), body=delete.si([doc.id]),
+76 -3
View File
@@ -1,4 +1,4 @@
"""Queued document metadata edits.""" """Queued document bulk operations."""
from typing import Any from typing import Any
@@ -12,6 +12,7 @@ from rest_framework.exceptions import PermissionDenied
from documents import bulk_edit from documents import bulk_edit
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
from documents.models import Document from documents.models import Document
from documents.models import PaperlessTask
from documents.permissions import has_document_operation_permissions from documents.permissions import has_document_operation_permissions
from documents.tasks import bulk_update_documents from documents.tasks import bulk_update_documents
@@ -25,7 +26,35 @@ MODIFIED_FIELDS = {
"modify_custom_fields": "custom_fields", "modify_custom_fields": "custom_fields",
"set_permissions": None, "set_permissions": None,
} }
QUEUED_OPERATIONS = frozenset(MODIFIED_FIELDS) LEGACY_MODIFIED_FIELDS = {"delete": "deleted_at", "reprocess": "checksum"}
QUEUED_OPERATIONS = frozenset(
(
*MODIFIED_FIELDS,
"delete",
"reprocess",
"run_workflow",
"rotate",
"merge",
"merge_as_versions",
"split",
"delete_pages",
"edit_pdf",
"remove_password",
),
)
USER_OPERATIONS = frozenset(
{
"split",
"merge",
"rotate",
"delete_pages",
"edit_pdf",
"remove_password",
"merge_as_versions",
},
)
# merge_as_versions doesn't queue any consume tasks
TRIGGER_SOURCE_OPERATIONS = USER_OPERATIONS - {"merge_as_versions"}
def snapshot_field(doc_ids: list[int], field: str) -> dict[int, Any]: def snapshot_field(doc_ids: list[int], field: str) -> dict[int, Any]:
@@ -60,12 +89,14 @@ def snapshot_field(doc_ids: list[int], field: str) -> dict[int, Any]:
return values return values
@shared_task @shared_task(bind=True)
def execute_bulk_operation( def execute_bulk_operation(
self,
operation: str, operation: str,
document_ids: list[int], document_ids: list[int],
parameters: dict, parameters: dict,
user_id: int, user_id: int,
trigger_source: str = PaperlessTask.TriggerSource.API_UPLOAD,
) -> dict: ) -> dict:
if operation not in QUEUED_OPERATIONS: if operation not in QUEUED_OPERATIONS:
raise ValueError(f"Unsupported bulk operation: {operation}") raise ValueError(f"Unsupported bulk operation: {operation}")
@@ -79,6 +110,48 @@ def execute_bulk_operation(
): ):
raise PermissionDenied("Insufficient permissions to execute bulk operation") raise PermissionDenied("Insufficient permissions to execute bulk operation")
if operation == "run_workflow" and not user.has_perm("documents.view_workflow"):
raise PermissionDenied("Insufficient permissions to run this workflow")
if operation not in MODIFIED_FIELDS:
execution_parameters = parameters.copy()
if (
operation in TRIGGER_SOURCE_OPERATIONS | {"reprocess"}
and self.request.id is not None
):
execution_parameters["parent_task_id"] = self.request.id
if operation in USER_OPERATIONS:
execution_parameters["user"] = user
if operation in TRIGGER_SOURCE_OPERATIONS:
execution_parameters["trigger_source"] = trigger_source
modified_field = LEGACY_MODIFIED_FIELDS.get(operation)
with set_actor(user):
old_values = (
snapshot_field(document_ids, modified_field)
if settings.AUDIT_LOG_ENABLED and modified_field
else {}
)
result = method(document_ids, **execution_parameters)
if settings.AUDIT_LOG_ENABLED and modified_field:
from auditlog.models import LogEntry
new_values = snapshot_field(document_ids, modified_field)
for doc in Document.objects.filter(pk__in=document_ids):
LogEntry.objects.log_create(
instance=doc,
changes={
modified_field: [old_values[doc.pk], new_values[doc.pk]],
},
action=LogEntry.Action.UPDATE,
actor=user,
additional_data={"reason": f"Bulk edit: {operation}"},
)
return {
"operation": operation,
"document_count": len(document_ids),
**(result if isinstance(result, dict) else {}),
}
affected_ids: list[int] = [] affected_ids: list[int] = []
modified_field = MODIFIED_FIELDS[operation] modified_field = MODIFIED_FIELDS[operation]
# Keep metadata writes and their audit entries together. Follow-up processing # Keep metadata writes and their audit entries together. Follow-up processing
@@ -0,0 +1,30 @@
# Generated by Django 5.2.18 on 2026-11-14 19:59
import django.db.models.deletion
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "0030_note_format"),
]
operations = [
migrations.AddField(
model_name="paperlesstask",
name="dispatched",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="paperlesstask",
name="parent",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="children",
to="documents.paperlesstask",
),
),
]
+10
View File
@@ -970,6 +970,16 @@ class PaperlessTask(ModelWithOwner):
help_text=_("Structured result data from task execution"), help_text=_("Structured result data from task execution"),
) )
parent = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="children",
)
dispatched = models.BooleanField(default=False)
# Acknowledgment # Acknowledgment
acknowledged = models.BooleanField( acknowledged = models.BooleanField(
default=False, default=False,
+5 -14
View File
@@ -1688,6 +1688,7 @@ class MergeDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin
class MergeDocumentsAsVersionsSerializer(DocumentListSerializer): class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
root_document_id = serializers.IntegerField(required=True) root_document_id = serializers.IntegerField(required=True)
from_webui = serializers.BooleanField(required=False, default=False)
version_label = serializers.CharField( version_label = serializers.CharField(
required=False, required=False,
allow_blank=True, allow_blank=True,
@@ -1716,19 +1717,8 @@ class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
"root_document_id must be one of the selected documents.", "root_document_id must be one of the selected documents.",
) )
selected_documents = Document.objects.filter(id__in=documents) # Version state checks (e.g., whether a document is already part of a version chain)
if selected_documents.filter(root_document__isnull=False).exists(): # moved to merge_as_versions() under row locks, since it could change between queueing and execution
raise serializers.ValidationError(
"Only top-level documents can be merged as versions.",
)
source_document_ids = set(documents) - {attrs["root_document_id"]}
if Document.global_objects.filter(
root_document_id__in=source_document_ids,
).exists():
raise serializers.ValidationError(
"Documents with existing versions cannot be merged into another document.",
)
return attrs return attrs
@@ -1801,11 +1791,12 @@ class RemovePasswordDocumentsSerializer(
class DeleteDocumentsSerializer(DocumentSelectionSerializer): class DeleteDocumentsSerializer(DocumentSelectionSerializer):
pass from_webui = serializers.BooleanField(required=False, default=False)
class ReprocessDocumentsSerializer(DocumentSelectionSerializer): class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
remote_ocr = serializers.BooleanField(required=False, default=False) remote_ocr = serializers.BooleanField(required=False, default=False)
from_webui = serializers.BooleanField(required=False, default=False)
class BulkEditSerializer( class BulkEditSerializer(
+46
View File
@@ -56,6 +56,8 @@ from documents.models import WorkflowRun
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.permissions import restrict_queryset_to_visible from documents.permissions import restrict_queryset_to_visible
from documents.plugins.helpers import DocumentsStatusManager from documents.plugins.helpers import DocumentsStatusManager
from documents.task_tracking import finish_dispatch
from documents.task_tracking import update_parent
from documents.templating.utils import convert_format_str_to_template_format from documents.templating.utils import convert_format_str_to_template_format
from documents.utils import compute_checksum from documents.utils import compute_checksum
from documents.utils import copy_file_with_basic_stats from documents.utils import copy_file_with_basic_stats
@@ -1077,6 +1079,7 @@ TRACKED_TASKS: dict[str, PaperlessTask.TaskType] = {
"documents.tasks.update_document_content_maybe_archive_file": PaperlessTask.TaskType.REPROCESS_DOCUMENT, "documents.tasks.update_document_content_maybe_archive_file": PaperlessTask.TaskType.REPROCESS_DOCUMENT,
"documents.tasks.build_share_link_bundle": PaperlessTask.TaskType.BUILD_SHARE_LINK, "documents.tasks.build_share_link_bundle": PaperlessTask.TaskType.BUILD_SHARE_LINK,
"documents.bulk_edit.delete": PaperlessTask.TaskType.BULK_DELETE, "documents.bulk_edit.delete": PaperlessTask.TaskType.BULK_DELETE,
"documents.task_tracking.finish_file_operation": PaperlessTask.TaskType.BULK_DELETE,
"documents.tasks.apply_ai_suggestions": PaperlessTask.TaskType.APPLY_AI_SUGGESTIONS, "documents.tasks.apply_ai_suggestions": PaperlessTask.TaskType.APPLY_AI_SUGGESTIONS,
BULK_OPERATION_TASK_NAME: PaperlessTask.TaskType.BULK_UPDATE, BULK_OPERATION_TASK_NAME: PaperlessTask.TaskType.BULK_UPDATE,
} }
@@ -1125,6 +1128,8 @@ def _extract_input_data(
data["overrides"] = override_dict data["overrides"] = override_dict
return data return data
if task_type == PaperlessTask.TaskType.REPROCESS_DOCUMENT:
return {"document_id": task_kwargs["document_id"]}
if task_type == PaperlessTask.TaskType.MAIL_FETCH: if task_type == PaperlessTask.TaskType.MAIL_FETCH:
account_ids = task_kwargs.get("account_ids") account_ids = task_kwargs.get("account_ids")
if account_ids is not None: if account_ids is not None:
@@ -1206,10 +1211,25 @@ def before_task_publish_handler(
"document_count": len(task_kwargs["document_ids"]), "document_count": len(task_kwargs["document_ids"]),
} }
owner_id = task_kwargs["user_id"] owner_id = task_kwargs["user_id"]
if task_kwargs["operation"] == "delete":
task_type = PaperlessTask.TaskType.BULK_DELETE
else: else:
input_data = _extract_input_data(task_type, task_kwargs) input_data = _extract_input_data(task_type, task_kwargs)
owner_id = _extract_owner_id(task_type, task_kwargs) owner_id = _extract_owner_id(task_type, task_kwargs)
trigger_source = _determine_trigger_source(headers) trigger_source = _determine_trigger_source(headers)
# Consumption can fan out again (e.g. barcode splitting). Keep those
# descendants attached so their caller cannot finish before them.
parent = None
if task_type == PaperlessTask.TaskType.CONSUME_FILE and headers.get(
"parent_id",
):
parent = PaperlessTask.objects.filter(
task_id=headers["parent_id"],
parent__isnull=False,
).first()
if parent is not None:
owner_id = parent.owner_id
trigger_source = parent.trigger_source
# A retried task is republished with the same task_id, so this fires # A retried task is republished with the same task_id, so this fires
# again for it; get_or_create keeps the original PENDING record # again for it; get_or_create keeps the original PENDING record
@@ -1222,6 +1242,7 @@ def before_task_publish_handler(
"status": PaperlessTask.Status.PENDING, "status": PaperlessTask.Status.PENDING,
"input_data": input_data, "input_data": input_data,
"owner_id": owner_id, "owner_id": owner_id,
"parent": parent,
}, },
) )
except Exception: # pragma: no cover except Exception: # pragma: no cover
@@ -1307,6 +1328,10 @@ def task_postrun_handler(
except PaperlessTask.DoesNotExist: except PaperlessTask.DoesNotExist:
return return
if task_instance.children.exists():
finish_dispatch(task_instance, retval if isinstance(retval, dict) else {})
return
task_instance.status = new_status task_instance.status = new_status
task_instance.date_done = now task_instance.date_done = now
changed_fields = ["status", "date_done"] changed_fields = ["status", "date_done"]
@@ -1329,7 +1354,21 @@ def task_postrun_handler(
task_instance.status = PaperlessTask.Status.FAILURE task_instance.status = PaperlessTask.Status.FAILURE
changed_fields.append("status") changed_fields.append("status")
if (
task_instance.parent_id is not None
and task_instance.task_type == PaperlessTask.TaskType.CONSUME_FILE
and (not isinstance(retval, dict) or "document_id" not in retval)
and not (isinstance(retval, dict) and "duplicate_of" in retval)
):
task_instance.status = PaperlessTask.Status.FAILURE
task_instance.result_data = {
**(retval if isinstance(retval, dict) else {}),
"error_message": "Consumption stopped without creating a document.",
}
changed_fields.extend(["status", "result_data"])
task_instance.save(update_fields=changed_fields) task_instance.save(update_fields=changed_fields)
update_parent(task_instance)
if task and task.name == BULK_OPERATION_TASK_NAME: if task and task.name == BULK_OPERATION_TASK_NAME:
_send_bulk_operation_status(task_instance) _send_bulk_operation_status(task_instance)
except Exception: # pragma: no cover except Exception: # pragma: no cover
@@ -1386,6 +1425,10 @@ def task_failure_handler(
} }
task_qs = PaperlessTask.objects.filter(task_id=task_id) task_qs = PaperlessTask.objects.filter(task_id=task_id)
parent = task_qs.first()
if parent is not None and parent.children.exists():
finish_dispatch(parent, result_data)
return
task_instance = task_qs.values("date_started", "date_created").first() task_instance = task_qs.values("date_started", "date_created").first()
if task_instance: if task_instance:
date_started = task_instance["date_started"] date_started = task_instance["date_started"]
@@ -1397,6 +1440,7 @@ def task_failure_handler(
date_started - date_created date_started - date_created
).total_seconds() ).total_seconds()
task_qs.update(**update_fields) task_qs.update(**update_fields)
update_parent(task_qs.get())
if sender and sender.name == BULK_OPERATION_TASK_NAME: if sender and sender.name == BULK_OPERATION_TASK_NAME:
_send_bulk_operation_status(task_qs.first()) _send_bulk_operation_status(task_qs.first())
except Exception: # pragma: no cover except Exception: # pragma: no cover
@@ -1443,6 +1487,8 @@ def task_revoked_handler(
"reason": (reason + " Check worker logs for details."), "reason": (reason + " Check worker logs for details."),
}, },
) )
if task_instance := task_qs.first():
update_parent(task_instance)
if sender and sender.name == BULK_OPERATION_TASK_NAME: if sender and sender.name == BULK_OPERATION_TASK_NAME:
_send_bulk_operation_status(task_qs.first()) _send_bulk_operation_status(task_qs.first())
except Exception: # pragma: no cover except Exception: # pragma: no cover
+242
View File
@@ -0,0 +1,242 @@
"""Completion tracking for operations which dispatch document processing tasks."""
from __future__ import annotations
import uuid
from typing import TYPE_CHECKING
from celery import shared_task
from django.db import transaction
from django.utils import timezone
from documents.models import PaperlessTask
if TYPE_CHECKING:
from celery.canvas import Signature
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
def queue_consumption(
*,
input_doc: ConsumableDocument,
overrides: DocumentMetadataOverrides,
trigger_source: str,
parent_task_id: str | None,
) -> None:
from documents.tasks import consume_file
kwargs = {"input_doc": input_doc, "overrides": overrides}
if parent_task_id is None:
consume_file.apply_async(
kwargs=kwargs,
headers={"trigger_source": trigger_source},
)
else:
queue_processing_tasks(parent_task_id, [consume_file.s(**kwargs)])
def queue_reprocessing_tasks(
parent_task_id: str,
document_ids: list[int],
*,
remote_ocr: bool,
) -> None:
from documents.tasks import update_document_content_maybe_archive_file
queue_processing_tasks(
parent_task_id,
[
update_document_content_maybe_archive_file.s(
document_id=document_id,
remote_ocr=remote_ocr,
)
for document_id in document_ids
],
)
def queue_processing_tasks(
parent_task_id: str,
signatures: list[Signature],
*,
delete_ids: list[int] | None = None,
asn_backup: dict | None = None,
) -> None:
from documents.signals.handlers import TRACKED_TASKS
from documents.signals.handlers import _extract_input_data
parent = PaperlessTask.objects.get(task_id=parent_task_id)
children = [
PaperlessTask(
task_id=str(uuid.uuid4()),
task_type=TRACKED_TASKS[signature.task],
trigger_source=parent.trigger_source,
owner_id=parent.owner_id,
parent=parent,
input_data=_extract_input_data(
TRACKED_TASKS[signature.task],
signature.kwargs,
),
)
for signature in signatures
]
# Dependencies and their final step must be registered together.
with transaction.atomic():
PaperlessTask.objects.bulk_create(children)
if delete_ids:
PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_type=PaperlessTask.TaskType.BULK_DELETE,
owner_id=parent.owner_id,
trigger_source=parent.trigger_source,
parent=parent,
input_data={
"dependencies": [child.pk for child in children],
"delete_ids": delete_ids,
"asn_backup": asn_backup or {},
},
)
for index, (signature, child) in enumerate(zip(signatures, children, strict=True)):
try:
signature.apply_async(
task_id=child.task_id,
headers={"trigger_source": parent.trigger_source},
)
except Exception:
PaperlessTask.objects.filter(
pk__in=[task.pk for task in children[index:]],
).update(
status=PaperlessTask.Status.FAILURE,
date_done=timezone.now(),
result_data={"error_message": "Could not publish processing task."},
)
raise
@shared_task
def finish_file_operation(
dependencies: list[int],
delete_ids: list[int],
asn_backup: dict,
) -> dict[str, str]:
from documents.bulk_edit import delete
from documents.bulk_edit import restore_archive_serial_numbers
statuses = list(
PaperlessTask.objects.filter(pk__in=dependencies).values_list(
"status",
flat=True,
),
)
if len(statuses) != len(dependencies):
raise ValueError(
"Processing task records are missing; originals were preserved.",
)
if any(status != PaperlessTask.Status.SUCCESS for status in statuses):
restore_archive_serial_numbers(asn_backup)
return {"reason": "Originals preserved; archive serial numbers restored."}
delete(delete_ids)
return {"reason": "Originals moved to trash."}
def _publish_finalizer(task: PaperlessTask) -> None:
try:
finish_file_operation.apply_async(
task_id=task.task_id,
kwargs=task.input_data,
headers={"trigger_source": task.trigger_source},
)
except Exception as error:
from documents.signals.handlers import task_failure_handler
task_failure_handler(
sender=finish_file_operation,
task_id=task.task_id,
exception=error,
)
def _schedule_finalizers(parent: PaperlessTask) -> None:
for task in parent.children.filter(
task_type=PaperlessTask.TaskType.BULK_DELETE,
status=PaperlessTask.Status.PENDING,
):
dependencies = task.input_data.get("dependencies")
if dependencies is None or task.result_data:
continue
if (
PaperlessTask.objects.filter(pk__in=dependencies)
.exclude(
status__in=PaperlessTask.COMPLETE_STATUSES,
)
.exists()
):
continue
task.result_data = {"scheduled": True}
task.save(update_fields=["result_data"])
transaction.on_commit(lambda task=task: _publish_finalizer(task))
def finish_dispatch(task: PaperlessTask, result_data: dict) -> None:
"""Record the scheduling outcome without completing an active parent."""
with transaction.atomic():
parent = PaperlessTask.objects.select_for_update().get(pk=task.pk)
if parent.is_complete:
return
parent.dispatched = True
parent.result_data = result_data
parent.save(update_fields=["dispatched", "result_data"])
_finalize_parent(parent)
def update_parent(task: PaperlessTask) -> None:
if task.parent_id is None:
return
with transaction.atomic():
parent = PaperlessTask.objects.select_for_update().get(pk=task.parent_id)
_finalize_parent(parent)
def _finalize_parent(parent: PaperlessTask) -> None:
if parent.is_complete or not parent.dispatched:
return
_schedule_finalizers(parent)
statuses = list(parent.children.values_list("status", flat=True))
if any(status not in PaperlessTask.COMPLETE_STATUSES for status in statuses):
return
succeeded = statuses.count(PaperlessTask.Status.SUCCESS)
failed = len(statuses) - succeeded
result_data = parent.result_data or {}
if failed and not result_data.get("error_message"):
result_data["error_message"] = (
f"{succeeded} child task(s) succeeded; {failed} failed or were revoked. "
"See child tasks for details."
)
parent.status = (
PaperlessTask.Status.FAILURE
if result_data.get("error_message")
else PaperlessTask.Status.SUCCESS
)
parent.result_data = {
**parent.input_data,
**result_data,
"succeeded_count": succeeded,
"failed_count": failed,
}
parent.date_done = timezone.now()
if parent.date_started:
parent.duration_seconds = (
parent.date_done - parent.date_started
).total_seconds()
parent.wait_time_seconds = (
parent.date_started - parent.date_created
).total_seconds()
parent.save()
from documents.signals.handlers import _send_bulk_operation_status
if "operation" in parent.input_data:
transaction.on_commit(lambda: _send_bulk_operation_status(parent))
update_parent(parent)
+11 -4
View File
@@ -394,11 +394,10 @@ def update_document_content_maybe_archive_file(
) )
if not parser_class: if not parser_class:
logger.error( raise ValueError(
f"No parser found for mime type {mime_type}, cannot " f"No parser found for mime type {mime_type}, cannot "
f"archive document {document} (ID: {document_id})", f"reprocess document {document} (ID: {document_id})",
) )
return
with parser_class() as parser: with parser_class() as parser:
parser.configure(ParserContext()) parser.configure(ParserContext())
@@ -512,6 +511,7 @@ def update_document_content_maybe_archive_file(
logger.exception( logger.exception(
f"Error while parsing document {document} (ID: {document_id})", f"Error while parsing document {document} (ID: {document_id})",
) )
raise
@shared_task @shared_task
@@ -713,8 +713,9 @@ def run_workflows_for_documents(
logger.warning( logger.warning(
f"Manual workflow run requested for missing workflow {workflow_id}", f"Manual workflow run requested for missing workflow {workflow_id}",
) )
return raise
failed_ids = []
for document in Document.objects.filter( for document in Document.objects.filter(
id__in=document_ids, id__in=document_ids,
root_document__isnull=True, root_document__isnull=True,
@@ -732,10 +733,16 @@ def run_workflows_for_documents(
document=document, document=document,
) )
except Exception: except Exception:
failed_ids.append(document.pk)
logger.exception( logger.exception(
f"Error running workflow {workflow} on document {document.pk}", f"Error running workflow {workflow} on document {document.pk}",
) )
if failed_ids:
raise RuntimeError(
f"Workflow {workflow_id} failed for document(s): {failed_ids}. Check logs for details.",
)
def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None: def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None:
""" """
+9 -4
View File
@@ -698,8 +698,8 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
def test_delete_documents_old_uuid_field(self, m) -> None: def test_delete_documents_old_uuid_field(self, m) -> None:
m.side_effect = Exception("Data too long for column 'transaction_id' at row 1") m.side_effect = Exception("Data too long for column 'transaction_id' at row 1")
doc_ids = [self.doc1.id, self.doc2.id, self.doc3.id] doc_ids = [self.doc1.id, self.doc2.id, self.doc3.id]
bulk_edit.delete(doc_ids)
with self.assertLogs(level="WARNING") as cm: with self.assertLogs(level="WARNING") as cm:
with self.assertRaisesRegex(Exception, "Data too long for column"):
bulk_edit.delete(doc_ids) bulk_edit.delete(doc_ids)
self.assertIn("possible incompatible database column", cm.output[0]) self.assertIn("possible incompatible database column", cm.output[0])
@@ -1038,8 +1038,9 @@ class TestPDFActions(DirectoriesMixin, TestCase):
mock_consume_file.assert_not_called() mock_consume_file.assert_not_called()
@mock.patch("documents.bulk_edit.group")
@mock.patch("documents.tasks.consume_file.s") @mock.patch("documents.tasks.consume_file.s")
def test_split(self, mock_consume_file) -> None: def test_split(self, mock_consume_file, mock_group) -> None:
""" """
GIVEN: GIVEN:
- Existing documents - Existing documents
@@ -1060,6 +1061,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
self.assertIsNone(mock_consume_file.call_args.kwargs["overrides"].asn) self.assertIsNone(mock_consume_file.call_args.kwargs["overrides"].asn)
self.assertEqual(result, "OK") self.assertEqual(result, "OK")
mock_group.return_value.delay.assert_called_once()
@mock.patch("documents.bulk_edit.group") @mock.patch("documents.bulk_edit.group")
@mock.patch("documents.pdf_ops.build_pdfs") @mock.patch("documents.pdf_ops.build_pdfs")
@@ -1168,8 +1170,8 @@ class TestPDFActions(DirectoriesMixin, TestCase):
sig.apply_async.side_effect = Exception("boom") sig.apply_async.side_effect = Exception("boom")
mock_chord.return_value = sig mock_chord.return_value = sig
result = bulk_edit.split(doc_ids, pages, delete_originals=True) with self.assertRaisesMessage(Exception, "boom"):
self.assertEqual(result, "OK") bulk_edit.split(doc_ids, pages, delete_originals=True)
self.doc2.refresh_from_db() self.doc2.refresh_from_db()
self.assertEqual(self.doc2.archive_serial_number, 222) self.assertEqual(self.doc2.archive_serial_number, 222)
@@ -1191,6 +1193,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
pages = [[1, 2], [3]] pages = [[1, 2], [3]]
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm: with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
with self.assertRaisesMessage(Exception, "Error building PDFs"):
bulk_edit.split(doc_ids, pages) bulk_edit.split(doc_ids, pages)
error_str = cm.output[0] error_str = cm.output[0]
expected_str = "Error splitting document 2" expected_str = "Error splitting document 2"
@@ -1240,6 +1243,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
doc_ids = [self.doc2.id, self.doc3.id] doc_ids = [self.doc2.id, self.doc3.id]
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm: with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
with self.assertRaisesMessage(Exception, "Error rotating PDF"):
bulk_edit.rotate(doc_ids, 90) bulk_edit.rotate(doc_ids, 90)
error_str = cm.output[0] error_str = cm.output[0]
expected_str = "Error rotating document" expected_str = "Error rotating document"
@@ -1375,6 +1379,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
pages = [1, 3] pages = [1, 3]
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm: with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
with self.assertRaisesMessage(Exception, "Error removing pages"):
bulk_edit.delete_pages(doc_ids, pages) bulk_edit.delete_pages(doc_ids, pages)
error_str = cm.output[0] error_str = cm.output[0]
expected_str = "Error deleting pages from document" expected_str = "Error deleting pages from document"
+167 -15
View File
@@ -54,6 +54,7 @@ def test_request_only_queues(bulk_setup, endpoint, operation, parameters):
"document_ids": [doc.pk], "document_ids": [doc.pk],
"parameters": parameters, "parameters": parameters,
"user_id": user.pk, "user_id": user.pk,
"trigger_source": "web_ui",
} }
assert enqueue.call_args.kwargs["headers"]["trigger_source"] == "web_ui" assert enqueue.call_args.kwargs["headers"]["trigger_source"] == "web_ui"
@@ -102,6 +103,119 @@ def test_permissions_rechecked_on_worker(bulk_setup):
assert doc.correspondent_id is None assert doc.correspondent_id is None
@pytest.mark.django_db
def test_document_action_runs_with_user_in_worker(bulk_setup):
user, _, doc, _ = bulk_setup
with mock.patch("documents.bulk_edit.rotate") as rotate:
result = execute_bulk_operation(
"rotate",
[doc.pk],
{"degrees": 90},
user.pk,
trigger_source="web_ui",
)
rotate.assert_called_once_with(
[doc.pk],
degrees=90,
user=user,
trigger_source="web_ui",
)
assert result == {"operation": "rotate", "document_count": 1}
@pytest.mark.django_db
def test_document_action_queue_contains_only_serializable_parameters(bulk_setup):
user, client, doc, _ = bulk_setup
with mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue:
enqueue.return_value.id = "rotate-task"
response = client.post(
"/api/documents/rotate/",
{"documents": [doc.pk], "degrees": 90, "from_webui": True},
format="json",
)
assert response.data == {"result": "OK", "task_id": "rotate-task"}
assert enqueue.call_args.kwargs["kwargs"] == {
"operation": "rotate",
"document_ids": [doc.pk],
"parameters": {"degrees": 90, "source_mode": "latest_version"},
"user_id": user.pk,
"trigger_source": "web_ui",
}
@pytest.mark.django_db
def test_reprocess_keeps_bulk_audit_entry(bulk_setup, settings):
user, _, doc, _ = bulk_setup
settings.AUDIT_LOG_ENABLED = True
LogEntry.objects.all().delete()
with mock.patch("documents.bulk_edit.reprocess"):
execute_bulk_operation("reprocess", [doc.pk], {}, user.pk)
entry = LogEntry.objects.get_for_object(doc).get()
assert entry.changes_dict["checksum"] == [doc.checksum, doc.checksum]
assert entry.actor_id == user.pk
@pytest.mark.django_db
def test_workflow_runs_in_tracked_task_instead_of_dispatching_another(bulk_setup):
user, _, doc, _ = bulk_setup
with mock.patch("documents.bulk_edit.run_workflows_for_documents") as run:
execute_bulk_operation(
"run_workflow",
[doc.pk],
{"workflow": 7, "filter_documents": False},
user.pk,
)
run.assert_called_once_with(
document_ids=[doc.pk],
workflow_id=7,
filter_documents=False,
)
@pytest.mark.django_db
def test_delete_failure_propagates_from_worker(bulk_setup):
user, _, doc, _ = bulk_setup
with mock.patch(
"documents.models.Document.delete",
side_effect=RuntimeError("delete failed"),
):
with pytest.raises(RuntimeError, match="delete failed"):
execute_bulk_operation("delete", [doc.pk], {}, user.pk)
assert Document.objects.filter(pk=doc.pk).exists()
@pytest.mark.django_db
@pytest.mark.parametrize(
"operation,parameters,preparation",
[
("rotate", {"degrees": 90}, "rotate_pdf"),
("split", {"pages": [[1]]}, "build_pdfs"),
("delete_pages", {"pages": [1]}, "remove_pages"),
],
)
def test_preparation_failure_propagates_from_worker(
bulk_setup,
operation,
parameters,
preparation,
):
user, _, doc, _ = bulk_setup
doc.mime_type = "application/pdf"
doc.save()
with (
mock.patch(
f"documents.bulk_edit.pdf_ops.{preparation}",
side_effect=RuntimeError("preparation failed"),
),
mock.patch("documents.bulk_edit.consume_file.apply_async") as consume,
mock.patch("documents.bulk_edit.consume_file.s") as consume_signature,
pytest.raises(RuntimeError, match="preparation failed"),
):
execute_bulk_operation(operation, [doc.pk], parameters, user.pk)
consume.assert_not_called()
consume_signature.assert_not_called()
@pytest.mark.django_db @pytest.mark.django_db
def test_queue_failure_does_not_change_documents(bulk_setup): def test_queue_failure_does_not_change_documents(bulk_setup):
_, client, doc, correspondent = bulk_setup _, client, doc, correspondent = bulk_setup
@@ -162,6 +276,22 @@ def test_tracked_outcome_records_initiator_and_result(bulk_setup, failed):
assert task.result_data == {"document_count": 1} assert task.result_data == {"document_count": 1}
@pytest.mark.django_db
def test_queued_delete_is_tracked_as_bulk_delete(bulk_setup):
user, _, doc, _ = bulk_setup
before_task_publish_handler(
headers={"task": execute_bulk_operation.name, "id": "delete-task"},
body=(
(),
{"operation": "delete", "document_ids": [doc.pk], "user_id": user.pk},
{},
),
)
task = PaperlessTask.objects.get(task_id="delete-task")
assert task.task_type == PaperlessTask.TaskType.BULK_DELETE
assert task.owner_id == user.pk
@pytest.mark.django_db @pytest.mark.django_db
def test_large_filtered_selection_is_one_task(bulk_setup): def test_large_filtered_selection_is_one_task(bulk_setup):
_, client, _, _ = bulk_setup _, client, _, _ = bulk_setup
@@ -284,24 +414,46 @@ async def test_completion_socket_is_private(anyio_backend):
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.parametrize("endpoint", ["delete", "bulk_edit"]) def test_legacy_trash_queues_without_deleting_in_request(bulk_setup):
def test_trash_remains_synchronous(bulk_setup, endpoint): user, client, doc, _ = bulk_setup
_, client, doc, _ = bulk_setup data = {"documents": [doc.pk], "method": "delete", "parameters": {}}
data = {"documents": [doc.pk]} with mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue:
if endpoint == "bulk_edit": enqueue.return_value.id = "delete-task"
data.update(method="delete", parameters={})
with (
mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue,
mock.patch("documents.search.get_backend"),
):
response = client.post( response = client.post(
f"/api/documents/{endpoint}/", "/api/documents/bulk_edit/",
data, data,
format="json", format="json",
HTTP_ACCEPT="application/json; version=9", HTTP_ACCEPT="application/json; version=9",
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.data == {"result": "OK"} assert response.data == {"result": "OK", "task_id": "delete-task"}
assert not Document.objects.filter(pk=doc.pk).exists() assert Document.objects.filter(pk=doc.pk).exists()
assert Document.deleted_objects.filter(pk=doc.pk).exists() assert enqueue.call_args.kwargs["kwargs"] == {
enqueue.assert_not_called() "operation": "delete",
"document_ids": [doc.pk],
"parameters": {},
"user_id": user.pk,
"trigger_source": "api_upload",
}
@pytest.mark.django_db
def test_dedicated_trash_queues_without_deleting_in_request(bulk_setup):
user, client, doc, _ = bulk_setup
with mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue:
enqueue.return_value.id = "delete-task"
response = client.post(
"/api/documents/delete/",
{"documents": [doc.pk]},
format="json",
)
assert response.status_code == 200
assert response.data == {"result": "OK", "task_id": "delete-task"}
assert Document.objects.filter(pk=doc.pk).exists()
assert enqueue.call_args.kwargs["kwargs"] == {
"operation": "delete",
"document_ids": [doc.pk],
"parameters": {},
"user_id": user.pk,
"trigger_source": "api_upload",
}
+1
View File
@@ -69,6 +69,7 @@ class TestArchiver(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
doc.save() doc.save()
shutil.copy(sample_file, doc.source_path) shutil.copy(sample_file, doc.source_path)
with self.assertRaisesMessage(ValueError, "No parser found"):
update_document_content_maybe_archive_file(doc.pk) update_document_content_maybe_archive_file(doc.pk)
doc = Document.objects.get(id=doc.id) doc = Document.objects.get(id=doc.id)
@@ -8,6 +8,7 @@ from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from documents.bulk_edit import merge_as_versions from documents.bulk_edit import merge_as_versions
from documents.bulk_operations import execute_bulk_operation
from documents.models import Document from documents.models import Document
from documents.serialisers import MergeDocumentsAsVersionsSerializer from documents.serialisers import MergeDocumentsAsVersionsSerializer
from paperless_testing.factories import UserFactory from paperless_testing.factories import UserFactory
@@ -34,6 +35,7 @@ class TestMergeDocumentsAsVersionsSerializer(TestCase):
{ {
"documents": [self.doc1.id, self.doc2.id, self.doc3.id], "documents": [self.doc1.id, self.doc2.id, self.doc3.id],
"root_document_id": self.doc2.id, "root_document_id": self.doc2.id,
"from_webui": False,
}, },
) )
@@ -103,67 +105,6 @@ class TestMergeDocumentsAsVersionsSerializer(TestCase):
self.assertFalse(serializer.is_valid()) self.assertFalse(serializer.is_valid())
self.assertIn("documents", serializer.errors) self.assertIn("documents", serializer.errors)
def test_rejects_selected_version(self) -> None:
version = Document.objects.create(
checksum="D",
title="D",
root_document=self.doc1,
version_index=1,
)
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [version.id, self.doc2.id],
"root_document_id": self.doc2.id,
},
)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors["non_field_errors"][0],
"Only top-level documents can be merged as versions.",
)
def test_rejects_source_document_with_versions(self) -> None:
Document.objects.create(
checksum="D",
title="D",
root_document=self.doc1,
version_index=1,
)
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc2.id,
},
)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors["non_field_errors"][0],
"Documents with existing versions cannot be merged into another document.",
)
def test_rejects_source_document_with_trashed_versions(self) -> None:
version = Document.objects.create(
checksum="D",
title="D",
root_document=self.doc1,
version_index=1,
)
version.delete() # trashed, but still points at doc1
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc2.id,
},
)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors["non_field_errors"][0],
"Documents with existing versions cannot be merged into another document.",
)
def test_allows_root_document_with_versions(self) -> None: def test_allows_root_document_with_versions(self) -> None:
Document.objects.create( Document.objects.create(
checksum="D", checksum="D",
@@ -182,6 +123,95 @@ class TestMergeDocumentsAsVersionsSerializer(TestCase):
class TestMergeDocumentsAsVersions(TestCase): class TestMergeDocumentsAsVersions(TestCase):
def setUp(self) -> None:
self.doc1 = Document.objects.create(checksum="test-A", title="A")
self.doc2 = Document.objects.create(checksum="test-B", title="B")
def test_rejects_selected_version(self) -> None:
version = Document.objects.create(
checksum="D",
title="D",
root_document=self.doc1,
version_index=1,
)
with self.assertRaisesMessage(
ValueError,
"Only top-level documents can be merged as versions.",
):
merge_as_versions(
[version.id, self.doc2.id],
root_document_id=self.doc2.id,
)
def test_rejects_source_document_with_versions(self) -> None:
Document.objects.create(
checksum="D",
title="D",
root_document=self.doc1,
version_index=1,
)
with self.assertRaisesMessage(
ValueError,
"Documents with existing versions cannot be merged into another document.",
):
merge_as_versions(
[self.doc1.id, self.doc2.id],
root_document_id=self.doc2.id,
)
def test_rejects_source_document_with_trashed_versions(self) -> None:
version = Document.objects.create(
checksum="D",
title="D",
root_document=self.doc1,
version_index=1,
)
version.delete() # trashed, but still points at doc1
with self.assertRaisesMessage(
ValueError,
"Documents with existing versions cannot be merged into another document.",
):
merge_as_versions(
[self.doc1.id, self.doc2.id],
root_document_id=self.doc2.id,
)
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.search.get_backend")
def test_rejects_source_that_gained_versions_after_validation(
self,
_get_backend_mock,
_bulk_update_mock,
_status_manager_mock,
) -> None:
destination = Document.objects.create(checksum="destination")
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, destination.id],
"root_document_id": destination.id,
},
)
self.assertTrue(serializer.is_valid(), serializer.errors)
merge_as_versions(
[self.doc1.id, self.doc2.id],
root_document_id=self.doc1.id,
)
with self.assertRaisesMessage(
ValueError,
"Documents with existing versions cannot be merged into another document.",
):
merge_as_versions(
serializer.validated_data["documents"],
root_document_id=serializer.validated_data["root_document_id"],
)
self.doc1.refresh_from_db()
self.doc2.refresh_from_db()
self.assertIsNone(self.doc1.root_document_id)
self.assertEqual(self.doc2.root_document_id, self.doc1.id)
@mock.patch("documents.bulk_edit.DocumentsStatusManager") @mock.patch("documents.bulk_edit.DocumentsStatusManager")
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async") @mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.search.get_backend") @mock.patch("documents.search.get_backend")
@@ -348,6 +378,17 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
owner=self.user, owner=self.user,
) )
self.client.force_authenticate(user=self.user) self.client.force_authenticate(user=self.user)
enqueue = mock.patch(
"documents.views.execute_bulk_operation.apply_async",
side_effect=lambda *, kwargs, headers: self.run_operation(kwargs),
)
enqueue.start()
self.addCleanup(enqueue.stop)
@staticmethod
def run_operation(kwargs):
execute_bulk_operation(**kwargs)
return mock.Mock(id="test-bulk-operation")
@mock.patch("documents.views.bulk_edit.merge_as_versions") @mock.patch("documents.views.bulk_edit.merge_as_versions")
def test_merges_documents_as_versions(self, merge_mock) -> None: def test_merges_documents_as_versions(self, merge_mock) -> None:
@@ -367,7 +408,10 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, {"result": "OK"}) self.assertEqual(
response.data,
{"result": "OK", "task_id": "test-bulk-operation"},
)
merge_mock.assert_called_once_with( merge_mock.assert_called_once_with(
[self.doc1.id, self.doc2.id], [self.doc1.id, self.doc2.id],
root_document_id=self.doc2.id, root_document_id=self.doc2.id,
@@ -440,8 +484,9 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
merge_mock.assert_not_called() merge_mock.assert_not_called()
@mock.patch("documents.views.bulk_edit.merge_as_versions") @mock.patch("documents.views.execute_bulk_operation.apply_async")
def test_rejects_source_document_with_versions(self, merge_mock) -> None: def test_queues_source_document_with_versions(self, enqueue_mock) -> None:
enqueue_mock.return_value.id = "queued-merge"
Document.objects.create( Document.objects.create(
checksum="C", checksum="C",
title="C", title="C",
@@ -459,8 +504,9 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
format="json", format="json",
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_200_OK)
merge_mock.assert_not_called() enqueue_mock.assert_called_once()
self.assertEqual(response.data["task_id"], "queued-merge")
self.doc1.refresh_from_db() self.doc1.refresh_from_db()
self.assertIsNone(self.doc1.root_document_id) self.assertIsNone(self.doc1.root_document_id)
+543
View File
@@ -0,0 +1,543 @@
import shutil
import uuid
from unittest import mock
import pytest
from documents.bulk_operations import execute_bulk_operation
from documents.models import PaperlessTask
from documents.signals.handlers import task_failure_handler
from documents.signals.handlers import task_postrun_handler
from documents.signals.handlers import task_revoked_handler
from documents.tasks import update_document_content_maybe_archive_file
from paperless_testing.factories import DocumentFactory
from paperless_testing.factories import UserFactory
@pytest.fixture
def operation(paperless_dirs):
user = UserFactory(superuser=True)
documents = DocumentFactory.create_batch(3)
parent = PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_type=PaperlessTask.TaskType.BULK_UPDATE,
owner=user,
trigger_source=PaperlessTask.TriggerSource.WEB_UI,
input_data={"operation": "reprocess", "document_count": 3},
)
kwargs = {
"operation": "reprocess",
"document_ids": [document.pk for document in documents],
"parameters": {"remote_ocr": True},
"user_id": user.pk,
}
return parent, kwargs
@pytest.fixture
def pdf_documents(paperless_dirs, tmp_path):
from documents.models import Document
from paperless_testing.samples import SIMPLE_DIGITAL_PDF
documents = DocumentFactory.create_batch(2, mime_type="application/pdf")
for document in documents:
source = tmp_path / f"{document.pk}.pdf"
shutil.copy(SIMPLE_DIGITAL_PDF, source)
Document.objects.filter(pk=document.pk).update(filename=str(source))
document.refresh_from_db()
return documents
def complete(child, status="SUCCESS"):
if status == "FAILURE":
task_failure_handler(
sender=update_document_content_maybe_archive_file,
task_id=child.task_id,
exception=RuntimeError("Parser failed"),
)
elif status == "REVOKED":
task_revoked_handler(
sender=update_document_content_maybe_archive_file,
request=mock.Mock(id=child.task_id),
)
else:
task_postrun_handler(
task=update_document_content_maybe_archive_file,
task_id=child.task_id,
state=status,
)
@pytest.mark.django_db(transaction=True)
@pytest.mark.parametrize("outcome", ["SUCCESS", "FAILURE", "REVOKED"])
def test_parent_waits_for_all_children(operation, outcome):
parent, kwargs = operation
with (
mock.patch(
"documents.tasks.update_document_content_maybe_archive_file.apply_async",
) as publish,
mock.patch("documents.signals.handlers._send_bulk_operation_status") as notify,
):
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=True)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.STARTED
assert parent.date_done is None
children = list(parent.children.order_by("pk"))
assert len(children) == 3
assert publish.call_count == 3
assert all(child.owner_id == parent.owner_id for child in children)
assert all(child.trigger_source == parent.trigger_source for child in children)
assert all(call.args[1]["remote_ocr"] for call in publish.call_args_list)
complete(children[0], outcome)
complete(children[1])
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.STARTED
notify.assert_not_called()
complete(children[2])
parent.refresh_from_db()
assert parent.status == ("success" if outcome == "SUCCESS" else "failure")
assert parent.result_data["succeeded_count"] == (
3 if outcome == "SUCCESS" else 2
)
assert parent.result_data["failed_count"] == (0 if outcome == "SUCCESS" else 1)
assert parent.date_done is not None
assert parent.duration_seconds >= 0
notify.assert_called_once()
complete(children[2])
notify.assert_called_once()
@pytest.mark.django_db(transaction=True)
@pytest.mark.parametrize("outcome", ["SUCCESS", "FAILURE"])
def test_fast_children_wait_for_dispatch_to_finish(operation, outcome):
parent, kwargs = operation
def publish(*args, task_id, **kwargs):
# Every child is registered before the first publication.
assert parent.children.count() == 3
complete(PaperlessTask.objects.get(task_id=task_id), outcome)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.STARTED
assert not parent.dispatched
with (
mock.patch(
"documents.tasks.update_document_content_maybe_archive_file.apply_async",
side_effect=publish,
),
mock.patch("documents.signals.handlers._send_bulk_operation_status") as notify,
):
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=True)
parent.refresh_from_db()
assert parent.status == ("success" if outcome == "SUCCESS" else "failure")
notify.assert_called_once()
@pytest.mark.django_db(transaction=True)
@pytest.mark.parametrize("published_count", [0, 1])
def test_publish_failure_waits_for_published_children(operation, published_count):
parent, kwargs = operation
with (
mock.patch(
"documents.tasks.update_document_content_maybe_archive_file.apply_async",
side_effect=[None] * published_count + [RuntimeError("Broker unavailable")],
),
mock.patch("documents.signals.handlers._send_bulk_operation_status") as notify,
):
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=False)
parent.refresh_from_db()
if published_count:
assert parent.status == PaperlessTask.Status.STARTED
notify.assert_not_called()
complete(parent.children.get(status=PaperlessTask.Status.PENDING))
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.FAILURE
assert parent.result_data["error_message"] == "Broker unavailable"
assert parent.result_data["succeeded_count"] == published_count
assert parent.result_data["failed_count"] == 3 - published_count
notify.assert_called_once()
@pytest.mark.django_db(transaction=True)
def test_empty_selection_completes_without_children(operation):
parent, kwargs = operation
kwargs["document_ids"] = []
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=True)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.SUCCESS
assert not parent.children.exists()
@pytest.mark.django_db
def test_client_cannot_supply_parent_task_id(operation):
from rest_framework.test import APIClient
parent, kwargs = operation
client = APIClient()
client.force_authenticate(parent.owner)
with mock.patch("documents.views.execute_bulk_operation.apply_async") as publish:
response = client.post(
"/api/documents/bulk_edit/",
{
"documents": kwargs["document_ids"],
"method": "set_correspondent",
"parameters": {"correspondent": None, "parent_task_id": parent.task_id},
},
format="json",
)
assert response.status_code == 400
publish.assert_not_called()
@pytest.mark.django_db(transaction=True)
@pytest.mark.parametrize(
"name,parameters",
[
("rotate", {"degrees": 90}),
("merge", {}),
("split", {"pages": [[1]]}),
("delete_pages", {"pages": [1]}),
("edit_pdf", {"operations": [{"page": 1}], "update_document": True}),
("edit_pdf", {"operations": [{"page": 1}]}),
("remove_password", {"password": "secret", "update_document": True}),
("remove_password", {"password": "secret"}),
],
)
def test_file_operations_wait_for_consumption(
operation,
pdf_documents,
name,
parameters,
):
from documents.tasks import consume_file
parent, kwargs = operation
documents = pdf_documents
kwargs.update(
operation=name,
document_ids=[doc.pk for doc in documents]
if name == "merge"
else [documents[0].pk],
parameters=parameters,
)
parent.input_data = {
"operation": name,
"document_count": len(kwargs["document_ids"]),
}
parent.save()
with (
mock.patch("documents.tasks.consume_file.apply_async") as publish,
mock.patch("documents.bulk_edit.pdf_ops.needs_decrypt", return_value=True),
mock.patch(
"documents.bulk_edit.pdf_ops.decrypt_pdf",
return_value=documents[0].source_path,
),
):
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=True)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.STARTED
child = parent.children.get()
assert child.task_type == PaperlessTask.TaskType.CONSUME_FILE
publish.assert_called_once()
task_postrun_handler(
task=consume_file,
task_id=child.task_id,
state="SUCCESS",
retval={"document_id": 100},
)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.SUCCESS
@pytest.mark.django_db(transaction=True)
@pytest.mark.parametrize(
"outcome",
["success", "failure", "duplicate", "stopped", "revoked"],
)
def test_replacement_waits_for_deletion_or_recovery(operation, pdf_documents, outcome):
from documents.models import Document
from documents.task_tracking import finish_file_operation
from documents.tasks import consume_file
parent, kwargs = operation
doc = pdf_documents[0]
doc.archive_serial_number = 123
Document.objects.filter(pk=doc.pk).update(archive_serial_number=123)
kwargs.update(
operation="edit_pdf",
document_ids=[doc.pk],
parameters={"operations": [{"page": 1}], "delete_original": True},
)
parent.input_data = {"operation": "edit_pdf", "document_count": 1}
parent.save()
with (
mock.patch("documents.tasks.consume_file.apply_async"),
mock.patch(
"documents.task_tracking.finish_file_operation.apply_async",
) as finalize,
mock.patch("documents.signals.handlers._send_bulk_operation_status") as notify,
):
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=True)
consume = parent.children.get(task_type=PaperlessTask.TaskType.CONSUME_FILE)
finalizer = parent.children.get(task_type=PaperlessTask.TaskType.BULK_DELETE)
finalize.assert_not_called()
if outcome == "failure":
complete(consume, "FAILURE")
elif outcome == "revoked":
complete(consume, "REVOKED")
else:
retval = (
{"document_id": 100}
if outcome == "success"
else (
{"duplicate_of": 100}
if outcome == "duplicate"
else {"reason": "Stopped"}
)
)
task_postrun_handler(
task=consume_file,
task_id=consume.task_id,
state="SUCCESS",
retval=retval,
)
finalize.assert_called_once()
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.STARTED
assert Document.objects.filter(pk=doc.pk).exists()
notify.assert_not_called()
finish_file_operation.apply(
kwargs=finalizer.input_data,
task_id=finalizer.task_id,
throw=True,
)
parent.refresh_from_db()
assert parent.status == ("success" if outcome == "success" else "failure")
notify.assert_called_once()
if outcome == "success":
assert not Document.objects.filter(pk=doc.pk).exists()
else:
doc.refresh_from_db()
assert not doc.is_deleted
assert doc.archive_serial_number == 123
@pytest.mark.django_db(transaction=True)
def test_consumption_descendants_keep_operation_active(operation):
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentSource
from documents.signals.handlers import before_task_publish_handler
from documents.task_tracking import finish_dispatch
from documents.task_tracking import queue_processing_tasks
from documents.tasks import consume_file
from paperless_testing.samples import SIMPLE_DIGITAL_PDF
parent, _ = operation
parent.status = PaperlessTask.Status.STARTED
parent.save()
input_doc = ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=SIMPLE_DIGITAL_PDF,
)
with mock.patch("documents.tasks.consume_file.apply_async"):
queue_processing_tasks(parent.task_id, [consume_file.s(input_doc=input_doc)])
child = parent.children.get()
finish_dispatch(parent, {})
descendant_id = str(uuid.uuid4())
before_task_publish_handler(
headers={
"task": consume_file.name,
"id": descendant_id,
"parent_id": child.task_id,
},
body=((), {"input_doc": input_doc}, {}),
)
descendant = PaperlessTask.objects.get(task_id=descendant_id)
assert descendant.parent_id == child.pk
assert descendant.owner_id == parent.owner_id
task_postrun_handler(
task=consume_file,
task_id=child.task_id,
state="SUCCESS",
retval={"reason": "Barcode splitting complete"},
)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.STARTED
task_postrun_handler(
task=consume_file,
task_id=descendant_id,
state="SUCCESS",
retval={"document_id": 100},
)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.SUCCESS
@pytest.mark.django_db(transaction=True)
@pytest.mark.parametrize("failure", ["delete", "restore", "publish"])
def test_final_step_failures_reach_parent(operation, pdf_documents, failure):
from documents.task_tracking import finish_file_operation
from documents.tasks import consume_file
parent, kwargs = operation
doc = pdf_documents[0]
kwargs.update(
operation="edit_pdf",
document_ids=[doc.pk],
parameters={"operations": [{"page": 1}], "delete_original": True},
)
with (
mock.patch("documents.tasks.consume_file.apply_async"),
mock.patch(
"documents.task_tracking.finish_file_operation.apply_async",
side_effect=RuntimeError("Publish failed")
if failure == "publish"
else None,
),
mock.patch("documents.signals.handlers._send_bulk_operation_status") as notify,
):
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=True)
child = parent.children.get(task_type=PaperlessTask.TaskType.CONSUME_FILE)
if failure == "restore":
complete(child, "FAILURE")
else:
task_postrun_handler(
task=consume_file,
task_id=child.task_id,
state="SUCCESS",
retval={"document_id": 100},
)
finalizer = parent.children.get(task_type=PaperlessTask.TaskType.BULK_DELETE)
if failure != "publish":
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.STARTED
notify.assert_not_called()
target = (
"documents.bulk_edit.delete"
if failure == "delete"
else "documents.bulk_edit.restore_archive_serial_numbers"
)
with mock.patch(target, side_effect=RuntimeError("Final step failed")):
finish_file_operation.apply(
kwargs=finalizer.input_data,
task_id=finalizer.task_id,
throw=False,
)
parent.refresh_from_db()
finalizer.refresh_from_db()
assert finalizer.status == PaperlessTask.Status.FAILURE
assert parent.status == PaperlessTask.Status.FAILURE
notify.assert_called_once()
doc.refresh_from_db()
assert not doc.is_deleted
@pytest.mark.django_db(transaction=True)
def test_partial_merge_preserves_skipped_original_and_reports_it(
operation,
pdf_documents,
):
from documents.models import Document
from documents.task_tracking import finish_file_operation
from documents.tasks import consume_file
parent, kwargs = operation
included, excluded = pdf_documents
excluded.source_path.unlink()
kwargs.update(
operation="merge",
document_ids=[included.pk, excluded.pk],
parameters={"delete_originals": True},
)
parent.input_data = {"operation": "merge", "document_count": 2}
parent.save()
with (
mock.patch("documents.tasks.consume_file.apply_async") as consume,
mock.patch("documents.task_tracking.finish_file_operation.apply_async"),
):
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=True)
consume.assert_called_once()
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.STARTED
assert parent.result_data["failed_document_ids"] == [excluded.pk]
child = parent.children.get(task_type=PaperlessTask.TaskType.CONSUME_FILE)
task_postrun_handler(
task=consume_file,
task_id=child.task_id,
state="SUCCESS",
retval={"document_id": 100},
)
finalizer = parent.children.get(task_type=PaperlessTask.TaskType.BULK_DELETE)
assert finalizer.input_data["delete_ids"] == [included.pk]
finish_file_operation.apply(
kwargs=finalizer.input_data,
task_id=finalizer.task_id,
throw=True,
)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.FAILURE
assert parent.result_data["failed_document_ids"] == [excluded.pk]
assert not Document.objects.filter(pk=included.pk).exists()
assert Document.objects.filter(pk=excluded.pk).exists()
@pytest.mark.django_db(transaction=True)
def test_merge_with_no_valid_inputs_fails(operation, pdf_documents):
parent, kwargs = operation
for doc in pdf_documents:
doc.source_path.unlink()
kwargs.update(
operation="merge",
document_ids=[doc.pk for doc in pdf_documents],
parameters={},
)
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=False)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.FAILURE
assert not parent.children.exists()
@pytest.mark.django_db(transaction=True)
def test_split_waits_for_every_output_before_recovery(operation, pdf_documents):
from documents.task_tracking import finish_file_operation
from documents.tasks import consume_file
parent, kwargs = operation
doc = pdf_documents[0]
kwargs.update(
operation="split",
document_ids=[doc.pk],
parameters={"pages": [[1], [1]], "delete_originals": True},
)
with (
mock.patch("documents.tasks.consume_file.apply_async"),
mock.patch(
"documents.task_tracking.finish_file_operation.apply_async",
) as finalize,
):
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=True)
children = list(
parent.children.filter(task_type=PaperlessTask.TaskType.CONSUME_FILE),
)
assert len(children) == 2
complete(children[0], "FAILURE")
finalize.assert_not_called()
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.STARTED
task_postrun_handler(
task=consume_file,
task_id=children[1].task_id,
state="SUCCESS",
retval={"document_id": 100},
)
finalize.assert_called_once()
finalizer = parent.children.get(task_type=PaperlessTask.TaskType.BULK_DELETE)
finish_file_operation.apply(
kwargs=finalizer.input_data,
task_id=finalizer.task_id,
throw=True,
)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.FAILURE
doc.refresh_from_db()
assert not doc.is_deleted
+57
View File
@@ -1,4 +1,5 @@
import shutil import shutil
import uuid
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
@@ -16,6 +17,7 @@ from documents import tasks
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import PaperlessTask
from documents.models import Tag from documents.models import Tag
from documents.models import WorkflowAction from documents.models import WorkflowAction
from documents.sanity_checker import SanityCheckFailedException from documents.sanity_checker import SanityCheckFailedException
@@ -280,6 +282,60 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
tasks.update_document_content_maybe_archive_file(doc.pk) tasks.update_document_content_maybe_archive_file(doc.pk)
self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test") self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test")
@mock.patch("documents.tasks.get_parser_registry")
def test_missing_parser_records_failure(self, registry) -> None:
doc = DocumentFactory(content="Original content", mime_type="application/pdf")
registry.return_value.get_parser_for_file.return_value = None
task = PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_type=PaperlessTask.TaskType.REPROCESS_DOCUMENT,
)
result = tasks.update_document_content_maybe_archive_file.apply(
args=[doc.pk],
task_id=task.task_id,
throw=False,
)
self.assertTrue(result.failed())
task.refresh_from_db()
self.assertEqual(task.status, PaperlessTask.Status.FAILURE)
self.assertIn("No parser found", task.result_data["error_message"])
doc.refresh_from_db()
self.assertEqual(doc.content, "Original content")
@mock.patch("documents.tasks.should_produce_archive", return_value=False)
@mock.patch("documents.tasks.get_parser_registry")
def test_parser_failure_records_failure(self, registry, _produce_archive) -> None:
for method in ("parse", "get_thumbnail"):
with self.subTest(method=method):
doc = DocumentFactory(
content="Original content",
mime_type="application/pdf",
)
parser = mock.MagicMock()
getattr(parser, method).side_effect = RuntimeError("Processing failed")
parser_class = mock.MagicMock()
parser_class.return_value.__enter__.return_value = parser
registry.return_value.get_parser_for_file.return_value = parser_class
task = PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_type=PaperlessTask.TaskType.REPROCESS_DOCUMENT,
)
result = tasks.update_document_content_maybe_archive_file.apply(
args=[doc.pk],
task_id=task.task_id,
throw=False,
)
self.assertTrue(result.failed())
task.refresh_from_db()
self.assertEqual(task.status, PaperlessTask.Status.FAILURE)
self.assertEqual(task.result_data["error_message"], "Processing failed")
doc.refresh_from_db()
self.assertEqual(doc.content, "Original content")
def _create_root_with_version(self) -> tuple[Document, Document]: def _create_root_with_version(self) -> tuple[Document, Document]:
sample1 = self.dirs.scratch_dir / "sample.pdf" sample1 = self.dirs.scratch_dir / "sample.pdf"
shutil.copy(SIMPLE_DIGITAL_PDF, sample1) shutil.copy(SIMPLE_DIGITAL_PDF, sample1)
@@ -370,6 +426,7 @@ class TestUpdateContentRemoteOCR(DirectoriesMixin, TestCase):
) )
def _allow_remote(self, **kwargs) -> bool: def _allow_remote(self, **kwargs) -> bool:
with self.assertRaisesMessage(ValueError, "No parser found"):
tasks.update_document_content_maybe_archive_file(self.doc.pk, **kwargs) tasks.update_document_content_maybe_archive_file(self.doc.pk, **kwargs)
_, call_kwargs = self.mock_registry.return_value.get_parser_for_file.call_args _, call_kwargs = self.mock_registry.return_value.get_parser_for_file.call_args
return call_kwargs["allow_remote"] return call_kwargs["allow_remote"]
+12 -3
View File
@@ -2333,9 +2333,12 @@ class TestWorkflows(
self.assertEqual(doc.owner, self.user2) self.assertEqual(doc.owner, self.user2)
def test_manual_workflow_run_missing_workflow(self) -> None: def test_manual_workflow_run_missing_workflow(self) -> None:
"""A missing workflow id logs a warning rather than raising.""" """A workflow removed after queueing fails the task."""
doc = DocumentFactory() doc = DocumentFactory()
with self.assertLogs("paperless.tasks", level="WARNING"): with (
self.assertLogs("paperless.tasks", level="WARNING"),
self.assertRaises(Workflow.DoesNotExist),
):
tasks.run_workflows_for_documents([doc.pk], 9999) tasks.run_workflows_for_documents([doc.pk], 9999)
@mock.patch("documents.tasks.send_websocket_document_updated") @mock.patch("documents.tasks.send_websocket_document_updated")
@@ -2356,7 +2359,13 @@ class TestWorkflows(
mock_run_workflows.side_effect = side_effect mock_run_workflows.side_effect = side_effect
with self.assertLogs("paperless.tasks", level="ERROR"): with (
self.assertLogs("paperless.tasks", level="ERROR"),
self.assertRaisesMessage(
RuntimeError,
f"failed for document(s): [{doc1.pk}]",
),
):
tasks.run_workflows_for_documents([doc1.pk, doc2.pk], workflow.pk) tasks.run_workflows_for_documents([doc1.pk, doc2.pk], workflow.pk)
# Both documents were attempted; the websocket update only fired for the # Both documents were attempted; the websocket update only fired for the
+32 -77
View File
@@ -107,9 +107,9 @@ from documents import bulk_edit
from documents.bulk_download import ArchiveOnlyStrategy from documents.bulk_download import ArchiveOnlyStrategy
from documents.bulk_download import OriginalAndArchiveStrategy from documents.bulk_download import OriginalAndArchiveStrategy
from documents.bulk_download import OriginalsOnlyStrategy from documents.bulk_download import OriginalsOnlyStrategy
from documents.bulk_operations import QUEUED_OPERATIONS from documents.bulk_operations import TRIGGER_SOURCE_OPERATIONS
from documents.bulk_operations import USER_OPERATIONS
from documents.bulk_operations import execute_bulk_operation from documents.bulk_operations import execute_bulk_operation
from documents.bulk_operations import snapshot_field
from documents.caching import get_llm_suggestion_cache from documents.caching import get_llm_suggestion_cache
from documents.caching import get_metadata_cache from documents.caching import get_metadata_cache
from documents.caching import get_suggestion_cache from documents.caching import get_suggestion_cache
@@ -2974,38 +2974,31 @@ class DocumentSelectionMixin:
class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin): class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
parser_classes = (parsers.JSONParser,) parser_classes = (parsers.JSONParser,)
METHOD_NAMES_REQUIRING_USER = {
"split",
"merge",
"rotate",
"delete_pages",
"edit_pdf",
"remove_password",
"merge_as_versions",
}
# merge_as_versions doesn't queue any consume tasks
METHOD_NAMES_REQUIRING_TRIGGER_SOURCE = METHOD_NAMES_REQUIRING_USER - {
"merge_as_versions",
}
def _queue_bulk_operation(self, method, documents, parameters, from_webui): def _queue_bulk_operation(self, method, documents, parameters, from_webui):
if "update_callback" in parameters: for parameter in ("update_callback", "parent_task_id"):
raise ValueError("Unsupported bulk operation parameter: update_callback") if parameter in parameters:
signature(method).bind(documents, **parameters) raise ValueError(f"Unsupported bulk operation parameter: {parameter}")
trigger_source = (
PaperlessTask.TriggerSource.WEB_UI
if from_webui
else PaperlessTask.TriggerSource.API_UPLOAD
)
final_parameters = parameters.copy()
if method.__name__ in USER_OPERATIONS:
final_parameters["user"] = self.request.user
if method.__name__ in TRIGGER_SOURCE_OPERATIONS:
final_parameters["trigger_source"] = trigger_source
signature(method).bind(documents, **final_parameters)
task = execute_bulk_operation.apply_async( task = execute_bulk_operation.apply_async(
kwargs={ kwargs={
"operation": method.__name__, "operation": method.__name__,
"document_ids": documents, "document_ids": documents,
"parameters": parameters, "parameters": parameters,
"user_id": self.request.user.pk, "user_id": self.request.user.pk,
"trigger_source": trigger_source,
}, },
headers={ headers={"trigger_source": trigger_source},
"trigger_source": (
PaperlessTask.TriggerSource.WEB_UI
if from_webui
else PaperlessTask.TriggerSource.API_UPLOAD
),
},
) )
return Response({"result": "OK", "task_id": task.id}) return Response({"result": "OK", "task_id": task.id})
@@ -3129,15 +3122,6 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
user = self.request.user user = self.request.user
from_webui = validated_data.get("from_webui", False) from_webui = validated_data.get("from_webui", False)
if method.__name__ in self.METHOD_NAMES_REQUIRING_USER:
parameters["user"] = user
if method.__name__ in self.METHOD_NAMES_REQUIRING_TRIGGER_SOURCE:
parameters["trigger_source"] = (
PaperlessTask.TriggerSource.WEB_UI
if from_webui
else PaperlessTask.TriggerSource.API_UPLOAD
)
if not has_document_operation_permissions( if not has_document_operation_permissions(
user=user, user=user,
documents=documents, documents=documents,
@@ -3147,8 +3131,12 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
try: try:
result = method(documents, **parameters) return self._queue_bulk_operation(
return Response({"result": result}) method,
documents,
parameters,
from_webui,
)
except Exception as e: except Exception as e:
logger.warning(f"An error occurred performing {operation_label}: {e!s}") logger.warning(f"An error occurred performing {operation_label}: {e!s}")
return HttpResponseBadRequest( return HttpResponseBadRequest(
@@ -3169,19 +3157,13 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
name="BulkEditDocumentsResult", name="BulkEditDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(required=False), "task_id": serializers.CharField(),
}, },
), ),
}, },
), ),
) )
class BulkEditView(DocumentOperationPermissionMixin): class BulkEditView(DocumentOperationPermissionMixin):
LEGACY_MODIFIED_FIELDS = {
"delete": "deleted_at",
"reprocess": "checksum",
"remove_password": None,
}
serializer_class = BulkEditSerializer serializer_class = BulkEditSerializer
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@@ -3211,14 +3193,6 @@ class BulkEditView(DocumentOperationPermissionMixin):
user=user, user=user,
validated_data=serializer.validated_data, validated_data=serializer.validated_data,
) )
if method.__name__ in self.METHOD_NAMES_REQUIRING_USER:
parameters["user"] = user
if method.__name__ in self.METHOD_NAMES_REQUIRING_TRIGGER_SOURCE:
parameters["trigger_source"] = (
PaperlessTask.TriggerSource.WEB_UI
if from_webui
else PaperlessTask.TriggerSource.API_UPLOAD
)
if not has_document_operation_permissions( if not has_document_operation_permissions(
user=user, user=user,
documents=documents, documents=documents,
@@ -3228,38 +3202,12 @@ class BulkEditView(DocumentOperationPermissionMixin):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
try: try:
if method.__name__ in QUEUED_OPERATIONS:
return self._queue_bulk_operation( return self._queue_bulk_operation(
method, method,
documents, documents,
parameters, parameters,
from_webui, from_webui,
) )
modified_field = self.LEGACY_MODIFIED_FIELDS.get(method.__name__)
if settings.AUDIT_LOG_ENABLED and modified_field:
old_values = snapshot_field(documents, modified_field)
result = method(documents, **parameters)
if settings.AUDIT_LOG_ENABLED and modified_field:
new_values = snapshot_field(documents, modified_field)
for doc in Document.objects.filter(pk__in=documents):
LogEntry.objects.log_create(
instance=doc,
changes={
modified_field: [
old_values[doc.pk],
new_values[doc.pk],
],
},
action=LogEntry.Action.UPDATE,
actor=user,
additional_data={
"reason": f"Bulk edit: {method.__name__}",
},
)
return Response({"result": result})
except Exception as e: except Exception as e:
logger.warning(f"An error occurred performing bulk edit: {e!s}") logger.warning(f"An error occurred performing bulk edit: {e!s}")
return HttpResponseBadRequest( return HttpResponseBadRequest(
@@ -3276,6 +3224,7 @@ class BulkEditView(DocumentOperationPermissionMixin):
name="RotateDocumentsResult", name="RotateDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },
@@ -3303,6 +3252,7 @@ class RotateDocumentsView(DocumentOperationPermissionMixin):
name="MergeDocumentsResult", name="MergeDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },
@@ -3330,6 +3280,7 @@ class MergeDocumentsView(DocumentOperationPermissionMixin):
name="MergeDocumentsAsVersionsResult", name="MergeDocumentsAsVersionsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },
@@ -3357,6 +3308,7 @@ class MergeDocumentsAsVersionsView(DocumentOperationPermissionMixin):
name="DeleteDocumentsResult", name="DeleteDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },
@@ -3384,6 +3336,7 @@ class DeleteDocumentsView(DocumentOperationPermissionMixin):
name="ReprocessDocumentsResult", name="ReprocessDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },
@@ -3411,6 +3364,7 @@ class ReprocessDocumentsView(DocumentOperationPermissionMixin):
name="EditPdfDocumentsResult", name="EditPdfDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },
@@ -3438,6 +3392,7 @@ class EditPdfDocumentsView(DocumentOperationPermissionMixin):
name="RemovePasswordDocumentsResult", name="RemovePasswordDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },