mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-15 10:26:42 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a9d4284d7 | ||
|
|
6043dd1c81 | ||
|
|
058e27e3ef | ||
|
|
6f5a192967 | ||
|
|
bb775a16c1 | ||
|
|
6d26d3f5ca | ||
|
|
2131feac73 | ||
|
|
e2705768b4 | ||
|
|
ce3c4e0855 | ||
|
|
3a241e51d9 | ||
|
|
020a2c8fc0 | ||
|
|
b533bfe4b5 | ||
|
|
813dfe487f | ||
|
|
557afa6454 | ||
|
|
c72473f7bc | ||
|
|
7d79b544ae | ||
|
|
a9fef2b931 | ||
|
|
adbd7561d4 | ||
|
|
9f694cdd2d | ||
|
|
7d78716069 | ||
|
|
9523d158a8 | ||
|
|
91316df9b7 | ||
|
|
7e67013c42 | ||
|
|
2a0c1249fa |
No files matched your search
@@ -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)
|
||||||
|
|||||||
+23
-14
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
+7
-9
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -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 ?? '')
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user