mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-14 10:02:45 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbc0144309 | ||
|
|
2cb5d87247 | ||
|
|
dc292d7ede | ||
|
|
c7fcdbed61 | ||
|
|
d2197bf05e | ||
|
|
cf11735a2b | ||
|
|
871711d394 | ||
|
|
823d1a7835 | ||
|
|
2813eed184 | ||
|
|
05426daee0 | ||
|
|
b11078fd92 | ||
|
|
f0e52756ea | ||
|
|
00301c503a |
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],
|
||||||
|
|||||||
@@ -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 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 failed. Some changes may already have been applied.',
|
||||||
|
error: 'Could not update',
|
||||||
|
})
|
||||||
)
|
)
|
||||||
|
expect(error).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
content: 'Operation 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,7 +36,7 @@ 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 clipboard = inject(Clipboard)
|
||||||
private customDatePipe = inject(CustomDatePipe)
|
private customDatePipe = inject(CustomDatePipe)
|
||||||
private destroyRef = inject(DestroyRef)
|
private destroyRef = inject(DestroyRef)
|
||||||
@@ -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 reprocessing completed.` }
|
||||||
)
|
)
|
||||||
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 completed.`,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
modal.close()
|
modal.close()
|
||||||
if (modal.componentInstance.deleteOriginal) {
|
|
||||||
this.openDocumentService.closeDocument(this.document())
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
@@ -2046,17 +2055,14 @@ 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 completed.` }
|
||||||
)
|
)
|
||||||
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) => {
|
||||||
|
|||||||
@@ -1239,14 +1239,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
|
||||||
@@ -1263,9 +1264,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
|
||||||
@@ -1345,10 +1346,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`
|
||||||
@@ -1379,11 +1381,12 @@ 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' })
|
||||||
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`
|
||||||
@@ -1421,10 +1424,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`
|
||||||
@@ -1439,11 +1443,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`
|
||||||
@@ -1460,11 +1465,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`
|
||||||
@@ -1476,6 +1482,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)
|
||||||
@@ -1503,8 +1510,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()
|
||||||
@@ -1520,9 +1527,11 @@ 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 queued.' })
|
||||||
)
|
)
|
||||||
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not report success when merging documents as versions fails', () => {
|
it('should not report success when merging documents as versions fails', () => {
|
||||||
@@ -1616,6 +1625,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
it.each([true, false])(
|
it.each([true, false])(
|
||||||
'shows queued feedback and gates the task link on permission (%s)',
|
'shows queued feedback and gates the task link on permission (%s)',
|
||||||
(canViewTasks) => {
|
(canViewTasks) => {
|
||||||
|
jest.useFakeTimers()
|
||||||
jest
|
jest
|
||||||
.spyOn(permissionsService, 'currentUserCan')
|
.spyOn(permissionsService, 'currentUserCan')
|
||||||
.mockReturnValue(canViewTasks)
|
.mockReturnValue(canViewTasks)
|
||||||
@@ -1636,6 +1646,8 @@ 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.')
|
expect(notification.content).toBe('Bulk operation queued.')
|
||||||
if (canViewTasks) {
|
if (canViewTasks) {
|
||||||
@@ -1651,6 +1663,7 @@ 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)
|
||||||
|
jest.useRealTimers()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +45,12 @@ 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 { flattenTags } from 'src/app/utils/flatten-tags'
|
import { flattenTags } from 'src/app/utils/flatten-tags'
|
||||||
@@ -84,7 +77,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 +102,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)
|
||||||
@@ -347,65 +338,39 @@ export class BulkEditorComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleOperationQueued(modal: NgbModalRef, taskId: string) {
|
private handleOperationQueued(
|
||||||
|
modal: NgbModalRef,
|
||||||
|
taskId: string,
|
||||||
|
onQueued?: (taskId: string) => void
|
||||||
|
) {
|
||||||
modal?.close()
|
modal?.close()
|
||||||
this.list.selectNone()
|
this.list.selectNone()
|
||||||
const canViewTasks = this.permissionService.currentUserCan(
|
if (onQueued) {
|
||||||
PermissionAction.View,
|
onQueued(taskId)
|
||||||
PermissionType.PaperlessTask
|
} else {
|
||||||
|
this.taskNotifications.showQueued(
|
||||||
|
taskId,
|
||||||
|
$localize`Bulk operation queued.`
|
||||||
)
|
)
|
||||||
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>,
|
request: Observable<BulkOperationResponse>,
|
||||||
options: { clearSelection?: boolean; successMessage?: string } = {}
|
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, 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)
|
||||||
@@ -973,16 +938,8 @@ export class BulkEditorComponent
|
|||||||
const count = this.getSelectionSize()
|
const count = this.getSelectionSize()
|
||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
modal,
|
modal,
|
||||||
this.documentService
|
this.documentService.deleteDocuments(this.getSelectionQuery()),
|
||||||
.deleteDocuments(this.getSelectionQuery())
|
(taskId) => this.trashService.trackMovedToTrash(taskId, count, documents)
|
||||||
.pipe(
|
|
||||||
tap(() =>
|
|
||||||
this.trashService.notifyMovedToTrash(
|
|
||||||
$localize`Moved ${count} document(s) to the trash`,
|
|
||||||
documents
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1096,11 +1053,7 @@ export class BulkEditorComponent
|
|||||||
mergeDialog.buttonsEnabled.set(false)
|
mergeDialog.buttonsEnabled.set(false)
|
||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
modal,
|
modal,
|
||||||
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args),
|
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args)
|
||||||
{ clearSelection: !!args.delete_originals }
|
|
||||||
)
|
|
||||||
this.toastService.showInfo(
|
|
||||||
$localize`Merged document will be queued for consumption.`
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1125,11 +1078,7 @@ export class BulkEditorComponent
|
|||||||
this.documentService.mergeDocumentsAsVersions(
|
this.documentService.mergeDocumentsAsVersions(
|
||||||
mergeDialog.documentIDs(),
|
mergeDialog.documentIDs(),
|
||||||
mergeDialog.rootDocumentID()
|
mergeDialog.rootDocumentID()
|
||||||
),
|
)
|
||||||
{
|
|
||||||
clearSelection: true,
|
|
||||||
successMessage: $localize`Documents merged as versions.`,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,145 @@
|
|||||||
|
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, retaining the task link', () => {
|
||||||
|
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.')
|
||||||
|
service.complete(result())
|
||||||
|
expect(notifications).toHaveLength(1)
|
||||||
|
expect(popups[1]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: popups[0].id,
|
||||||
|
content: 'Workflow completed.',
|
||||||
|
level: ToastLevel.Success,
|
||||||
|
actionName: 'View task',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
popups[1].action()
|
||||||
|
expect(navigate).toHaveBeenCalledWith(['/tasks'], {
|
||||||
|
queryParams: { task_id: 'task-1' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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 completed.',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
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.')
|
||||||
|
})
|
||||||
|
|
||||||
|
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 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
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
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 {
|
||||||
|
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 toastService = inject(ToastService)
|
||||||
|
private router = inject(Router)
|
||||||
|
private permissionsService = inject(PermissionsService)
|
||||||
|
private pending = new Map<string, PendingNotification>()
|
||||||
|
private 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
|
||||||
|
this.show(result.task_id, {
|
||||||
|
content: succeeded
|
||||||
|
? $localize`Operation completed.`
|
||||||
|
: result.status === PaperlessTaskStatus.Revoked
|
||||||
|
? $localize`Operation revoked.`
|
||||||
|
: $localize`Operation failed. Some changes may already have been applied.`,
|
||||||
|
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
|
||||||
|
? {
|
||||||
|
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,22 @@ 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,
|
||||||
|
$localize`Bulk operation 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 +60,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[]) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -1801,11 +1802,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)
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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,87 @@ 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.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 +244,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 +382,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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -348,6 +350,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 +380,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,
|
||||||
|
|||||||
+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