Compare commits

..
Author SHA1 Message Date
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
39 changed files with 1505 additions and 400 deletions

No files matched your search

+6
View File
@@ -288,6 +288,12 @@ The API supports various bulk-editing operations which are executed asynchronous
For bulk operations on documents, use the endpoint `/api/documents/bulk_edit/` which accepts For bulk operations on documents, use the endpoint `/api/documents/bulk_edit/` which accepts
a json payload of the format: a json payload of the format:
Document bulk operations, including the dedicated document action endpoints, return
`{"result":"OK","task_id":"…"}` when queued. Use `/api/tasks/?task_id={uuid}`
to check the task outcome. Actions that create or reprocess files may start further
consumption or reprocessing tasks and completion of the bulk task means those tasks
were scheduled, not that their file processing has finished.
```json ```json
{ {
"documents": [LIST_OF_DOCUMENT_IDS], "documents": [LIST_OF_DOCUMENT_IDS],
@@ -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 ?? '')
}
+14 -2
View File
@@ -427,6 +427,7 @@ 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"
@@ -474,9 +475,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,
@@ -573,6 +574,7 @@ def rotate(
) )
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"
@@ -701,7 +703,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(
@@ -864,6 +874,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"
@@ -909,6 +920,7 @@ def delete_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"
+65 -2
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]:
@@ -66,6 +95,7 @@ def execute_bulk_operation(
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 +109,39 @@ 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 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 {}
)
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)}
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
+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(
+2
View File
@@ -1206,6 +1206,8 @@ 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)
+8 -1
View File
@@ -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",
}
@@ -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)
+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
+29 -75
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,30 @@ 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: if "update_callback" in parameters:
raise ValueError("Unsupported bulk operation parameter: update_callback") raise ValueError("Unsupported bulk operation parameter: update_callback")
signature(method).bind(documents, **parameters) 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 +3121,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 +3130,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 +3156,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 +3192,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 +3201,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 +3223,7 @@ class BulkEditView(DocumentOperationPermissionMixin):
name="RotateDocumentsResult", name="RotateDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },
@@ -3303,6 +3251,7 @@ class RotateDocumentsView(DocumentOperationPermissionMixin):
name="MergeDocumentsResult", name="MergeDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },
@@ -3330,6 +3279,7 @@ class MergeDocumentsView(DocumentOperationPermissionMixin):
name="MergeDocumentsAsVersionsResult", name="MergeDocumentsAsVersionsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },
@@ -3357,6 +3307,7 @@ class MergeDocumentsAsVersionsView(DocumentOperationPermissionMixin):
name="DeleteDocumentsResult", name="DeleteDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },
@@ -3384,6 +3335,7 @@ class DeleteDocumentsView(DocumentOperationPermissionMixin):
name="ReprocessDocumentsResult", name="ReprocessDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },
@@ -3411,6 +3363,7 @@ class ReprocessDocumentsView(DocumentOperationPermissionMixin):
name="EditPdfDocumentsResult", name="EditPdfDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },
@@ -3438,6 +3391,7 @@ class EditPdfDocumentsView(DocumentOperationPermissionMixin):
name="RemovePasswordDocumentsResult", name="RemovePasswordDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },